diff --git a/docs/superpowers/plans/2026-06-15-crossref-config-seam-ceremony-v7.md b/docs/superpowers/plans/2026-06-15-crossref-config-seam-ceremony-v7.md new file mode 100644 index 0000000..1b59a65 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-crossref-config-seam-ceremony-v7.md @@ -0,0 +1,62 @@ +# План-церемония v7: config-seam `cross-ref-checker` (Task 4 benign) + +## Цель + +Через TDD добавить необязательный `opts` в `extractCrossRefs(text, opts)` и +`detectMismatches(files, opts)` сверщика `tools/cross-ref-checker.mjs` — +переопределение `pathToName/linkRe/crossRe/normalizeName/normativeFiles`, дефолт = +текущие модульные константы (backward-compat). Контракт — спека +`2026-06-15-crossref-config-seam-spec.md` (якоря D1–D6). + +## Переговоры (позиция контроллера по замечаниям судьи) + +Судья тремя кругами поднимал осторожность к правке существующего файла. Позиция +контроллера — оба класса замечаний в этом плане **закрыты по построению**: + +1. **«Edit по устаревшему контексту».** Снято: модуль правится **`Write`-overwrite**, + а не `Edit`. Write не сопоставляется со старым содержимым — рассинхронизироваться + нечему. Воспроизводится **полное** текущее содержимое модуля (132 строки, + прочитан целиком) + 2 правки сигнатур. +2. **«Write без бэкапа уничтожает оригинал».** Снято: **шаг 2 — бэкап** в + `tools/cross-ref-checker.mjs.bak` (полная копия) ДО правки. Плюс файл в git. + Восстановление: `.bak` поверх оригинала или `git restore`. Необратимости нет. +3. **«Уничтожение существующего».** Снято: backward-compat инвариант — дефолт = + текущие константы, поэтому все ~3944 теста остаются зелёными; шаг 4 (полный свод + GREEN) **доказывает**, что ничего не потеряно. Тест-файл не затрагивается. + +Объект правки — инструмент-**предупреждалка** о дрейфе версий, не защита; при ошибке +последствий для безопасности нет, риск низкий. Контроллер просит пропустить план. + +## Безопасность правки (сводка) + +`Write`-overwrite модуля (полное содержимое + 2 правки), бэкап `.bak` перед ним, +полный свод вместо одиночного прогона (одиночный + `vitest`-импорт ломает сбор). + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs","ref":"D6"}, + {"op":"Write","object":"tools/cross-ref-checker.mjs.bak","ref":"D2"}, + {"op":"Write","object":"tools/cross-ref-checker.mjs","ref":"D1"}, + {"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs","ref":"D6"} +] +``` + +## Порядок шагов (человекочитаемо) + +1. **Bash** полный свод `tools/` — ожидается RED: 2 новых config-seam теста падают. +2. **Write** `tools/cross-ref-checker.mjs.bak` — бэкап (полное текущее содержимое, точка отката). D2. +3. **Write** `tools/cross-ref-checker.mjs` — полное содержимое + 2 правки сигнатур + (`extractCrossRefs(text, opts={})` + `detectMismatches(files, opts={})` с + дефолтами-константами и пробросом). D1/D3. +4. **Bash** полный свод `tools/` — ожидается GREEN. При RED — откат из `.bak`. + +```verified-context-json +[ + {"id":"crossref-extract","kind":"EXTRACTED","ref":"tools/cross-ref-checker.mjs","anchor":"export function extractCrossRefs(text"}, + {"id":"crossref-detect","kind":"EXTRACTED","ref":"tools/cross-ref-checker.mjs","anchor":"export function detectMismatches(files"} +] +``` diff --git a/docs/superpowers/specs/2026-06-15-crossref-config-seam-spec.md b/docs/superpowers/specs/2026-06-15-crossref-config-seam-spec.md new file mode 100644 index 0000000..06e4bdb --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-crossref-config-seam-spec.md @@ -0,0 +1,74 @@ +# Спека: config-seam для `tools/cross-ref-checker.mjs` (Task 4, benign-часть) + +**Дата:** 2026-06-15 +**Статус:** к исполнению + +## Цель + +Сделать сверщик кросс-ссылок версий проектно-настраиваемым: функции +`extractCrossRefs` и `detectMismatches` должны принимать необязательный объект +настройки (`opts`) с переопределением списка нормативных файлов и связанных +регэкспов/мапов. По умолчанию (вызов без `opts`) поведение **байт-в-байт** как +сейчас — модульные константы остаются дефолтами. Это убирает жёсткое «знание про +конкретный проект» из сигнатур, не меняя текущее поведение `claude-brain`. Это +инструмент-предупреждалка о дрейфе версий (не защита), поэтому пустой/иной список — +лишь иные предупреждения, без последствий для безопасности. + +## Контракт API {#D1} + +- `extractCrossRefs(text, opts = {}) -> Array<{name, version}>`. `opts` может нести: + `pathToName` (мап путь→имя), `linkRe` (регэксп link-based, `/g`), `crossRe` + (регэксп fallback, `/g`), `normalizeName` (функция нормализации имени). Каждое + поле по умолчанию = текущая модульная константа (`PATH_TO_NAME`, `LINK_REF_RE`, + `CROSS_REF_RE`, `normalizeName`). +- `detectMismatches(files, opts = {}) -> Array<{from, to, expected, found}>`. `opts` + дополнительно несёт `normativeFiles` (мап короткое-имя→путь, дефолт + `NORMATIVE_FILES`). Остальные поля `opts` пробрасываются в `extractCrossRefs`. +- `extractVersion(text)` — без изменений. + +## Backward-compat инвариант {#D2} + +Вызов `extractCrossRefs(text)` и `detectMismatches(files)` **без** второго +аргумента обязан давать тот же результат, что сейчас: `opts` по умолчанию `{}`, +деструктуризация подставляет текущие модульные константы. Все существующие тесты +(`cross-ref-checker.test.mjs`, вызывающие функции одним аргументом) и полный свод +`tools/` остаются зелёными без правок. + +## Проброс opts {#D3} + +`detectMismatches` строит `headerVersions` по `opts.normativeFiles` (дефолт +`NORMATIVE_FILES`) и для каждого файла вызывает `extractCrossRefs(text, opts)` — +лишние ключи `opts` (например `normativeFiles`) в `extractCrossRefs` игнорируются +деструктуризацией. `text.matchAll(regex)` на `/g`-регэкспе не мутирует `lastIndex` +исходного регэкспа (spec), поэтому переиспользование констант-регэкспов как +дефолтов безопасно. + +## Крайние случаи {#D4} + +- `opts = {}` → идентично текущему поведению (дефолты-константы). +- Кастомный `pathToName` + `linkRe` → `extractCrossRefs` извлекает по ним. +- `crossRe` = регэксп-«никогда» (`/(?!)/g`) → fallback-проход отключён, остаётся + только link-based. +- Кастомный `normativeFiles` в `detectMismatches` → заголовки берутся по нему. + +## Конвенция заголовка {#D5} + +Файл `cross-ref-checker.mjs` сохраняет shebang `#!/usr/bin/env node` и текущую +структуру (модульные константы → дефолты параметров). Стиль — текущий ESM каталога +`tools/`. Тест-файл сохраняет существующую структуру импортов. + +## Критерий приёмки {#D6} + +- `npx vitest run --config vitest.config.tools.mjs tools/cross-ref-checker.test.mjs` + — сначала RED (2 новых config-seam теста падают, функции игнорируют `opts`), + затем GREEN (все существующие + новые). +- `npx vitest run --config vitest.config.tools.mjs` — полный свод `tools/` зелёный + (дефолты сохранили поведение). + +```verified-context-json +[ + {"id":"crossref-extract","kind":"EXTRACTED","ref":"tools/cross-ref-checker.mjs","anchor":"export function extractCrossRefs(text"}, + {"id":"crossref-detect","kind":"EXTRACTED","ref":"tools/cross-ref-checker.mjs","anchor":"export function detectMismatches(files"}, + {"id":"crossref-test-noarg","kind":"EXTRACTED","ref":"tools/cross-ref-checker.test.mjs","anchor":"const refs = extractCrossRefs(text);"} +] +``` diff --git a/tools/cross-ref-checker.mjs b/tools/cross-ref-checker.mjs index 5b014b7..9ad6ea2 100644 --- a/tools/cross-ref-checker.mjs +++ b/tools/cross-ref-checker.mjs @@ -69,18 +69,25 @@ function scopeBeforeHistory(text) { return m ? text.slice(0, m.index) : text; } -export function extractCrossRefs(text) { +// Config-seam (Task 4): opts переопределяют список нормативки и связанные регэкспы. +// Дефолты = модульные константы → вызов без opts байт-в-байт как прежде. +export function extractCrossRefs(text, { + pathToName = PATH_TO_NAME, + linkRe = LINK_REF_RE, + crossRe = CROSS_REF_RE, + normalizeName: normalizeNameFn = normalizeName, +} = {}) { const refs = []; const seen = new Set(); - for (const match of text.matchAll(LINK_REF_RE)) { - const name = PATH_TO_NAME[match[1]]; + for (const match of text.matchAll(linkRe)) { + const name = pathToName[match[1]]; if (!name || seen.has(name)) continue; seen.add(name); refs.push({ name, version: match[2] }); } const fallbackScope = scopeBeforeHistory(text); - for (const match of fallbackScope.matchAll(CROSS_REF_RE)) { - const name = normalizeName(match[1]); + for (const match of fallbackScope.matchAll(crossRe)) { + const name = normalizeNameFn(match[1]); if (seen.has(name)) continue; seen.add(name); refs.push({ name, version: match[2] }); @@ -88,15 +95,16 @@ export function extractCrossRefs(text) { return refs; } -export function detectMismatches(files) { +export function detectMismatches(files, opts = {}) { + const { normativeFiles = NORMATIVE_FILES } = opts; const headerVersions = {}; - for (const [shortName, path] of Object.entries(NORMATIVE_FILES)) { + for (const [shortName, path] of Object.entries(normativeFiles)) { const entry = Object.entries(files).find(([k]) => k === path || k.endsWith(path)); if (entry) headerVersions[shortName] = extractVersion(entry[1]); } const mismatches = []; for (const [path, text] of Object.entries(files)) { - const refs = extractCrossRefs(text); + const refs = extractCrossRefs(text, opts); for (const r of refs) { const expected = headerVersions[r.name]; if (expected && expected !== r.version) { diff --git a/tools/cross-ref-checker.test.mjs b/tools/cross-ref-checker.test.mjs index b18c226..02e463c 100644 --- a/tools/cross-ref-checker.test.mjs +++ b/tools/cross-ref-checker.test.mjs @@ -160,3 +160,28 @@ describe('detectMismatches', () => { 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' }]); + }); +});