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