#!/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); }