Files
portal/tools/cross-ref-checker.mjs
T
Дмитрий 8ae0ecef25 feat(brain): C2 cross-ref-checker link-anchored detection — strict-ready
Closes the ~150 false drifts that prevented strict mode. The old regex
`\b(Name)\s+v(\d+\.\d+)` swept the whole file head and matched every
historical version mention, plus the FROM-side of arrow transitions
("v1.30→v1.31"). Real current-vs-header drift in the repo: zero.

Two-tier detection:
- Primary LINK_REF_RE: a markdown-link to a normative file followed by
  the first bold version — "[..](docs/Tooling_v8_3.md) (**Прил. Н
  v2.17**". Link anchor makes it immune to history-block noise. This is
  how CLAUDE.md §0 cross-refs table is written, so CLAUDE.md is fully
  validated. Runs on the whole file.
- Fallback CROSS_REF_RE: plain "Name vX.Y" mention, scoped to the text
  *before* the first history block. Pravila/Tooling/PSR_v1 have no
  markdown-link cross-refs, so the fallback covers them — but their
  shapki list past releases, so the scan stops at the first history
  marker (`**vN.M наследие**` / `**Что изменилось в vN.M относительно**`
  / `**vN.M** — `). dedupe-by-target keeps the first ref per target.

Regex hardening:
- `\b` after the version forbids backtracking to a partial capture
  (so "v1.30→" never collapses to a spurious "v1.3" match).
- `(?!\s*→)` negative lookahead drops the FROM-side of transitions.

TDD: 8 new tests (link-based, "Прил. Н" prefix, multi-file table,
dedupe, two arrow shapes, three history-marker shapes, link-beats-
fallback). 18/18 GREEN.
Smoke: node tools/cross-ref-checker.mjs -> exit 0, "OK — 0 drift in
4 files" (Pravila/CLAUDE.md/Tooling/PSR_v1; MEMORY.md is outside the
repo by design — existsSync-skipped).

Refs: ADR-011 brain governance §6.2 (C2 cross-ref consistency detector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:29:43 +03:00

133 lines
4.9 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;
}
export function extractCrossRefs(text) {
const refs = [];
const seen = new Set();
for (const match of text.matchAll(LINK_REF_RE)) {
const name = PATH_TO_NAME[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(CROSS_REF_RE)) {
const name = normalizeName(match[1]);
if (seen.has(name)) continue;
seen.add(name);
refs.push({ name, version: match[2] });
}
return refs;
}
export function detectMismatches(files) {
const headerVersions = {};
for (const [shortName, path] of Object.entries(NORMATIVE_FILES)) {
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);
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);
}