Files
portal/tools/cross-ref-checker.test.mjs
T
Дмитрий 8ae0ecef25 feat(brain): C2 cross-ref-checker link-anchored detection — strict-ready
Closes the ~150 false drifts that prevented strict mode. The old regex
`\b(Name)\s+v(\d+\.\d+)` swept the whole file head and matched every
historical version mention, plus the FROM-side of arrow transitions
("v1.30→v1.31"). Real current-vs-header drift in the repo: zero.

Two-tier detection:
- Primary LINK_REF_RE: a markdown-link to a normative file followed by
  the first bold version — "[..](docs/Tooling_v8_3.md) (**Прил. Н
  v2.17**". Link anchor makes it immune to history-block noise. This is
  how CLAUDE.md §0 cross-refs table is written, so CLAUDE.md is fully
  validated. Runs on the whole file.
- Fallback CROSS_REF_RE: plain "Name vX.Y" mention, scoped to the text
  *before* the first history block. Pravila/Tooling/PSR_v1 have no
  markdown-link cross-refs, so the fallback covers them — but their
  shapki list past releases, so the scan stops at the first history
  marker (`**vN.M наследие**` / `**Что изменилось в vN.M относительно**`
  / `**vN.M** — `). dedupe-by-target keeps the first ref per target.

Regex hardening:
- `\b` after the version forbids backtracking to a partial capture
  (so "v1.30→" never collapses to a spurious "v1.3" match).
- `(?!\s*→)` negative lookahead drops the FROM-side of transitions.

TDD: 8 new tests (link-based, "Прил. Н" prefix, multi-file table,
dedupe, two arrow shapes, three history-marker shapes, link-beats-
fallback). 18/18 GREEN.
Smoke: node tools/cross-ref-checker.mjs -> exit 0, "OK — 0 drift in
4 files" (Pravila/CLAUDE.md/Tooling/PSR_v1; MEMORY.md is outside the
repo by design — existsSync-skipped).

Refs: ADR-011 brain governance §6.2 (C2 cross-ref consistency detector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 07:29:43 +03:00

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