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(); }); });