feat(brain-config): cross-ref имена config-driven (greenfield #3 cross-ref)

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>
This commit is contained in:
Дмитрий
2026-06-16 08:16:27 +03:00
parent a16023743c
commit deb504988a
4 changed files with 350 additions and 3 deletions
@@ -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).
@@ -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)` → массив `/<stem>/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` (стем → `(?:^|/)<stem>[^/]*\.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 + этот дизайн.
+33 -3
View File
@@ -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);
+38
View File
@@ -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();
});
});