Files
brain/tools/cross-ref-checker.test.mjs
T
Дмитрий deb504988a feat(brain-config): cross-ref имена config-driven (greenfield #3 cross-ref)
docStem снимает версию; buildCrossRefPatterns строит linkRe/crossRe/normalizeName/pathToName из normative_files плюс DEFAULT_ALIASES; CLI спредит в detectMismatches. Дефолт 5 доков = детект как хардкод; greenfield распознаёт свои доки. shell/observer — отдельные планы.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:16:27 +03:00

241 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' });
});
it('link-based primary: first bold version after markdown-link to normative file', () => {
const text =
'| Правила | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.31 от 19.05.2026** — описание; v1.30 наследие; v1.29 наследие) |';
const refs = extractCrossRefs(text);
expect(refs).toEqual([{ name: 'Pravila', version: '1.31' }]);
});
it('link-based primary: extracts version even if bold contains preceding words (e.g. "Прил. Н")', () => {
const text =
'| Реестр | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (**Прил. Н v2.17 от 19.05.2026** — описание) |';
const refs = extractCrossRefs(text);
expect(refs).toEqual([{ name: 'Tooling', version: '2.17' }]);
});
it('link-based primary: §0 table with multiple normative files', () => {
const text = [
'| Pravila | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.31** ...) |',
'| Tooling | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (**Прил. Н v2.17** ...) |',
'| PSR_v1 | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (**v3.16** ...) |',
].join('\n');
const refs = extractCrossRefs(text);
expect(refs).toEqual([
{ name: 'Pravila', version: '1.31' },
{ name: 'Tooling', version: '2.17' },
{ name: 'PSR_v1', version: '3.16' },
]);
});
it('dedupe per target: only first ref per target name is kept', () => {
const text =
'header: Tooling v2.17; legacy: Tooling v2.16 наследие; older: Tooling v2.15 наследие';
const refs = extractCrossRefs(text);
expect(refs.filter((r) => r.name === 'Tooling')).toEqual([
{ name: 'Tooling', version: '2.17' },
]);
});
it('skips left side of arrow transition "v1.30→**v1.31**"', () => {
const text = 'cross-refs: Pravila v1.30→**v1.31**';
const refs = extractCrossRefs(text);
expect(refs.filter((r) => r.name === 'Pravila')).toHaveLength(0);
});
it('skips arrow with whitespace: "v1.30 → v1.31"', () => {
const text = 'transition: Pravila v1.30 → v1.31 happened today';
const refs = extractCrossRefs(text);
expect(refs.filter((r) => r.name === 'Pravila')).toHaveLength(0);
});
it('link-based wins over fallback when both present', () => {
const text = [
'Body mentions Pravila v1.25 in some sentence.',
'| Pravila | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.31** current) |',
].join('\n');
const refs = extractCrossRefs(text);
expect(refs.filter((r) => r.name === 'Pravila')).toEqual([
{ name: 'Pravila', version: '1.31' },
]);
});
it('fallback scope-cuts at first "**vN.M наследие**" history marker', () => {
const text =
'header refs: Tooling v2.17\n\n**v2.16 наследие:** old refs Tooling v2.15';
const refs = extractCrossRefs(text);
expect(refs).toEqual([{ name: 'Tooling', version: '2.17' }]);
});
it('fallback scope-cuts at first "**Что изменилось в vN.M относительно**" marker', () => {
const text = [
'**Версия:** v1.31',
'',
'**Что изменилось в v1.31 относительно v1.30:** ADR-011',
'',
'**Что изменилось в v1.29 относительно v1.28:** Связано: Tooling v2.15',
].join('\n');
const refs = extractCrossRefs(text);
expect(refs.filter((r) => r.name === 'Tooling')).toEqual([]);
});
it('fallback scope-cuts at first "**vN.M** — " bold-dash marker', () => {
const text = '**v3.16** — current changes\n**v3.15** — Связано: Tooling v2.15';
const refs = extractCrossRefs(text);
expect(refs.filter((r) => r.name === 'Tooling')).toEqual([]);
});
it('link-based catches refs even when scope cut would exclude them', () => {
const text = [
'shapka with **v2.17 наследие** description body',
'| Pravila | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.31** current) |',
].join('\n');
const refs = extractCrossRefs(text);
expect(refs).toContainEqual({ name: 'Pravila', version: '1.31' });
});
});
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);
});
it('ignores «наследие» chain when current link-ref matches header', () => {
const files = {
'CLAUDE.md':
'**Версия:** 2.18 — §0 cross-refs Pravila v1.30→**v1.31** / Tooling v2.16→**v2.17**\n\n| Правила | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.31** current; v1.30 наследие; v1.29 наследие) |',
'docs/Pravila_raboty_Claude_v1_1.md': '**Версия:** v1.31 — current',
};
expect(detectMismatches(files)).toEqual([]);
});
it('detects real drift when link-based cross-ref is stale', () => {
const files = {
'CLAUDE.md':
'| Pravila | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.30** description) |',
'docs/Pravila_raboty_Claude_v1_1.md': '**Версия:** v1.31 — current',
};
const m = detectMismatches(files);
expect(m).toHaveLength(1);
expect(m[0].from).toBe('CLAUDE.md');
expect(m[0].to).toBe('Pravila');
expect(m[0].found).toBe('1.30');
expect(m[0].expected).toBe('1.31');
});
});
describe('config-seam (opts override)', () => {
it('extractCrossRefs honors custom pathToName + linkRe', () => {
const text = '| Foo | [docs/Foo.md](docs/Foo.md) (**v9.9** current) |';
const linkRe = /\[[^\]]+\]\((docs\/Foo\.md)\)(?:[^\n*]{0,200}?)\*\*[^*\n]*?v(\d+\.\d+(?:\.\d+)?)/g;
const pathToName = { 'docs/Foo.md': 'Foo' };
const refs = extractCrossRefs(text, { pathToName, linkRe, crossRe: /(?!)/g });
expect(refs).toEqual([{ name: 'Foo', version: '9.9' }]);
});
it('detectMismatches honors custom normativeFiles + crossRe', () => {
const files = {
'A.md': '# A — refs: Foo v9.8',
'docs/Foo.md': '# Foo v9.9',
};
const m = detectMismatches(files, {
normativeFiles: { Foo: 'docs/Foo.md' },
crossRe: /\b(Foo)\s+v(\d+\.\d+(?:\.\d+)?)\b(?!\s*→)/g,
linkRe: /(?!)/g,
pathToName: {},
normalizeName: (x) => x,
});
expect(m).toEqual([{ from: 'A.md', to: 'Foo', expected: '9.9', found: '9.8' }]);
});
});
import { buildNormativeMap } from './cross-ref-checker.mjs';
describe('buildNormativeMap (Task 7 follow-up)', () => {
it('config-список встроенные CLAUDE/MEMORY; дефолт claude-brain = 5', () => {
const m = buildNormativeMap(['docs/Pravila_raboty_Claude_v1_1.md', 'docs/Plugin_stack_rules_v1.md', 'docs/Tooling_v8_3.md']);
expect(m.Pravila).toBe('docs/Pravila_raboty_Claude_v1_1.md');
expect(m.PSR_v1).toBe('docs/Plugin_stack_rules_v1.md');
expect(m.Tooling).toBe('docs/Tooling_v8_3.md');
expect(m.CLAUDE).toBe('CLAUDE.md');
expect(m.MEMORY).toBe('MEMORY.md');
});
it('пустой список → только universal', () => {
expect(buildNormativeMap([])).toEqual({ CLAUDE: 'CLAUDE.md', MEMORY: 'MEMORY.md' });
});
});
import { docStem, buildCrossRefPatterns } from './cross-ref-checker.mjs';
describe('docStem (#3 cross-ref)', () => {
it('срезает директорию, .md и хвост версии', () => {
expect(docStem('docs/Pravila_raboty_Claude_v1_1.md')).toBe('Pravila_raboty_Claude');
expect(docStem('docs/Tooling_v8_3.md')).toBe('Tooling');
expect(docStem('docs/Plugin_stack_rules_v1.md')).toBe('Plugin_stack_rules');
expect(docStem('MyRules_v2.md')).toBe('MyRules');
expect(docStem('CLAUDE.md')).toBe('CLAUDE');
expect(docStem('docs/Report_2024.md')).toBe('Report_2024');
});
});
describe('buildCrossRefPatterns (#3 cross-ref)', () => {
const map = buildNormativeMap(['docs/Pravila_raboty_Claude_v1_1.md', 'docs/Plugin_stack_rules_v1.md', 'docs/Tooling_v8_3.md']);
const grab = (re, nm, t) => [...t.matchAll(re)].map((m) => nm(m[1]));
it('детектит имена и алиасы; PSR_v1 раньше PSR; алиас → канон', () => {
const { crossRe, normalizeName } = buildCrossRefPatterns(map);
expect(grab(crossRe, normalizeName, 'PSR_v1 v3.24')).toEqual(['PSR_v1']);
expect(grab(crossRe, normalizeName, 'PSR v3.24')).toEqual(['PSR_v1']);
expect(grab(crossRe, normalizeName, 'Pravila v1.44')).toEqual(['Pravila']);
});
it('pathToName — инверсия карты', () => {
expect(buildCrossRefPatterns(map).pathToName['docs/Tooling_v8_3.md']).toBe('Tooling');
});
it('greenfield: имена из карты, без алиасов', () => {
const { crossRe, normalizeName } = buildCrossRefPatterns({ MyRules: 'docs/MyRules_v2.md' }, {});
expect(grab(crossRe, normalizeName, 'MyRules v1.0')).toEqual(['MyRules']);
});
});
describe('buildNormativeMap greenfield fallback (#3)', () => {
it('имя без версии (docStem), не basename с версией', () => {
const m = buildNormativeMap(['docs/MyRules_v2.md']);
expect(m.MyRules).toBe('docs/MyRules_v2.md');
expect(m.MyRules_v2).toBeUndefined();
});
});