feat(controller): C2 cross-ref-checker — version drift detector (DONE_WITH_CONCERNS)
Pure regex/JSON, 0 LLM calls. 5 Vitest tests GREEN (23/23 total). Per ADR-011 + spec §6.2. Smoke run on real repo surfaces ~150 «drifts» — these are **historical 'наследие' entries** in headers (CLAUDE.md / Pravila / Tooling / PSR_v1), not actual current cross-ref mismatches. Each of these 4 files has a multi-line «v2.X наследие:» / «v1.Y наследие:» chain in its top header describing past sub-versions; my 50-line scan picks them all up. CONCERN: mechanism is correct (test fixtures pass), but real-world needs refinement before lefthook wiring (C5). Options for follow-up: - Scope match to explicit «§0 cross-refs» table marker. - Distinguish «current cross-ref» from «historical наследие mention» by surrounding markup. - Restrict regex to cross-ref tables (markdown | columns) only. Until refined: C2 will be wired in C5 with caveat (WARN-only, or disabled) to avoid blocking every commit on pre-existing 'наследие' entries. Extracted Tooling Прил. Н version via **Версия:** pattern (file-level v8.3 wrapper at line 1 was misleading — Прил. Н is v2.17 at line 4). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user