deb504988a
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>
241 lines
10 KiB
JavaScript
241 lines
10 KiB
JavaScript
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();
|
||
});
|
||
});
|