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