diff --git a/tools/cross-ref-checker.mjs b/tools/cross-ref-checker.mjs new file mode 100644 index 00000000..076e79e9 --- /dev/null +++ b/tools/cross-ref-checker.mjs @@ -0,0 +1,84 @@ +#!/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 VERSION_RE = /v(\d+\.\d+(?:\.\d+)?)/; +const CROSS_REF_RE = /\b(Pravila|CLAUDE|Tooling|PSR_v1|MEMORY|PSR|Plugin_stack_rules|Pravila_raboty_Claude)\s+v(\d+\.\d+(?:\.\d+)?)/g; +const VERSIYA_RE = /\*\*Версия:\*\*\s*v?(\d+\.\d+(?:\.\d+)?)/; +const CROSS_REF_SCAN_LINES = 50; + +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; +} + +export function extractCrossRefs(text, maxLines = CROSS_REF_SCAN_LINES) { + const top = text.split('\n').slice(0, maxLines).join('\n'); + const refs = []; + for (const match of top.matchAll(CROSS_REF_RE)) { + let name = match[1]; + if (name === 'PSR' || name === 'Plugin_stack_rules') name = 'PSR_v1'; + if (name === 'Pravila_raboty_Claude') name = 'Pravila'; + 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); +} diff --git a/tools/cross-ref-checker.test.mjs b/tools/cross-ref-checker.test.mjs new file mode 100644 index 00000000..82cbd587 --- /dev/null +++ b/tools/cross-ref-checker.test.mjs @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { extractVersion, extractCrossRefs, detectMismatches } from './cross-ref-checker.mjs'; + +describe('extractVersion', () => { + it('extracts version from header', () => { + const text = '# Pravila v1.30 от 18.05.2026'; + expect(extractVersion(text)).toBe('1.30'); + }); + + it('returns null if no version', () => { + expect(extractVersion('# Untitled')).toBeNull(); + }); +}); + +describe('extractCrossRefs', () => { + it('extracts cross-refs like "Pravila v1.29"', () => { + const text = 'See Pravila v1.29 and Tooling v2.15.'; + const refs = extractCrossRefs(text); + expect(refs).toContainEqual({ name: 'Pravila', version: '1.29' }); + expect(refs).toContainEqual({ name: 'Tooling', version: '2.15' }); + }); +}); + +describe('detectMismatches', () => { + it('detects when fileA references fileB v1.29 but fileB header is v1.30', () => { + const files = { + 'A.md': '# A v1.0 — cross-refs: Pravila v1.29', + 'docs/Pravila_raboty_Claude_v1_1.md': '# Pravila v1.30', + }; + const m = detectMismatches(files); + expect(m).toHaveLength(1); + expect(m[0].from).toBe('A.md'); + expect(m[0].to).toBe('Pravila'); + expect(m[0].expected).toBe('1.30'); + expect(m[0].found).toBe('1.29'); + }); + + it('passes when in sync', () => { + const files = { + 'A.md': '# A v1.0 — Pravila v1.30', + 'docs/Pravila_raboty_Claude_v1_1.md': '# Pravila v1.30', + }; + expect(detectMismatches(files)).toHaveLength(0); + }); +});