Files
brain/tools/cross-ref-checker.mjs
T
2026-06-15 11:42:26 +03:00

141 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
const NORMATIVE_FILES = {
Pravila: 'docs/Pravila_raboty_Claude_v1_1.md',
CLAUDE: 'CLAUDE.md',
Tooling: 'docs/Tooling_v8_3.md',
PSR_v1: 'docs/Plugin_stack_rules_v1.md',
MEMORY: 'MEMORY.md',
};
const PATH_TO_NAME = {
'docs/Pravila_raboty_Claude_v1_1.md': 'Pravila',
'docs/Tooling_v8_3.md': 'Tooling',
'docs/Plugin_stack_rules_v1.md': 'PSR_v1',
'CLAUDE.md': 'CLAUDE',
'MEMORY.md': 'MEMORY',
};
const VERSION_RE = /v(\d+\.\d+(?:\.\d+)?)/;
const VERSIYA_RE = /\*\*Версия:\*\*\s*v?(\d+\.\d+(?:\.\d+)?)/;
// Primary: markdown-link to normative file followed by first **vN.M** (bold
// may contain preceding words like "Прил. Н"). Anchored on link → precision.
const LINK_REF_RE =
/\[[^\]]+\]\((docs\/Pravila_raboty_Claude_v1_1\.md|docs\/Tooling_v8_3\.md|docs\/Plugin_stack_rules_v1\.md|CLAUDE\.md|MEMORY\.md)\)(?:[^\n*]{0,200}?)\*\*[^*\n]*?v(\d+\.\d+(?:\.\d+)?)/g;
// Fallback: plain "Name vX.Y" mention. `\b` after the version forbids partial
// captures via backtracking (so "v1.30" never collapses to "v1.3"). Negative
// lookahead skips the left side of transitions ("v1.30→v1.31") since that is
// the FROM-side, not a current cross-ref.
const CROSS_REF_RE =
/\b(Pravila|CLAUDE|Tooling|PSR_v1|MEMORY|PSR|Plugin_stack_rules|Pravila_raboty_Claude)\s+v(\d+\.\d+(?:\.\d+)?)\b(?!\s*→)/g;
// History-block marker. Normative shapki list past releases after the current
// one; the fallback scan must stop before the first such block, otherwise it
// picks up stale "наследие" cross-refs. Three shapes are used across the files:
// "**v2.16 наследие:**" — CLAUDE.md / Tooling
// "**Что изменилось в v1.29 относительно**" — Pravila
// "**v3.15** — ..." — PSR_v1 / changelog entries
const HISTORY_MARKER_RE =
/\*\*(?:v\d+\.\d+(?:\.\d+)?\s+наследие|Что изменилось в v\d+\.\d+(?:\.\d+)?\s+относительно|v\d+\.\d+(?:\.\d+)?[^*\n]{0,80}?\*\*\s+—)/;
export function extractVersion(text) {
const head = text.split('\n').slice(0, 30);
for (const line of head) {
const m = line.match(VERSIYA_RE);
if (m) return m[1];
}
for (const line of head.slice(0, 10)) {
const m = line.match(VERSION_RE);
if (m) return m[1];
}
return null;
}
function normalizeName(raw) {
if (raw === 'PSR' || raw === 'Plugin_stack_rules') return 'PSR_v1';
if (raw === 'Pravila_raboty_Claude') return 'Pravila';
return raw;
}
// Truncate the text before the first history block. Applied to the fallback
// scan only — link-based detection runs on the whole file (a markdown-link to
// a normative file is precise enough that history blocks cannot pollute it).
function scopeBeforeHistory(text) {
const m = text.match(HISTORY_MARKER_RE);
return m ? text.slice(0, m.index) : text;
}
// Config-seam (Task 4): opts переопределяют список нормативки и связанные регэкспы.
// Дефолты = модульные константы → вызов без opts байт-в-байт как прежде.
export function extractCrossRefs(text, {
pathToName = PATH_TO_NAME,
linkRe = LINK_REF_RE,
crossRe = CROSS_REF_RE,
normalizeName: normalizeNameFn = normalizeName,
} = {}) {
const refs = [];
const seen = new Set();
for (const match of text.matchAll(linkRe)) {
const name = pathToName[match[1]];
if (!name || seen.has(name)) continue;
seen.add(name);
refs.push({ name, version: match[2] });
}
const fallbackScope = scopeBeforeHistory(text);
for (const match of fallbackScope.matchAll(crossRe)) {
const name = normalizeNameFn(match[1]);
if (seen.has(name)) continue;
seen.add(name);
refs.push({ name, version: match[2] });
}
return refs;
}
export function detectMismatches(files, opts = {}) {
const { normativeFiles = NORMATIVE_FILES } = opts;
const headerVersions = {};
for (const [shortName, path] of Object.entries(normativeFiles)) {
const entry = Object.entries(files).find(([k]) => k === path || k.endsWith(path));
if (entry) headerVersions[shortName] = extractVersion(entry[1]);
}
const mismatches = [];
for (const [path, text] of Object.entries(files)) {
const refs = extractCrossRefs(text, opts);
for (const r of refs) {
const expected = headerVersions[r.name];
if (expected && expected !== r.version) {
mismatches.push({ from: path, to: r.name, expected, found: r.version });
}
}
}
return mismatches;
}
function loadFiles(root = process.cwd()) {
const out = {};
for (const [, path] of Object.entries(NORMATIVE_FILES)) {
const abs = join(root, path);
if (existsSync(abs)) out[path] = readFileSync(abs, 'utf-8');
}
return out;
}
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/cross-ref-checker.mjs')) {
const files = loadFiles();
const m = detectMismatches(files);
if (m.length === 0) {
console.log(`[cross-ref-checker] OK — 0 drift in ${Object.keys(files).length} files`);
process.exit(0);
}
console.error(`[cross-ref-checker] FAIL — version drift detected:`);
for (const x of m) {
console.error(` ${x.from} references ${x.to} v${x.found}, but ${x.to} header is v${x.expected}`);
}
console.error(`Update cross-refs in offending files.`);
process.exit(1);
}