From deb504988a845f78a4a93e3ac197a710d8f62821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 16 Jun 2026 08:16:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(brain-config):=20cross-ref=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20config-driven=20(greenfield=20#3=20cross-r?= =?UTF-8?q?ef)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...-06-16-greenfield-cross-ref-config-plan.md | 181 ++++++++++++++++++ ...16-greenfield-regex-names-config-design.md | 98 ++++++++++ tools/cross-ref-checker.mjs | 36 +++- tools/cross-ref-checker.test.mjs | 38 ++++ 4 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-16-greenfield-cross-ref-config-plan.md create mode 100644 docs/superpowers/specs/2026-06-16-greenfield-regex-names-config-design.md diff --git a/docs/superpowers/plans/2026-06-16-greenfield-cross-ref-config-plan.md b/docs/superpowers/plans/2026-06-16-greenfield-cross-ref-config-plan.md new file mode 100644 index 0000000..a9809bd --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-greenfield-cross-ref-config-plan.md @@ -0,0 +1,181 @@ +# greenfield cross-ref config-driven имена — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (инлайн под стеной — субагенты запрещены VA-4; production-правки под TDD-gate override + floor-escape). Steps — checkbox (`- [ ]`). + +**Goal:** Сделать regex-имена нормативных доков в `cross-ref-checker` производными от `normative_files` (config), сохранив поведение claude-brain байт-в-байт. + +**Architecture:** Спека `2026-06-15... → 2026-06-16-greenfield-regex-names-config-design.md` (Подход A). Новый pure `docStem` (снятие версии) + `buildCrossRefPatterns(map, aliases)` строит `{linkRe,crossRe,normalizeName,pathToName}` из config-карты; `DEFAULT_ALIASES` — маленький лидерровский дефолт. `extractCrossRefs`/`detectMismatches` уже принимают эти regex (шов Task 4) — CLI строит и спредит в opts. Дефолт (claude-brain 5 доков) → детект идентичен хардкоду. **Scope: только cross-ref** (shell/observer — отдельные планы). + +**Tech Stack:** Node ESM (.mjs), vitest (`tools/`). + +--- + +## File Structure + +- Modify: `tools/cross-ref-checker.mjs` — добавить `docStem` (export), `DEFAULT_ALIASES`, `buildCrossRefPatterns` (export); `buildNormativeMap` fallback → `docStem`; CLI wiring. +- Modify: `tools/cross-ref-checker.test.mjs` — тесты `docStem`, `buildCrossRefPatterns`, `buildNormativeMap` docStem-fallback. + +--- + +## Task 1: `docStem` — снятие версии (pure) + +**Files:** `tools/cross-ref-checker.mjs`, `tools/cross-ref-checker.test.mjs` + +- [ ] **Step 1 (RED test):** в `cross-ref-checker.test.mjs` добавить (импорт `docStem` к существующему импорту из `./cross-ref-checker.mjs`): + +```javascript +import { docStem } 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'); // не версия (нет _v) + }); +}); +``` + +- [ ] **Step 2:** Run RED — `npx vitest run --config vitest.config.tools.mjs tools/cross-ref-checker.test.mjs` → FAIL «docStem is not a function». + +- [ ] **Step 3 (impl):** в `cross-ref-checker.mjs` после `import`-ов (до `NORMATIVE_FILES`) добавить: + +```javascript +// Снять директорию, расширение .md и хвост версии (_v1_1 / _v8_3) → стем дока. +// Общий для greenfield short-name (cross-ref) и file-stem (shell/observer — отдельные планы). +export function docStem(path) { + const base = String(path || '').replace(/\\/g, '/').split('/').pop() || ''; + return base.replace(/\.md$/i, '').replace(/_v\d+(?:[._]\d+)*$/i, ''); +} +``` + +- [ ] **Step 4:** Run GREEN — тот же прогон → PASS docStem. + +--- + +## Task 2: `DEFAULT_ALIASES` + `buildCrossRefPatterns` (pure) + +**Files:** `tools/cross-ref-checker.mjs`, `tools/cross-ref-checker.test.mjs` + +- [ ] **Step 1 (RED test):** добавить: + +```javascript +import { buildCrossRefPatterns } from './cross-ref-checker.mjs'; +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']); + }); +}); +``` + +- [ ] **Step 2:** Run RED → FAIL «buildCrossRefPatterns is not a function». + +- [ ] **Step 3 (impl):** в `cross-ref-checker.mjs` после `PATH_TO_NAME`/`UNIVERSAL_VERSION_TRACKED` добавить: + +```javascript +// Лидерра-специфичные алиасы (короткие/файловые формы → канон). Дефолт; greenfield без алиасов. +const DEFAULT_ALIASES = Object.freeze({ PSR: 'PSR_v1', Plugin_stack_rules: 'PSR_v1', Pravila_raboty_Claude: 'Pravila' }); + +function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +// Построить regex/normalizeName из карты {имя:путь} ∪ алиасы. Альтернативы — длинные раньше +// коротких (prefix-safety: PSR_v1 до PSR). Дефолт-карта (Лидерра 5) → детект как хардкод-regex. +export function buildCrossRefPatterns(normativeMap = {}, aliases = DEFAULT_ALIASES) { + const names = Object.keys(normativeMap); + const paths = Object.values(normativeMap); + const pathToName = {}; + for (const [name, path] of Object.entries(normativeMap)) pathToName[path] = name; + const byLenDesc = (a, b) => b.length - a.length; + const linkAlt = paths.map(escapeRe).sort(byLenDesc).join('|'); + const linkRe = new RegExp( + `\\[[^\\]]+\\]\\((${linkAlt})\\)(?:[^\\n*]{0,200}?)\\*\\*[^*\\n]*?v(\\d+\\.\\d+(?:\\.\\d+)?)`, 'g'); + const nameAlt = [...new Set([...names, ...Object.keys(aliases)])].map(escapeRe).sort(byLenDesc).join('|'); + const crossRe = new RegExp(`\\b(${nameAlt})\\s+v(\\d+\\.\\d+(?:\\.\\d+)?)\\b(?!\\s*→)`, 'g'); + const normalizeName = (raw) => aliases[raw] || raw; + return { linkRe, crossRe, normalizeName, pathToName }; +} +``` + +- [ ] **Step 4:** Run GREEN → PASS. + +--- + +## Task 3: `buildNormativeMap` fallback → `docStem` + +**Files:** `tools/cross-ref-checker.mjs`, `tools/cross-ref-checker.test.mjs` + +- [ ] **Step 1 (RED test):** добавить: + +```javascript +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(); + }); +}); +``` + +- [ ] **Step 2:** Run RED → FAIL (`m.MyRules` undefined, имя было `MyRules_v2`). + +- [ ] **Step 3 (impl):** в `buildNormativeMap` заменить строку fallback: + +```javascript + const name = PATH_TO_NAME[p] || docStem(p); +``` + +(была `PATH_TO_NAME[p] || String(p).split('/').pop().replace(/\.md$/, '')`.) + +- [ ] **Step 4:** Run GREEN → PASS (новые + прежние buildNormativeMap-тесты: claude-brain имена из PATH_TO_NAME, не затронуты). + +--- + +## Task 4: CLI wiring — строить patterns из config + +**Files:** `tools/cross-ref-checker.mjs` + +- [ ] **Step 1 (impl):** в CLI-блоке (после try/catch с `normativeMap`, перед `loadFiles`) вставить построение patterns и спред в opts: + +```javascript + const patterns = buildCrossRefPatterns(normativeMap); + const files = loadFiles(process.cwd(), normativeMap); + const m = detectMismatches(files, { normativeFiles: normativeMap, ...patterns }); +``` + +(заменяет текущие строки 147-148 `const files = ...` + `const m = detectMismatches(files, { normativeFiles: normativeMap });`. `detectMismatches` форвардит opts в `extractCrossRefs` — построенные linkRe/crossRe/normalizeName/pathToName применяются. claude-brain map=5 → детект идентичен.) + +- [ ] **Step 2 (verify):** владелец в терминале: `npx vitest run --config vitest.config.tools.mjs tools/cross-ref-checker.test.mjs` → PASS (новые + все прежние ~20). Затем полный свод → без регрессий. + +--- + +## Task 5: Полный свод + коммит (терминал владельца) + +- [ ] **Step 1:** `npx vitest run --config vitest.config.tools.mjs` → зелёный, новых падений нет. +- [ ] **Step 2:** commit: + +```bash +git add tools/cross-ref-checker.mjs tools/cross-ref-checker.test.mjs docs/superpowers/specs/2026-06-16-greenfield-regex-names-config-design.md docs/superpowers/plans/2026-06-16-greenfield-cross-ref-config-plan.md docs/superpowers/plans/2026-06-16-classifier-context-wiring-plan.md +git commit -m "feat(brain-config): cross-ref имена config-driven (greenfield #3 cross-ref)" +``` + +--- + +## Self-Review + +- **Spec coverage:** §3 (cross-ref) — Task 1 (docStem §2), Task 2 (buildCrossRefPatterns + DEFAULT_ALIASES + prefix-safety), Task 3 (buildNormativeMap docStem-fallback §2), Task 4 (CLI). §4/§5 (shell/observer) — вне scope (отдельные планы). §6 fail-safe: пустой config → `buildNormativeMap([])` = universal-only → patterns строятся из CLAUDE/MEMORY (тест «greenfield»/прежние покрывают форму). +- **Placeholders:** нет; весь код реальный. +- **Type consistency:** `docStem`/`buildCrossRefPatterns`/`DEFAULT_ALIASES`/`escapeRe` — единые имена; `{linkRe,crossRe,normalizeName,pathToName}` совпадает с opts `extractCrossRefs`. +- **Стена:** production-правки `cross-ref-checker.mjs` под TDD-gate override + floor-escape (как #2); тест-правки `.test.mjs` — gate молчит (не production). Полный свод + коммит — терминал владельца (harness-collapse). diff --git a/docs/superpowers/specs/2026-06-16-greenfield-regex-names-config-design.md b/docs/superpowers/specs/2026-06-16-greenfield-regex-names-config-design.md new file mode 100644 index 0000000..99a4089 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-greenfield-regex-names-config-design.md @@ -0,0 +1,98 @@ +# Дизайн: greenfield-hardening regex-имён нормативки из config (Task 7 follow-up #3) + +**Дата:** 2026-06-16 +**Статус:** Approved (brainstorming, владелец одобрил «ок») +**Контекст:** третий пункт остатка Фазы 1 (handoff №5 §6). Дизайн §7 normative-files-config-model +ссылался на «Подход #2 из brainstorming» без деталей — подход (пере)установлен здесь. +**Масштаб (решение владельца):** minimal-safe prep перед Фазой 2 (конкретного гринфилд-наката нет), +YAGNI. **Подход A** (config-имена + алиасы-дефолт), НЕ богатая настройка (B). + +--- + +## 1. Цель и контракт + +Захардкоженные имена/стемы нормативных доков (`Pravila`/`PSR_v1`/`Tooling` + сокращения) в 3 движковых +файлах выводятся из настройки `normative_files`, чтобы движок распознавал нормативку **чужого** проекта. + +**Контракт:** дефолты = текущие лидерровские значения → `claude-brain` (Лидерра) ведёт себя **байт-в-байт** +по поведению (детект cross-ref, protected-set, norm-классификация неизменны). Greenfield с другими именами +доков распознаётся по своей настройке. Пустой config → universal-only / база (fail-safe, не падение). + +**Граница (YAGNI):** короткое имя (`Pravila`) generically из пути НЕ выводится — для greenfield short-name = +basename. Алиасы (`PSR`↔`PSR_v1`, `Pravila_raboty_Claude`↔`Pravila`) — только лидерровские, как маленький +хардкод-дефолт. Это «minimal», не богатая per-doc config-модель (отклонённый B). + +## 2. Производные имён из пути (общий алгоритм версии) + +`docStem(path)` — общий алгоритм снятия версии: basename → срезать `.md` → срезать хвост версии +`_v<цифры>(_<цифры>)*` (формы `_v1_1`, `_v8_3`): `docs/Pravila_raboty_Claude_v1_1.md` → `Pravila_raboty_Claude`. + +Из пути выводятся ДВЕ формы (нужны разным потребителям): +- **file-stem** = `docStem(path)` — `Pravila_raboty_Claude`. Потребители: shell-content-rules (protect), + observer (norm-классификация). +- **short-name** = `PATH_TO_NAME[path]` (Лидерра: `Pravila`) **иначе** `docStem(path)` (greenfield). + Версионный хвост СРЕЗАН → `MyRules`, не `MyRules_v2` — это снимает неоднозначность «basename с версией» + (замечание наставника): greenfield short-name всегда без версии. Потребитель: cross-ref (version-tracking). + +`buildNormativeMap` (§3) обновляет fallback с `.replace(/\.md$/, '')` (оставлял версию) на `docStem`. +claude-brain не затронут — его имена из `PATH_TO_NAME`; тестов на version-keeping fallback нет. + +## 3. Компонент 1 — cross-ref-checker.mjs + +- Новая чистая `buildCrossRefPatterns(normativeMap, aliases = DEFAULT_ALIASES)` → `{ linkRe, crossRe, + normalizeName, pathToName }`: + - `pathToName` — инверсия `normativeMap` ({path: name}). + - `linkRe` — alternation путей (`Object.values(normativeMap)`, экранированы). + - `crossRe` — alternation имён ∪ ключей `aliases`, отсортированных **длинные-раньше** (prefix-safety: + `PSR_v1` до `PSR`) → детект как сейчас. + - `normalizeName` — `aliases[raw] || raw` (config-имена — identity; лидерровские алиасы → канон). +- `DEFAULT_ALIASES = { PSR: 'PSR_v1', Plugin_stack_rules: 'PSR_v1', Pravila_raboty_Claude: 'Pravila' }` + (хардкод-дефолт, только лидерровские). +- `extractCrossRefs`/`detectMismatches` уже принимают `linkRe`/`crossRe`/`normalizeName`/`pathToName` + параметрами (шов Task 4) — CLI строит через `buildCrossRefPatterns(buildNormativeMap(loadConfig().normative_files))` + и передаёт. Дефолт (claude-brain map = 5) → behavior-идентичен текущим хардкод-regex. + +## 4. Компонент 2 — shell-content-rules.mjs + +- `buildProtectedPatterns(configPaths)` уже UNION база + config (§D2). Добавить: нормативные защитные + паттерны выводятся из `normative_files` (file-stem) и попадают в augment; **убрать** хардкод 32-34 + (`/Pravila_raboty_Claude/`, `/Plugin_stack_rules/`, `/Tooling_v8_3/`) из `DEFAULT_PROTECTED_PATTERNS`. +- Реализация: новая чистая `normativeStemPatterns(normativeFiles)` → массив `//i`; CLI/потребитель + передаёт их (вместе с `protected_paths`) в `buildProtectedPatterns`. Общая база (CLAUDE/memory/.env/ + settings/…) — без изменений. +- claude-brain: `normative_files` (3) → стемы = текущие 32-34 → защищены как сейчас. Greenfield: его доки + защищены. **Fail-safe:** пустой `normative_files` → нормативных стемов нет, база защищает universal. + +## 5. Компонент 3 — observer-transcript-parser.mjs + +- `classifyFilePath(path, normativeStems = DEFAULT_NORMATIVE_STEMS)` — 371-373 заменяются циклом по + `normativeStems` (стем → `(?:^|/)[^/]*\.md$` → `'norm'`). Дефолт = текущие 3 стема. +- Универсальные (`CLAUDE.md`/`MEMORY.md`/`memory/`, 370/375/376) + `Открытые_вопросы` (374) — хардкод + (универсальные / Лидерра-спец, YAGNI). +- §/R citation-паттерны (529-531/578-579, `Pravila §N` / `PSR_v1 RN`) — **не трогаем**: только добавляют + сигнал, greenfield без них безопасен (Лидерра-нотация). +- Wiring: потребитель `classifyFilePath` (CLI/caller) выводит стемы из `loadConfig().normative_files`. + +## 6. Fail-safe (стиль §5.1) + +| Ключ | Пусто/нет | Направление | +|---|---|---| +| `normative_files` (cross-ref) | crossRe/linkRe = только universal (CLAUDE/MEMORY) | safe degrade | +| `normative_files` (shell) | нормативных стемов нет → база защищает universal | safe (no false-block) | +| `normative_files` (observer) | стемов нет → norm-классификация только universal | safe degrade | + +## 7. Testing (TDD) + +Чистые builder-функции — RED→GREEN: +- `buildCrossRefPatterns` — дефолт-карта даёт детект как текущий хардкод; greenfield-карта даёт свои имена; + prefix-safety (PSR_v1 до PSR); пустой → universal-only. +- `normativeStemPatterns` — стемы из путей (версия/расширение срезаны); пустой → []. +- `classifyFilePath` с кастомными стемами — greenfield-док → 'norm'; дефолт = текущие 3. +Wiring CLI/caller — config-seam (как #2): `await import` + try/catch fallback на дефолт. Полный свод + +коммит — терминал владельца (harness-collapse под Claude-Bash). + +## 8. Исполнение под стеной + +Production-файлы → `enforce-tdd-gate` (floor-escape не снимает) + harness-collapse (RED не достать) → +правки кода под TDD-gate override + floor-escape per file (как #2). Pure builder-функции с TDD-тестами; +тесты — `.test.mjs` (не production, gate молчит). Канон: design v6 + план Фазы 1 + handoff №5 + этот дизайн. diff --git a/tools/cross-ref-checker.mjs b/tools/cross-ref-checker.mjs index bedff54..ab75a12 100644 --- a/tools/cross-ref-checker.mjs +++ b/tools/cross-ref-checker.mjs @@ -21,12 +21,41 @@ const PATH_TO_NAME = { // Универсальные version-tracked доки (есть у любого проекта; не в настройке). const UNIVERSAL_VERSION_TRACKED = Object.freeze({ CLAUDE: 'CLAUDE.md', MEMORY: 'MEMORY.md' }); +// Снять директорию, расширение .md и хвост версии (_v1_1 / _v8_3) → стем дока. +// Общий для greenfield short-name (cross-ref) и file-stem (shell/observer — отдельные планы). +export function docStem(path) { + const base = String(path || '').replace(/\\/g, '/').split('/').pop() || ''; + return base.replace(/\.md$/i, '').replace(/_v\d+(?:[._]\d+)*$/i, ''); +} + +// Лидерра-специфичные алиасы (короткие/файловые формы → канон). Дефолт; greenfield без алиасов. +const DEFAULT_ALIASES = Object.freeze({ PSR: 'PSR_v1', Plugin_stack_rules: 'PSR_v1', Pravila_raboty_Claude: 'Pravila' }); + +function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + +// Построить regex/normalizeName из карты {имя:путь} ∪ алиасы. Альтернативы — длинные раньше +// коротких (prefix-safety: PSR_v1 до PSR). Дефолт-карта (Лидерра 5) → детект как хардкод-regex. +export function buildCrossRefPatterns(normativeMap = {}, aliases = DEFAULT_ALIASES) { + const names = Object.keys(normativeMap); + const paths = Object.values(normativeMap); + const pathToName = {}; + for (const [name, path] of Object.entries(normativeMap)) pathToName[path] = name; + const byLenDesc = (a, b) => b.length - a.length; + const linkAlt = paths.map(escapeRe).sort(byLenDesc).join('|'); + const linkRe = new RegExp( + `\\[[^\\]]+\\]\\((${linkAlt})\\)(?:[^\\n*]{0,200}?)\\*\\*[^*\\n]*?v(\\d+\\.\\d+(?:\\.\\d+)?)`, 'g'); + const nameAlt = [...new Set([...names, ...Object.keys(aliases)])].map(escapeRe).sort(byLenDesc).join('|'); + const crossRe = new RegExp(`\\b(${nameAlt})\\s+v(\\d+\\.\\d+(?:\\.\\d+)?)\\b(?!\\s*→)`, 'g'); + const normalizeName = (raw) => aliases[raw] || raw; + return { linkRe, crossRe, normalizeName, pathToName }; +} + // Собрать {name:path} из проектного списка (config) ∪ universal. Имя — из PATH_TO_NAME, -// иначе basename без .md (greenfield; regex-имена — отдельный заход, дизайн §4.1/§7). +// иначе docStem (greenfield: без версии; #3 cross-ref). export function buildNormativeMap(normativeFilesList = []) { const map = {}; for (const p of (Array.isArray(normativeFilesList) ? normativeFilesList : [])) { - const name = PATH_TO_NAME[p] || String(p).split('/').pop().replace(/\.md$/, ''); + const name = PATH_TO_NAME[p] || docStem(p); map[name] = p; } return { ...map, ...UNIVERSAL_VERSION_TRACKED }; @@ -144,8 +173,9 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/cross-ref- const { loadConfig } = await import('./brain-config.mjs'); normativeMap = buildNormativeMap(loadConfig().normative_files); } catch { /* brain-config недоступен → дефолт NORMATIVE_FILES */ } + const patterns = buildCrossRefPatterns(normativeMap); const files = loadFiles(process.cwd(), normativeMap); - const m = detectMismatches(files, { normativeFiles: normativeMap }); + const m = detectMismatches(files, { normativeFiles: normativeMap, ...patterns }); if (m.length === 0) { console.log(`[cross-ref-checker] OK — 0 drift in ${Object.keys(files).length} files`); process.exit(0); diff --git a/tools/cross-ref-checker.test.mjs b/tools/cross-ref-checker.test.mjs index a5ae0af..0e944e4 100644 --- a/tools/cross-ref-checker.test.mjs +++ b/tools/cross-ref-checker.test.mjs @@ -200,3 +200,41 @@ describe('buildNormativeMap (Task 7 follow-up)', () => { 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(); + }); +});