Files
brain/tools/cross-ref-checker.mjs
T
Дмитрий deb504988a feat(brain-config): cross-ref имена config-driven (greenfield #3 cross-ref)
docStem снимает версию; buildCrossRefPatterns строит linkRe/crossRe/normalizeName/pathToName из normative_files плюс DEFAULT_ALIASES; CLI спредит в detectMismatches. Дефолт 5 доков = детект как хардкод; greenfield распознаёт свои доки. shell/observer — отдельные планы.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:16:27 +03:00

190 lines
8.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',
};
// Универсальные version-tracked доки (есть у любого проекта; не в настройке).
const UNIVERSAL_VERSION_TRACKED = Object.freeze({ CLAUDE: 'CLAUDE.md', MEMORY: 'MEMORY.md' });
// Снять директорию, расширение .md и хвост версии (_v1_1 / _v8_3) → стем дока.
// Общий для greenfield short-name (cross-ref) и file-stem (shell/observer — отдельные планы).
export function docStem(path) {
const base = String(path || '').replace(/\\/g, '/').split('/').pop() || '';
return base.replace(/\.md$/i, '').replace(/_v\d+(?:[._]\d+)*$/i, '');
}
// Лидерра-специфичные алиасы (короткие/файловые формы → канон). Дефолт; greenfield без алиасов.
const DEFAULT_ALIASES = Object.freeze({ PSR: 'PSR_v1', Plugin_stack_rules: 'PSR_v1', Pravila_raboty_Claude: 'Pravila' });
function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
// Построить regex/normalizeName из карты {имя:путь} ∪ алиасы. Альтернативы — длинные раньше
// коротких (prefix-safety: PSR_v1 до PSR). Дефолт-карта (Лидерра 5) → детект как хардкод-regex.
export function buildCrossRefPatterns(normativeMap = {}, aliases = DEFAULT_ALIASES) {
const names = Object.keys(normativeMap);
const paths = Object.values(normativeMap);
const pathToName = {};
for (const [name, path] of Object.entries(normativeMap)) pathToName[path] = name;
const byLenDesc = (a, b) => b.length - a.length;
const linkAlt = paths.map(escapeRe).sort(byLenDesc).join('|');
const linkRe = new RegExp(
`\\[[^\\]]+\\]\\((${linkAlt})\\)(?:[^\\n*]{0,200}?)\\*\\*[^*\\n]*?v(\\d+\\.\\d+(?:\\.\\d+)?)`, 'g');
const nameAlt = [...new Set([...names, ...Object.keys(aliases)])].map(escapeRe).sort(byLenDesc).join('|');
const crossRe = new RegExp(`\\b(${nameAlt})\\s+v(\\d+\\.\\d+(?:\\.\\d+)?)\\b(?!\\s*→)`, 'g');
const normalizeName = (raw) => aliases[raw] || raw;
return { linkRe, crossRe, normalizeName, pathToName };
}
// Собрать {name:path} из проектного списка (config) universal. Имя — из PATH_TO_NAME,
// иначе docStem (greenfield: без версии; #3 cross-ref).
export function buildNormativeMap(normativeFilesList = []) {
const map = {};
for (const p of (Array.isArray(normativeFilesList) ? normativeFilesList : [])) {
const name = PATH_TO_NAME[p] || docStem(p);
map[name] = p;
}
return { ...map, ...UNIVERSAL_VERSION_TRACKED };
}
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(), normativeMap = NORMATIVE_FILES) {
const out = {};
for (const [, path] of Object.entries(normativeMap)) {
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')) {
let normativeMap = NORMATIVE_FILES;
try {
const { loadConfig } = await import('./brain-config.mjs');
normativeMap = buildNormativeMap(loadConfig().normative_files);
} catch { /* brain-config недоступен → дефолт NORMATIVE_FILES */ }
const patterns = buildCrossRefPatterns(normativeMap);
const files = loadFiles(process.cwd(), normativeMap);
const m = detectMismatches(files, { normativeFiles: normativeMap, ...patterns });
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);
}