2026-06-15 08:06:08 +03:00
|
|
|
|
#!/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',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-16 04:24:46 +03:00
|
|
|
|
// Универсальные version-tracked доки (есть у любого проекта; не в настройке).
|
|
|
|
|
|
const UNIVERSAL_VERSION_TRACKED = Object.freeze({ CLAUDE: 'CLAUDE.md', MEMORY: 'MEMORY.md' });
|
|
|
|
|
|
|
2026-06-16 08:16:27 +03:00
|
|
|
|
// Снять директорию, расширение .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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 04:24:46 +03:00
|
|
|
|
// Собрать {name:path} из проектного списка (config) ∪ universal. Имя — из PATH_TO_NAME,
|
2026-06-16 08:16:27 +03:00
|
|
|
|
// иначе docStem (greenfield: без версии; #3 cross-ref).
|
2026-06-16 04:24:46 +03:00
|
|
|
|
export function buildNormativeMap(normativeFilesList = []) {
|
|
|
|
|
|
const map = {};
|
|
|
|
|
|
for (const p of (Array.isArray(normativeFilesList) ? normativeFilesList : [])) {
|
2026-06-16 08:16:27 +03:00
|
|
|
|
const name = PATH_TO_NAME[p] || docStem(p);
|
2026-06-16 04:24:46 +03:00
|
|
|
|
map[name] = p;
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...map, ...UNIVERSAL_VERSION_TRACKED };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 08:06:08 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 11:42:26 +03:00
|
|
|
|
// 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,
|
|
|
|
|
|
} = {}) {
|
2026-06-15 08:06:08 +03:00
|
|
|
|
const refs = [];
|
|
|
|
|
|
const seen = new Set();
|
2026-06-15 11:42:26 +03:00
|
|
|
|
for (const match of text.matchAll(linkRe)) {
|
|
|
|
|
|
const name = pathToName[match[1]];
|
2026-06-15 08:06:08 +03:00
|
|
|
|
if (!name || seen.has(name)) continue;
|
|
|
|
|
|
seen.add(name);
|
|
|
|
|
|
refs.push({ name, version: match[2] });
|
|
|
|
|
|
}
|
|
|
|
|
|
const fallbackScope = scopeBeforeHistory(text);
|
2026-06-15 11:42:26 +03:00
|
|
|
|
for (const match of fallbackScope.matchAll(crossRe)) {
|
|
|
|
|
|
const name = normalizeNameFn(match[1]);
|
2026-06-15 08:06:08 +03:00
|
|
|
|
if (seen.has(name)) continue;
|
|
|
|
|
|
seen.add(name);
|
|
|
|
|
|
refs.push({ name, version: match[2] });
|
|
|
|
|
|
}
|
|
|
|
|
|
return refs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 11:42:26 +03:00
|
|
|
|
export function detectMismatches(files, opts = {}) {
|
|
|
|
|
|
const { normativeFiles = NORMATIVE_FILES } = opts;
|
2026-06-15 08:06:08 +03:00
|
|
|
|
const headerVersions = {};
|
2026-06-15 11:42:26 +03:00
|
|
|
|
for (const [shortName, path] of Object.entries(normativeFiles)) {
|
2026-06-15 08:06:08 +03:00
|
|
|
|
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)) {
|
2026-06-15 11:42:26 +03:00
|
|
|
|
const refs = extractCrossRefs(text, opts);
|
2026-06-15 08:06:08 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 04:24:46 +03:00
|
|
|
|
function loadFiles(root = process.cwd(), normativeMap = NORMATIVE_FILES) {
|
2026-06-15 08:06:08 +03:00
|
|
|
|
const out = {};
|
2026-06-16 04:24:46 +03:00
|
|
|
|
for (const [, path] of Object.entries(normativeMap)) {
|
2026-06-15 08:06:08 +03:00
|
|
|
|
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')) {
|
2026-06-16 04:24:46 +03:00
|
|
|
|
let normativeMap = NORMATIVE_FILES;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { loadConfig } = await import('./brain-config.mjs');
|
|
|
|
|
|
normativeMap = buildNormativeMap(loadConfig().normative_files);
|
|
|
|
|
|
} catch { /* brain-config недоступен → дефолт NORMATIVE_FILES */ }
|
2026-06-16 08:16:27 +03:00
|
|
|
|
const patterns = buildCrossRefPatterns(normativeMap);
|
2026-06-16 04:24:46 +03:00
|
|
|
|
const files = loadFiles(process.cwd(), normativeMap);
|
2026-06-16 08:16:27 +03:00
|
|
|
|
const m = detectMismatches(files, { normativeFiles: normativeMap, ...patterns });
|
2026-06-15 08:06:08 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|