diff --git a/docs/registry/capability-vocabulary.json b/docs/registry/capability-vocabulary.json new file mode 100644 index 0000000..7a2664d --- /dev/null +++ b/docs/registry/capability-vocabulary.json @@ -0,0 +1,15 @@ +{ + "version": "0.1.0", + "tokens": [ + {"token": "running-portal", "label": "работающий портал (DAST-цель)", "description": "Запущенный веб-портал как цель динамической проверки."}, + {"token": "laravel-config", "label": "конфигурация Laravel", "description": "Конфиг приложения Laravel (заголовки, debug, права)."}, + {"token": "pii-inventory-task", "label": "задача аудита ПДн", "description": "Запрос на аудит персональных данных по 152-ФЗ."}, + {"token": "portal-pre-launch", "label": "портал перед публикацией", "description": "Портал на стадии подготовки к выходу в интернет."}, + {"token": "dast-report", "label": "отчёт DAST", "description": "Результат динамической атаки на живой портал (инъекции/XSS/обход входа/IDOR)."}, + {"token": "cve-exposure-scan", "label": "скан CVE/экспозиции", "description": "Результат проверки известных уязвимостей и небезопасной внешней экспозиции."}, + {"token": "laravel-config-audit", "label": "аудит конфигурации Laravel", "description": "Результат проверки безопасности конфигурации Laravel."}, + {"token": "pii-152fz-audit", "label": "аудит ПДн/152-ФЗ", "description": "Результат аудита персональных данных на соответствие 152-ФЗ."}, + {"token": "stride-model", "label": "STRIDE-модель угроз", "description": "Карта attack surface и приоритеты защиты перед публикацией."}, + {"token": "go-live-verdict", "label": "вердикт go-live", "description": "Сводный вердикт GO/NO-GO по безопасности перед публикацией."} + ] +} diff --git a/docs/registry/contracts/nuclei.contract.json b/docs/registry/contracts/nuclei.contract.json index dd55f65..6341224 100644 --- a/docs/registry/contracts/nuclei.contract.json +++ b/docs/registry/contracts/nuclei.contract.json @@ -1,8 +1,8 @@ { "skill": "nuclei", "kind": "external", - "needs": ["работающий хост для скана по шаблонам"], - "produces": ["находки известных уязвимостей (CVE/экспозиция/слабый TLS/misconfig)"], + "needs": ["running-portal"], + "produces": ["cve-exposure-scan"], "constraints": ["CLI bin/nuclei.exe; цель 127.0.0.1 не localhost", "ADR-014 IS2 НЕ ZAP #68 (широта vs глубина)"], "preview-form": "none", "defaults": ["низкий rate-limit для dev"], diff --git a/docs/registry/contracts/owasp-zap.contract.json b/docs/registry/contracts/owasp-zap.contract.json index 38da366..2d18e25 100644 --- a/docs/registry/contracts/owasp-zap.contract.json +++ b/docs/registry/contracts/owasp-zap.contract.json @@ -1,8 +1,8 @@ { "skill": "owasp-zap", "kind": "external", - "needs": ["работающий веб-портал для DAST"], - "produces": ["DAST-отчёт (инъекции/XSS/обход входа/IDOR)"], + "needs": ["running-portal"], + "produces": ["dast-report"], "constraints": ["активное динамическое тестирование; цель 127.0.0.1", "ADR-014: IS1 НЕ Semgrep #25 (динамика vs статика), IS2 НЕ Nuclei #69 (глубина vs широта)"], "preview-form": "none", "defaults": ["цель 127.0.0.1, не localhost"], diff --git a/docs/registry/contracts/pdn-152fz-audit.contract.json b/docs/registry/contracts/pdn-152fz-audit.contract.json index aafa159..6d0359b 100644 --- a/docs/registry/contracts/pdn-152fz-audit.contract.json +++ b/docs/registry/contracts/pdn-152fz-audit.contract.json @@ -1,8 +1,8 @@ { "skill": "pdn-152fz-audit", "kind": "own", - "needs": ["задача аудита ПДн / 152-ФЗ"], - "produces": ["аудит ПДн: инвентаризация, согласия, маскирование, логи доступа, pd_subject_request"], + "needs": ["pii-inventory-task"], + "produces": ["pii-152fz-audit"], "constraints": ["self-authored; режим техника + закон", "ADR-014: IS4 НЕ pg_anonymizer #29 (аудит vs инструмент), IS5 НЕ D2 (техника vs юр.оформление)"], "preview-form": "outline", "defaults": ["инвентаризация ПДн в схеме/коде → проверка соответствия"], diff --git a/docs/registry/contracts/security-go-live.contract.json b/docs/registry/contracts/security-go-live.contract.json index 7a20419..32978f8 100644 --- a/docs/registry/contracts/security-go-live.contract.json +++ b/docs/registry/contracts/security-go-live.contract.json @@ -1,8 +1,8 @@ { "skill": "security-go-live", "kind": "own", - "needs": ["портал перед выходом в интернет (go/no-go)"], - "produces": ["вердикт GO/NO-GO + сводка проверок безопасности"], + "needs": ["dast-report","cve-exposure-scan","laravel-config-audit","pii-152fz-audit","stride-model"], + "produces": ["go-live-verdict"], "constraints": ["self-authored оркестратор #68-72 + D3", "ADR-014 IS7 НЕ audit-portal (только security+go-live vs полный 14-фазный аудит)"], "preview-form": "outline", "defaults": ["оркеструет ZAP/Nuclei/Ward/pdn-152fz/threat-model + Semgrep"], diff --git a/docs/registry/contracts/threat-model.contract.json b/docs/registry/contracts/threat-model.contract.json index e49bb99..68d0d16 100644 --- a/docs/registry/contracts/threat-model.contract.json +++ b/docs/registry/contracts/threat-model.contract.json @@ -1,8 +1,8 @@ { "skill": "threat-model", "kind": "own", - "needs": ["портал для моделирования угроз перед публикацией"], - "produces": ["STRIDE-модель: attack surface + приоритеты защиты"], + "needs": ["portal-pre-launch"], + "produces": ["stride-model"], "constraints": ["self-authored; STRIDE going-public", "ADR-014 IS6 НЕ Trail of Bits #39 (портал+STRIDE vs generic deep-audit)"], "preview-form": "outline", "defaults": ["карта точек входа → приоритизация по STRIDE"], diff --git a/docs/registry/contracts/ward.contract.json b/docs/registry/contracts/ward.contract.json index 5c9e251..2fe1f49 100644 --- a/docs/registry/contracts/ward.contract.json +++ b/docs/registry/contracts/ward.contract.json @@ -1,8 +1,8 @@ { "skill": "ward", "kind": "external", - "needs": ["Laravel-проект (.env/config/cookie/secrets/deps)"], - "produces": ["аудит-отчёт безопасности настроек Laravel"], + "needs": ["laravel-config"], + "produces": ["laravel-config-audit"], "constraints": ["Go CLI bin/ward.exe; заменил Enlightn (abandoned)", "ADR-014 IS3 НЕ Larastan #12/Semgrep #25"], "preview-form": "none", "defaults": ["скан .env/config/заголовков/secrets"], diff --git a/docs/superpowers/plans/2026-06-18-coverage-vocabulary-a8-prototype.md b/docs/superpowers/plans/2026-06-18-coverage-vocabulary-a8-prototype.md new file mode 100644 index 0000000..adf2bc4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-coverage-vocabulary-a8-prototype.md @@ -0,0 +1,140 @@ +# Прототип словаря охвата на кластере A8 — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Доказать на 6 реальных контрактах A8, что единый словарь токенов `needs/produces` оживляет спящий `coverage-machine` (граф строится, порядок осмыслен, ложных дыр нет). + +**Architecture:** Вводим файл-словарь `capability-vocabulary.json`; переводим `needs/produces` 6 контрактов A8 с прозы на токены словаря; пишем тест, который прогоняет `coverage-machine` на этих контрактах и проверяет граф/порядок/дыры. Код `coverage-machine` НЕ трогаем — только данные + тест. + +**Tech Stack:** Node ESM, vitest, существующий `tools/coverage-machine.mjs`. + +--- + +## Цель + +Прототип-валидация (риск-смягчение §8 спеки v2): на кластере A8 показать, что согласованный словарь токенов делает `buildDependencyGraph`/`topoOrder`/`findHoles` рабочими на реальных контрактах. Без этого раскатка на ~150 контрактов рискованна. + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Write","object":"tools/coverage-prototype-a8.test.mjs","ref":"#D3"}, + {"op":"Bash","object":"npx vitest run tools/coverage-prototype-a8.test.mjs","ref":"#D3"}, + {"op":"Write","object":"docs/registry/capability-vocabulary.json","ref":"#D3"}, + {"op":"Edit","object":"docs/registry/contracts/owasp-zap.contract.json","ref":"#D3"}, + {"op":"Edit","object":"docs/registry/contracts/nuclei.contract.json","ref":"#D3"}, + {"op":"Edit","object":"docs/registry/contracts/ward.contract.json","ref":"#D3"}, + {"op":"Edit","object":"docs/registry/contracts/pdn-152fz-audit.contract.json","ref":"#D3"}, + {"op":"Edit","object":"docs/registry/contracts/threat-model.contract.json","ref":"#D3"}, + {"op":"Edit","object":"docs/registry/contracts/security-go-live.contract.json","ref":"#D3"}, + {"op":"Bash","object":"npx vitest run tools/coverage-prototype-a8.test.mjs","ref":"#D3"} +] +``` + +```verified-context-json +[ + {"id":"cm-graph","kind":"EXTRACTED","ref":"tools/coverage-machine.mjs","anchor":"export function buildDependencyGraph("}, + {"id":"cm-topo","kind":"EXTRACTED","ref":"tools/coverage-machine.mjs","anchor":"export function topoOrder("}, + {"id":"cm-holes","kind":"EXTRACTED","ref":"tools/coverage-machine.mjs","anchor":"export function findHoles("}, + {"id":"zap-contract","kind":"EXTRACTED","ref":"docs/registry/contracts/owasp-zap.contract.json","anchor":"\"skill\": \"owasp-zap\""} +] +``` + +--- + +### Task 1: Тест-прототип (RED) + +**Files:** +- Create: `tools/coverage-prototype-a8.test.mjs` + +- [ ] **Шаг 1: Написать падающий тест** + +```js +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { buildDependencyGraph, topoOrder, findHoles } from './coverage-machine.mjs'; + +const A8 = ['owasp-zap', 'nuclei', 'ward', 'pdn-152fz-audit', 'threat-model', 'security-go-live']; +const load = (slug) => JSON.parse(readFileSync(`docs/registry/contracts/${slug}.contract.json`, 'utf8')); + +describe('A8 coverage prototype — единый словарь оживляет автомат', () => { + const contracts = A8.map(load); + + it('граф непуст: ≥5 рёбер сходятся в security-go-live', () => { + const { edges } = buildDependencyGraph(contracts); + const toGoLive = edges.filter((e) => e.to === 'security-go-live'); + expect(toGoLive.length).toBeGreaterThanOrEqual(5); + }); + + it('topoOrder: security-go-live идёт последним', () => { + const { order } = topoOrder(contracts); + expect(order).not.toBeNull(); + expect(order[order.length - 1]).toBe('security-go-live'); + }); + + it('findHoles: при объявленных initialInputs дыр нет (без constraints)', () => { + const initialInputs = ['running-portal', 'laravel-config', 'pii-inventory-task', 'portal-pre-launch']; + const holes = findHoles(contracts, { initialInputs, includeConstraints: false }); + expect(holes).toEqual([]); + }); +}); +``` + +- [ ] **Шаг 2: Прогнать тест — убедиться, что падает** + +Run: `npx vitest run tools/coverage-prototype-a8.test.mjs` +Expected: FAIL — первый тест (рёбер 0, т.к. `needs/produces` ещё проза и не совпадают по словам). + +### Task 2: Словарь токенов + +**Files:** +- Create: `docs/registry/capability-vocabulary.json` + +- [ ] **Шаг 3: Создать словарь capability-токенов кластера A8** + +```json +{ + "version": "0.1.0", + "tokens": [ + {"token": "running-portal", "label": "работающий портал (DAST-цель)", "description": "Запущенный веб-портал как цель динамической проверки."}, + {"token": "laravel-config", "label": "конфигурация Laravel", "description": "Конфиг приложения Laravel (заголовки, debug, права)."}, + {"token": "pii-inventory-task", "label": "задача аудита ПДн", "description": "Запрос на аудит персональных данных по 152-ФЗ."}, + {"token": "portal-pre-launch", "label": "портал перед публикацией", "description": "Портал на стадии подготовки к выходу в интернет."}, + {"token": "dast-report", "label": "отчёт DAST", "description": "Результат динамической атаки на живой портал (инъекции/XSS/обход входа/IDOR)."}, + {"token": "cve-exposure-scan", "label": "скан CVE/экспозиции", "description": "Результат проверки известных уязвимостей и небезопасной внешней экспозиции."}, + {"token": "laravel-config-audit", "label": "аудит конфигурации Laravel", "description": "Результат проверки безопасности конфигурации Laravel."}, + {"token": "pii-152fz-audit", "label": "аудит ПДн/152-ФЗ", "description": "Результат аудита персональных данных на соответствие 152-ФЗ."}, + {"token": "stride-model", "label": "STRIDE-модель угроз", "description": "Карта attack surface и приоритеты защиты перед публикацией."}, + {"token": "go-live-verdict", "label": "вердикт go-live", "description": "Сводный вердикт GO/NO-GO по безопасности перед публикацией."} + ] +} +``` + +### Task 3: Перевод контрактов A8 на токены (GREEN) + +В каждом контракте меняем ТОЛЬКО поля `needs` и `produces` на токены словаря. Остальные поля (`skill`, `kind`, `constraints`, `description`/визитка, `acceptance-criteria` и т.д.) НЕ трогаем. + +- [ ] **Шаг 4: `owasp-zap.contract.json`** — `"needs": ["running-portal"]`, `"produces": ["dast-report"]` +- [ ] **Шаг 5: `nuclei.contract.json`** — `"needs": ["running-portal"]`, `"produces": ["cve-exposure-scan"]` +- [ ] **Шаг 6: `ward.contract.json`** — `"needs": ["laravel-config"]`, `"produces": ["laravel-config-audit"]` +- [ ] **Шаг 7: `pdn-152fz-audit.contract.json`** — `"needs": ["pii-inventory-task"]`, `"produces": ["pii-152fz-audit"]` +- [ ] **Шаг 8: `threat-model.contract.json`** — `"needs": ["portal-pre-launch"]`, `"produces": ["stride-model"]` +- [ ] **Шаг 9: `security-go-live.contract.json`** — `"needs": ["dast-report","cve-exposure-scan","laravel-config-audit","pii-152fz-audit","stride-model"]`, `"produces": ["go-live-verdict"]` + +### Task 4: Зелёный прогон + +- [ ] **Шаг 10: Прогнать тест — убедиться, что проходит** + +Run: `npx vitest run tools/coverage-prototype-a8.test.mjs` +Expected: PASS — все три теста зелёные (граф 5 рёбер → security-go-live; topo go-live последним; дыр нет). + +--- + +## Само-ревью (по навыку) + +- **Покрытие спеки:** план реализует §3 спеки v2 (OPEN-1 словарь) в режиме прототипа (§8 риск-смягчение «прототип сначала»). Раскатка на 150 и расщепление комков — вне этого плана (отдельные планы). +- **Плейсхолдеры:** нет — тест-код, словарь и точные `needs/produces` всех 6 контрактов приведены целиком. +- **Согласованность типов:** токены в словаре (Task 2) дословно совпадают с `needs/produces` контрактов (Task 3) и с `initialInputs` теста (Task 1) — проверено вручную: producer.produces == consumer.needs для всех пяти рёбер к `security-go-live`. +- **Регрессия:** `coverage-machine.mjs` не меняется (только используется); правки — данные + новый тест, поэтому узкого прогона достаточно. diff --git a/docs/superpowers/plans/2026-06-18-vocabulary-validation-gate-plan.md b/docs/superpowers/plans/2026-06-18-vocabulary-validation-gate-plan.md new file mode 100644 index 0000000..579dd6b --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-vocabulary-validation-gate-plan.md @@ -0,0 +1,357 @@ +# Замок словаря: неизвестный токен → ошибка сборки — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Сделать `capability-vocabulary.json` единственным источником допустимых capability-токенов: неизвестный токен в `needs`/`produces` контракта валит сборку реестра (спека v2 §3, OPEN-1). + +**Architecture:** Новый чистый модуль `tools/capability-vocabulary.mjs` (загрузка+валидация словаря, сверка токенов контракта). Замок вшит в `buildRegistry`/`loadRegistry` как **опциональный** параметр `vocabTokens`: передан словарь → неизвестный токен отбраковывает контракт в `errors`; не передан → поведение прежнее (обратная совместимость для ~144 ещё-прозовых контрактов и живых тестов m3a/m3e). Глобальное умолчание «всегда со словарём» переворачивается отдельной задачей после переезда всех контрактов. + +**Tech Stack:** Node ESM, vitest, существующие `tools/skill-contract-registry.mjs` + `tools/skill-contract.mjs`. + +--- + +## Цель + +Запереть фундамент словаря ДО массового ввода данных: ни один контракт не сможет молча сослаться на токен, которого нет в `capability-vocabulary.json`. Это закрывает корень «пустого графа» (рассогласование `needs/produces`) на уровне сборки — рассогласование теперь невозможно протащить незаметно. + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Write","object":"tools/capability-vocabulary.test.mjs","ref":"#D3"}, + {"op":"Bash","object":"npx vitest run tools/capability-vocabulary.test.mjs","ref":"#D3"}, + {"op":"Write","object":"tools/capability-vocabulary.mjs","ref":"#D3"}, + {"op":"Bash","object":"npx vitest run tools/capability-vocabulary.test.mjs","ref":"#D3"}, + {"op":"Edit","object":"tools/skill-contract-registry.mjs","ref":"#D3"}, + {"op":"Write","object":"tools/registry-vocab-gate.test.mjs","ref":"#D3"}, + {"op":"Bash","object":"npx vitest run tools/registry-vocab-gate.test.mjs","ref":"#D3"}, + {"op":"Bash","object":"npx vitest run tools/m3a-contract-invariants.test.mjs tools/m3e-card-coverage-invariants.test.mjs tools/skill-contract-registry.test.mjs tools/card-coverage.test.mjs tools/coverage-prototype-a8.test.mjs","ref":"#D3"} +] +``` + +```verified-context-json +[ + {"id":"reg-build","kind":"EXTRACTED","ref":"tools/skill-contract-registry.mjs","anchor":"export function buildRegistry("}, + {"id":"reg-load","kind":"EXTRACTED","ref":"tools/skill-contract-registry.mjs","anchor":"export function loadRegistry("}, + {"id":"contract-validate","kind":"EXTRACTED","ref":"tools/skill-contract.mjs","anchor":"export function validateContract("}, + {"id":"vocab-file","kind":"EXTRACTED","ref":"docs/registry/capability-vocabulary.json","anchor":"\"version\": \"0.1.0\""} +] +``` + +--- + +### Task 1: Модуль словаря — загрузка и валидация формы + +**Files:** +- Create: `tools/capability-vocabulary.mjs` +- Test: `tools/capability-vocabulary.test.mjs` + +- [ ] **Шаг 1: Написать падающий тест** + +```js +import { describe, it, expect } from 'vitest'; +import { validateVocabulary, unknownTokens } from './capability-vocabulary.mjs'; + +describe('validateVocabulary — форма словаря', () => { + const good = { version: '0.1.0', tokens: [ + { token: 'dast-report', label: 'отчёт DAST', description: 'результат динамики' }, + { token: 'stride-model', label: 'STRIDE', description: 'модель угроз' }, + ] }; + + it('валидный словарь → ok + Set токенов', () => { + const r = validateVocabulary(good); + expect(r.ok).toBe(true); + expect(r.errors).toEqual([]); + expect(r.tokens.has('dast-report')).toBe(true); + expect(r.tokens.size).toBe(2); + }); + + it('не-объект / нет tokens-массива → ошибка', () => { + expect(validateVocabulary(null).ok).toBe(false); + expect(validateVocabulary({ version: '1' }).ok).toBe(false); + }); + + it('токен не kebab-case → ошибка', () => { + const r = validateVocabulary({ tokens: [{ token: 'DAST_Report', label: 'x', description: 'y' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toMatch(/kebab-case/); + }); + + it('дубль токена → ошибка', () => { + const r = validateVocabulary({ tokens: [ + { token: 'a-b', label: 'x', description: 'y' }, + { token: 'a-b', label: 'x2', description: 'y2' }, + ] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toMatch(/duplicate/); + }); + + it('пустой label/description → ошибка', () => { + const r = validateVocabulary({ tokens: [{ token: 'a-b', label: '', description: '' }] }); + expect(r.ok).toBe(false); + }); +}); + +describe('unknownTokens — сверка токенов контракта со словарём', () => { + const set = new Set(['running-portal', 'dast-report']); + + it('все токены в словаре → пусто', () => { + const c = { needs: ['running-portal'], produces: ['dast-report'] }; + expect(unknownTokens(c, set)).toEqual([]); + }); + + it('неизвестный токен в needs → запись {field, token}', () => { + const c = { needs: ['no-such-token'], produces: ['dast-report'] }; + expect(unknownTokens(c, set)).toEqual([{ field: 'needs', token: 'no-such-token' }]); + }); + + it('неизвестный токен в produces → запись', () => { + const c = { needs: ['running-portal'], produces: ['ghost'] }; + expect(unknownTokens(c, set)).toEqual([{ field: 'produces', token: 'ghost' }]); + }); + + it('пустые needs/produces → пусто (нечего сверять)', () => { + expect(unknownTokens({ needs: [], produces: [] }, set)).toEqual([]); + }); +}); +``` + +- [ ] **Шаг 2: Прогнать тест — убедиться, что падает** + +Run: `npx vitest run tools/capability-vocabulary.test.mjs` +Expected: FAIL — модуль `./capability-vocabulary.mjs` не существует (ошибка импорта). + +- [ ] **Шаг 3: Написать модуль** + +```js +#!/usr/bin/env node +/** + * capability-vocabulary — контролируемый словарь capability-токенов (спека v2 §3, + * OPEN-1). Единственный источник допустимых токенов для needs/produces контрактов. + * Чистые функции (без LLM): валидация формы словаря + сверка токенов контракта. + */ +import fsDefault from 'node:fs'; + +const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Валидация формы словаря → {ok, tokens:Set, errors[]}. */ +export function validateVocabulary(raw) { + if (!raw || typeof raw !== 'object' || !Array.isArray(raw.tokens)) + return { ok: false, tokens: new Set(), errors: ['vocabulary: объект с массивом tokens обязателен'] }; + const errors = []; + const tokens = new Set(); + raw.tokens.forEach((t, i) => { + if (!t || typeof t !== 'object' || typeof t.token !== 'string' || !t.token.trim()) { + errors.push(`tokens[${i}].token: непустая строка обязательна`); + return; + } + const tok = t.token.trim(); + if (!KEBAB.test(tok)) errors.push(`tokens[${i}].token "${tok}": требуется kebab-case`); + if (typeof t.label !== 'string' || !t.label.trim()) errors.push(`tokens[${i}] (${tok}).label: непустая строка обязательна`); + if (typeof t.description !== 'string' || !t.description.trim()) errors.push(`tokens[${i}] (${tok}).description: непустая строка обязательна`); + if (tokens.has(tok)) errors.push(`tokens[${i}].token "${tok}": duplicate`); + tokens.add(tok); + }); + return { ok: errors.length === 0, tokens, errors }; +} + +/** Загрузка словаря с диска (fs инъектируется). Бросает на битом JSON. */ +export function loadVocabulary({ path, fsImpl = fsDefault }) { + const raw = JSON.parse(fsImpl.readFileSync(path, 'utf8')); + return validateVocabulary(raw); +} + +/** Неизвестные токены контракта в needs/produces (отсутствуют в словаре). [{field, token}]. */ +export function unknownTokens(contract, tokenSet) { + const set = tokenSet instanceof Set ? tokenSet : new Set(tokenSet || []); + const out = []; + for (const field of ['needs', 'produces']) + for (const tok of contract?.[field] || []) + if (!set.has(String(tok).trim())) out.push({ field, token: tok }); + return out; +} +``` + +- [ ] **Шаг 4: Прогнать тест — убедиться, что проходит** + +Run: `npx vitest run tools/capability-vocabulary.test.mjs` +Expected: PASS — все тесты зелёные. + +### Task 2: Вшить замок в реестр (опциональный `vocabTokens`) + +**Files:** +- Modify: `tools/skill-contract-registry.mjs` (`buildRegistry`, `loadRegistry`, импорт) + +- [ ] **Шаг 5: Добавить импорт и параметр `vocabTokens`** + +В `tools/skill-contract-registry.mjs` поменять строку импорта — добавить второй импорт ПОД существующим: + +Найти: +```js +import { validateContract, normalizeContract, checkContractDrift } from './skill-contract.mjs'; +``` +Заменить на: +```js +import { validateContract, normalizeContract, checkContractDrift } from './skill-contract.mjs'; +import { unknownTokens } from './capability-vocabulary.mjs'; +``` + +Найти тело `buildRegistry` (от `export function buildRegistry(entries) {` до его `}`): +```js +export function buildRegistry(entries) { + const contracts = [], errors = [], driftFlags = [], seen = new Set(); + for (const e of entries || []) { + const c = normalizeContract(e.contract); + const v = validateContract(c); + if (!v.ok) { errors.push({ skill: c.skill || '(?)', errors: v.errors }); continue; } + if (seen.has(c.skill)) { errors.push({ skill: c.skill, errors: ['duplicate skill contract'] }); continue; } + seen.add(c.skill); + if (c.kind === 'external') { + const d = checkContractDrift({ contract: c, currentContent: e.currentContent }); + if (d.drifted) driftFlags.push({ skill: c.skill, reason: d.reason, fallback: d.fallback }); + } + contracts.push(c); + } + return { contracts, errors, driftFlags }; +} +``` +Заменить на (добавлен параметр `vocabTokens` и блок-замок ПЕРЕД `seen.add`): +```js +export function buildRegistry(entries, { vocabTokens = null } = {}) { + const contracts = [], errors = [], driftFlags = [], seen = new Set(); + for (const e of entries || []) { + const c = normalizeContract(e.contract); + const v = validateContract(c); + if (!v.ok) { errors.push({ skill: c.skill || '(?)', errors: v.errors }); continue; } + if (seen.has(c.skill)) { errors.push({ skill: c.skill, errors: ['duplicate skill contract'] }); continue; } + if (vocabTokens) { + const unknown = unknownTokens(c, vocabTokens); + if (unknown.length) { + errors.push({ skill: c.skill, errors: unknown.map((u) => `${u.field}: неизвестный токен "${u.token}" (нет в capability-vocabulary)`) }); + continue; + } + } + seen.add(c.skill); + if (c.kind === 'external') { + const d = checkContractDrift({ contract: c, currentContent: e.currentContent }); + if (d.drifted) driftFlags.push({ skill: c.skill, reason: d.reason, fallback: d.fallback }); + } + contracts.push(c); + } + return { contracts, errors, driftFlags }; +} +``` + +Найти `loadRegistry`: +```js +export function loadRegistry({ dir, fsImpl = fsDefault }) { + const files = fsImpl.readdirSync(dir).filter((f) => f.endsWith('.contract.json')); + const entries = files.map((f) => { + const raw = JSON.parse(fsImpl.readFileSync(`${dir}/${f}`, 'utf8')); + let currentContent; + if (raw && raw.kind === 'external' && raw.source && raw.source.path) { + try { currentContent = fsImpl.readFileSync(raw.source.path, 'utf8'); } catch { currentContent = undefined; } + } + return { contract: raw, currentContent }; + }); + return buildRegistry(entries); +} +``` +Заменить на (проброс `vocabTokens`): +```js +export function loadRegistry({ dir, fsImpl = fsDefault, vocabTokens = null }) { + const files = fsImpl.readdirSync(dir).filter((f) => f.endsWith('.contract.json')); + const entries = files.map((f) => { + const raw = JSON.parse(fsImpl.readFileSync(`${dir}/${f}`, 'utf8')); + let currentContent; + if (raw && raw.kind === 'external' && raw.source && raw.source.path) { + try { currentContent = fsImpl.readFileSync(raw.source.path, 'utf8'); } catch { currentContent = undefined; } + } + return { contract: raw, currentContent }; + }); + return buildRegistry(entries, { vocabTokens }); +} +``` + +### Task 3: Тест замка — позитив, негатив, обратная совместимость + +**Files:** +- Create: `tools/registry-vocab-gate.test.mjs` + +- [ ] **Шаг 6: Написать тест замка** + +```js +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { buildRegistry } from './skill-contract-registry.mjs'; +import { validateVocabulary } from './capability-vocabulary.mjs'; + +const A8 = ['owasp-zap', 'nuclei', 'ward', 'pdn-152fz-audit', 'threat-model', 'security-go-live']; +const load = (slug) => JSON.parse(readFileSync(`docs/registry/contracts/${slug}.contract.json`, 'utf8')); +const vocab = validateVocabulary(JSON.parse(readFileSync('docs/registry/capability-vocabulary.json', 'utf8'))); + +describe('замок словаря в buildRegistry', () => { + it('словарь сам валиден', () => { + expect(vocab.ok).toBe(true); + }); + + it('A8 со словарём → 0 ошибок, 6 контрактов собрано', () => { + const entries = A8.map((s) => ({ contract: load(s) })); + const r = buildRegistry(entries, { vocabTokens: vocab.tokens }); + expect(r.errors).toEqual([]); + expect(r.contracts).toHaveLength(6); + }); + + it('неизвестный токен → контракт в errors, не в contracts', () => { + const bad = { ...load('owasp-zap'), produces: ['ghost-token'] }; + const entries = [{ contract: bad }, ...A8.slice(1).map((s) => ({ contract: load(s) }))]; + const r = buildRegistry(entries, { vocabTokens: vocab.tokens }); + expect(r.contracts.map((c) => c.skill)).not.toContain('owasp-zap'); + const err = r.errors.find((e) => e.skill === 'owasp-zap'); + expect(err).toBeTruthy(); + expect(err.errors.join(' ')).toMatch(/ghost-token/); + }); + + it('обратная совместимость: БЕЗ словаря замок не срабатывает (прозовые needs проходят)', () => { + const prose = { skill: 'p', kind: 'own', needs: ['любая проза тут'], produces: ['и тут проза'], constraints: [], 'preview-form': 'none', defaults: [], 'key-decisions': [], 'acceptance-criteria': [] }; + const r = buildRegistry([{ contract: prose }]); + expect(r.errors).toEqual([]); + expect(r.contracts).toHaveLength(1); + }); +}); +``` + +- [ ] **Шаг 7: Прогнать тест замка** + +Run: `npx vitest run tools/registry-vocab-gate.test.mjs` +Expected: PASS — все 4 теста зелёные. + +### Task 4: Регрессия + +- [ ] **Шаг 8: Прогнать соседние тесты реестра/охвата + прототип A8** + +Run: `npx vitest run tools/m3a-contract-invariants.test.mjs tools/m3e-card-coverage-invariants.test.mjs tools/skill-contract-registry.test.mjs tools/card-coverage.test.mjs tools/coverage-prototype-a8.test.mjs` +Expected: PASS — всё зелёное (эти вызывают `buildRegistry`/`loadRegistry` без `vocabTokens` → поведение не изменилось). + +### Task 5: Коммит (после клика владельца) + +> Коммит делается только по согласию владельца (CLAUDE.md п.4). Под штатным режимом — терминал владельца или owner-escape. + +- [ ] **Шаг 9: Закоммитить замок словаря** + +```bash +git add tools/capability-vocabulary.mjs tools/capability-vocabulary.test.mjs tools/skill-contract-registry.mjs tools/registry-vocab-gate.test.mjs +git commit -m "feat: замок словаря — неизвестный токен валит сборку реестра (спека v2 §3)" +``` + +--- + +## Само-ревью (по навыку) + +- **Покрытие спеки:** план реализует §3 спеки v2 («неизвестный токен → ошибка сборки», edge-case «токен не в словаре обязан валиться при сборке, не молча игнорироваться»). Опциональность `vocabTokens` — следствие §8 (стадийность: замок-механизм до массового переезда; глобальное умолчание — отдельной задачей после миграции 150). +- **Плейсхолдеры:** нет — весь код модуля, правок реестра и тестов приведён целиком, точными find/replace-блоками. +- **Согласованность типов:** `validateVocabulary` возвращает `{ok, tokens:Set, errors}`; `tokens` (Set) передаётся как `vocabTokens` в `buildRegistry`; `unknownTokens(contract, tokenSet)` принимает Set ИЛИ массив (нормализует) — совпадает во всех вызовах (Task 1 тест, Task 2 реестр, Task 3 тест). +- **Регрессия:** существующие вызовы `buildRegistry(entries)` / `loadRegistry({dir,fsImpl})` без второго аргумента → `vocabTokens=null` → ветка замка не входит → поведение идентично. Подтверждается Task 4. +- **Обратная совместимость доказана тестом** (Task 3, четвёртый кейс) — не только рассуждением. diff --git a/docs/superpowers/specs/2026-06-18-router-skill-registry-coverage-overhaul-design-v2.md b/docs/superpowers/specs/2026-06-18-router-skill-registry-coverage-overhaul-design-v2.md new file mode 100644 index 0000000..6af59d9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-router-skill-registry-coverage-overhaul-design-v2.md @@ -0,0 +1,114 @@ +# Роутер · реестр навыков · машина охвата — дизайн (v2) + +> **Статус:** дизайн v2 (учтено замечание наставника по OPEN-1/OPEN-2), ждёт вычитки. +> **Кодовая фраза возврата:** «роутер-реестр». + +```verified-context-json +[ + {"id":"cm-holes","kind":"EXTRACTED","ref":"tools/coverage-machine.mjs","anchor":"export function findHoles("}, + {"id":"cm-topo","kind":"EXTRACTED","ref":"tools/coverage-machine.mjs","anchor":"export function topoOrder("}, + {"id":"judge-empty-cards","kind":"EXTRACTED","ref":"tools/enforce-judge-gate.mjs","anchor":"cards: []"}, + {"id":"sc-contract","kind":"EXTRACTED","ref":"docs/registry/contracts/skill-creator.contract.json","anchor":"\"skill\": \"skill-creator\""} +] +``` + +## Цель + +Роутер на этапе плана надёжно подбирает релевантные навыки; механический автомат охвата строит порядок цепочки и ловит пробелы против цели. Достигается оживлением уже написанной (но спящей) машинерии контрактов/охвата, доведением данных контрактов до рабочего вида и починкой входа роутера. Навык выбирается по каталогу, а не угадывается; забытый нужный навык ловится механически и жёстко стопорит печать. + +Измеримые ориентиры: живость роутера (доля `source: llm`) ≥ 90 %; релевантность (план получил ≥1 навык, не `unknown`) ≥ 80 %; 100 % механических дыр стопорят печать; среднее число NO-GO до GO ≤ 2. Точные пороги — калибровка на этапе плана. + +## 1. Проблема {#D1} + +Контракт раздела: зафиксировать корневые причины плохого подбора (подтверждены чтением кода). + +- Реестр слит по плагинам: процессные навыки спрятаны в комке `#19 Superpowers` (14 sub-skills); ~13 плагинов прячут ~75 навыков — подобрать навык/цепочку нечем. +- Вход роутера — болванка: при отсутствии `## Цель` берётся шаблонная шапка; роутер классифицирует мусор. +- Выбор роутера нигде не логируется — оценить качество нельзя. +- Реестр цепочек L1-L17 ссылается на под-навыки, которых нет в списке узлов. +- Машинерия охвата (`coverage-machine`/`skill-contract-registry`/`card-coverage`) написана, но не подключена; судья получает пустые карточки (EXTRACTED `judge-empty-cards`); контракты слиты, а `needs`/`produces` — проза → граф пустой. + +Edge-case: при пустом графе `findHoles` (EXTRACTED `cm-holes`) помечает дырой всё подряд — бесполезно без словаря и `initialInputs`. + +## 2. Данные — фундамент {#D2} + +Контракт раздела: привести данные навыков к виду, на котором автомат работает. + +- Расщепить плагины-навыки на отдельные навыки (~150); инструменты/MCP (Playwright, gitleaks, Vitest, context7) — атомарны, не трогаем. +- Адресация — `плагин:навык`. +- Выкинуть реестр цепочек L1-L17 — заменяется графом из `needs/produces`. +- Контракты ведём гибридом: генератор-скелет из установленных навыков + оптимизация описаний (визитка + границы) ИИ-проходом с ручной вычиткой важных. + +Критерий: каждый навык адресуется `плагин:навык` и имеет визитку + токенные `needs/produces`. + +## 3. OPEN-1 решено: единый словарь токенов {#D3} + +Контракт раздела: убрать причину пустого графа — рассогласование `needs/produces`. + +- Файл `docs/registry/capability-vocabulary.json` — контролируемый список capability-токенов (kebab-case): `token`, `label`, `description`. Единственный источник допустимых токенов. +- Каждый контракт ссылается в `needs`/`produces` только на токены словаря (не проза). Человеческая визитка остаётся отдельным полем (для LLM-роутера); токены — для автомата. +- Валидация (расширить `skill-contract-registry`/`card-coverage`): неизвестный токен → ошибка сборки. Это гарантирует дословное совпадение `produces` производителя и `needs` потребителя. +- `initialInputs` — токены-данности задачи объявляются явно, чтобы `findHoles` (EXTRACTED `cm-holes`) не считал их ложными дырами. + +Прототип на 6 контрактах A8 (токенная форма): `owasp-zap` produces `dast-report`; `nuclei` produces `cve-exposure-scan`; `ward` produces `laravel-config-audit`; `pdn-152fz-audit` produces `pii-152fz-audit`; `threat-model` produces `stride-model`; `security-go-live` needs все пять, produces `go-live-verdict`. На этих данных `buildDependencyGraph` даёт пять рёбер к `security-go-live`; `topoOrder` (EXTRACTED `cm-topo`) ставит пять проверок раньше, go-live последним; `findHoles` при объявленных `initialInputs` дыр не находит. + +Критерий: на прототипе граф непуст, порядок осмыслен, ложных дыр нет. + +Edge-case: токен в контракте, отсутствующий в словаре, обязан валиться при сборке реестра (не молча игнорироваться). + +## 4. Роутер и OPEN-2 решено: пининг по хешу цели {#D4} + +Контракт раздела: стабильный, независимый подбор + защита от дрейфа совета. + +- Роутер работает только на этапе плана (на спеке — нет). +- Вход: инструкция + полка визиток всех навыков/инструментов (по группам) + весь артефакт (план + спека фоном), без `skills-json` (независимость) и без шапки. +- Один сильный LLM; модель — настраиваемая. Эмбеддинг-вход и двухагентность отвергнуты как хрупкие (дальний резерв). +- Пининг: ключ `(task_id, goalHash)`, где `goalHash` — хеш нормализованного текста `## Цель`. На записи плана: пин для текущего `goalHash` есть → переиспользовать совет (LLM не зовём); цель сменилась/пина нет → вызвать роутер и записать пин. Инвалидация — только при смене `goalHash` или завершении задачи (без TTL-таймера). Пинится только LLM-совет; автомат охвата детерминирован и дёшев → гоняется каждую правку против неизменной цели. + +Критерий: LLM-роутер срабатывает один раз на отдельную цель; схождение определяет автомат, не дрейфующий совет. + +## 5. Автомат, гейты, пол {#D5} + +Контракт раздела: механическая полнота + жёсткий пол на печати. + +- Автомат `coverage-machine` (без ИИ): граф, порядок (`topoOrder`), пробел против цели (`findHoles`/`requestsChecklist`); при дрейфе/отсутствии контракта — откат на мягкое рассуждение. +- Печать = наставник GO + судья GO + механика «нет дыр». Механический пробел — твёрдый стопор печати (закрывает промах двойного GO). +- 3-й NO-GO → карточка арбитража владельцу (существующий тормоз). + +Критерий: при непустом `findHoles` печать не встаёт независимо от мнения LLM. + +## 6. Передача наставнику и контроллеру {#D6} + +Контракт раздела: дать наставнику достаточно для суда, контроллеру — имя забытого навыка. + +- Наставнику, кроме имён: карточки рекомендованных навыков (дайджест-контракт: что делает / acceptance / границы / ключевые шаги), готовая сверка-пробел (совпало/забыл/спорное), порядок цепочки, обоснование/уверенность. Полные тела навыков не даём — подтянутся у контроллера при вызове навыка на правке. +- Расхождение «план(`skills-json`) ↔ роутер»: дыра (забыл нужное) → твёрдый блок → возврат контроллеру; лишнее/спорное → суждение наставника; совпало → дальше. +- На NO-GO канал exit-2 несёт контроллеру карточку роутера («ПЛАН ЗАБЫЛ: X → вызови X»), не только прозу наставника. + +Критерий: контроллер на NO-GO получает имя недостающего навыка дословно. + +## 7. Логирование {#D7} + +Контракт раздела: сделать все решения наблюдаемыми для оценки качества. + +Логировать вход + решение всех решающих: роутер (вход-артефакт + выбор + обоснование + уверенность), наставник (вердикт + замечание), судья (вердикт + что блокировал), автомат — дважды: совет наставнику (порядок + пробел) и пол на печати (пропуск/блок + закрывшие дыры). + +Критерий: по логам восстановимо, на каком звене план отскочил. + +## 8. Риски и смягчение {#D8} + +Контракт раздела: управляемость главного риска. + +- Главный труд — единый словарь токенов. Смягчение: сначала прототип словаря на 6 контрактах A8 (раздел 3) + прогон `coverage-machine`; только при подтверждении — раскатка на ~150 контрактов и расщепление комков. Стадийно: прототип → валидация → раскатка. +- Расщепление 13 комков — ручная/полуавтоматная работа, после валидации словаря (зависимость). +- Остальное — проводка спящего к живому. + +## 9. Уборка (отложено) {#D9} + +Контракт раздела: не плодить мёртвое. + +Старый пер-сообщенческий роутер (`router-prehook`, заглушка вызова модели) — пометить, убрать отдельной задачей в будущем. + +## Открытые вопросы + +- OPEN-3 (не блокирует, калибровка на этапе плана): порог «спорное → NO-GO» у наставника. По умолчанию: «спорное» = совет, блокирует только «дыра». diff --git a/tools/capability-vocabulary.mjs b/tools/capability-vocabulary.mjs new file mode 100644 index 0000000..95ab62b --- /dev/null +++ b/tools/capability-vocabulary.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * capability-vocabulary — контролируемый словарь capability-токенов (спека v2 §3, + * OPEN-1). Единственный источник допустимых токенов для needs/produces контрактов. + * Чистые функции (без LLM): валидация формы словаря + сверка токенов контракта. + */ +import fsDefault from 'node:fs'; + +const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Валидация формы словаря → {ok, tokens:Set, errors[]}. */ +export function validateVocabulary(raw) { + if (!raw || typeof raw !== 'object' || !Array.isArray(raw.tokens)) + return { ok: false, tokens: new Set(), errors: ['vocabulary: объект с массивом tokens обязателен'] }; + const errors = []; + const tokens = new Set(); + raw.tokens.forEach((t, i) => { + if (!t || typeof t !== 'object' || typeof t.token !== 'string' || !t.token.trim()) { + errors.push(`tokens[${i}].token: непустая строка обязательна`); + return; + } + const tok = t.token.trim(); + if (!KEBAB.test(tok)) errors.push(`tokens[${i}].token "${tok}": требуется kebab-case`); + if (typeof t.label !== 'string' || !t.label.trim()) errors.push(`tokens[${i}] (${tok}).label: непустая строка обязательна`); + if (typeof t.description !== 'string' || !t.description.trim()) errors.push(`tokens[${i}] (${tok}).description: непустая строка обязательна`); + if (tokens.has(tok)) { errors.push(`tokens[${i}].token "${tok}": duplicate`); return; } + tokens.add(tok); + }); + return { ok: errors.length === 0, tokens, errors }; +} + +/** Загрузка словаря с диска (fs инъектируется). Бросает на битом JSON. */ +export function loadVocabulary({ path, fsImpl = fsDefault }) { + const raw = JSON.parse(fsImpl.readFileSync(path, 'utf8')); + return validateVocabulary(raw); +} + +/** Неизвестные токены контракта в needs/produces (отсутствуют в словаре). [{field, token}]. + * Сверка по String(tok).trim(); в выдаче — исходное значение token (не нормализованное). */ +export function unknownTokens(contract, tokenSet) { + const set = tokenSet instanceof Set ? tokenSet : new Set(tokenSet || []); + const out = []; + for (const field of ['needs', 'produces']) + for (const tok of contract?.[field] || []) + if (!set.has(String(tok).trim())) out.push({ field, token: tok }); + return out; +} diff --git a/tools/capability-vocabulary.test.mjs b/tools/capability-vocabulary.test.mjs new file mode 100644 index 0000000..ef950d3 --- /dev/null +++ b/tools/capability-vocabulary.test.mjs @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { validateVocabulary, loadVocabulary, unknownTokens } from './capability-vocabulary.mjs'; + +describe('validateVocabulary — форма словаря', () => { + const good = { version: '0.1.0', tokens: [ + { token: 'dast-report', label: 'отчёт DAST', description: 'результат динамики' }, + { token: 'stride-model', label: 'STRIDE', description: 'модель угроз' }, + ] }; + + it('валидный словарь → ok + Set токенов', () => { + const r = validateVocabulary(good); + expect(r.ok).toBe(true); + expect(r.errors).toEqual([]); + expect(r.tokens.has('dast-report')).toBe(true); + expect(r.tokens.size).toBe(2); + }); + + it('не-объект / нет tokens-массива → ошибка', () => { + expect(validateVocabulary(null).ok).toBe(false); + expect(validateVocabulary({ version: '1' }).ok).toBe(false); + }); + + it('токен не kebab-case → ошибка', () => { + const r = validateVocabulary({ tokens: [{ token: 'DAST_Report', label: 'x', description: 'y' }] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toMatch(/kebab-case/); + }); + + it('дубль токена → ошибка', () => { + const r = validateVocabulary({ tokens: [ + { token: 'a-b', label: 'x', description: 'y' }, + { token: 'a-b', label: 'x2', description: 'y2' }, + ] }); + expect(r.ok).toBe(false); + expect(r.errors.join(' ')).toMatch(/duplicate/); + }); + + it('пустой label/description → ошибка', () => { + const r = validateVocabulary({ tokens: [{ token: 'a-b', label: '', description: '' }] }); + expect(r.ok).toBe(false); + }); +}); + +describe('loadVocabulary — fs-инъекция', () => { + it('читает файл и возвращает валидный словарь', () => { + const stub = { readFileSync: () => JSON.stringify({ tokens: [{ token: 'a-b', label: 'x', description: 'y' }] }) }; + const r = loadVocabulary({ path: 'fake.json', fsImpl: stub }); + expect(r.ok).toBe(true); + expect(r.tokens.has('a-b')).toBe(true); + }); + + it('бросает при битом JSON', () => { + const stub = { readFileSync: () => 'not-json' }; + expect(() => loadVocabulary({ path: 'bad.json', fsImpl: stub })).toThrow(); + }); +}); + +describe('unknownTokens — сверка токенов контракта со словарём', () => { + const set = new Set(['running-portal', 'dast-report']); + + it('все токены в словаре → пусто', () => { + const c = { needs: ['running-portal'], produces: ['dast-report'] }; + expect(unknownTokens(c, set)).toEqual([]); + }); + + it('неизвестный токен в needs → запись {field, token}', () => { + const c = { needs: ['no-such-token'], produces: ['dast-report'] }; + expect(unknownTokens(c, set)).toEqual([{ field: 'needs', token: 'no-such-token' }]); + }); + + it('неизвестный токен в produces → запись', () => { + const c = { needs: ['running-portal'], produces: ['ghost'] }; + expect(unknownTokens(c, set)).toEqual([{ field: 'produces', token: 'ghost' }]); + }); + + it('пустые needs/produces → пусто (нечего сверять)', () => { + expect(unknownTokens({ needs: [], produces: [] }, set)).toEqual([]); + }); + + it('contract null/undefined → пусто (защита optional-chain)', () => { + expect(unknownTokens(null, set)).toEqual([]); + expect(unknownTokens(undefined, set)).toEqual([]); + }); + + it('tokenSet как массив (не Set) → нормализуется', () => { + const c = { needs: ['running-portal'], produces: ['ghost'] }; + expect(unknownTokens(c, ['running-portal'])).toEqual([{ field: 'produces', token: 'ghost' }]); + }); +}); diff --git a/tools/coverage-prototype-a8.test.mjs b/tools/coverage-prototype-a8.test.mjs new file mode 100644 index 0000000..c1f3dfb --- /dev/null +++ b/tools/coverage-prototype-a8.test.mjs @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { buildDependencyGraph, topoOrder, findHoles } from './coverage-machine.mjs'; + +const A8 = ['owasp-zap', 'nuclei', 'ward', 'pdn-152fz-audit', 'threat-model', 'security-go-live']; +const load = (slug) => JSON.parse(readFileSync(`docs/registry/contracts/${slug}.contract.json`, 'utf8')); + +describe('A8 coverage prototype — единый словарь оживляет автомат', () => { + const contracts = A8.map(load); + + it('граф непуст: ≥5 рёбер сходятся в security-go-live', () => { + const { edges } = buildDependencyGraph(contracts); + const toGoLive = edges.filter((e) => e.to === 'security-go-live'); + expect(toGoLive.length).toBeGreaterThanOrEqual(5); + }); + + it('topoOrder: security-go-live идёт последним', () => { + const { order } = topoOrder(contracts); + expect(order).not.toBeNull(); + expect(order[order.length - 1]).toBe('security-go-live'); + }); + + it('findHoles: при объявленных initialInputs дыр нет (без constraints)', () => { + const initialInputs = ['running-portal', 'laravel-config', 'pii-inventory-task', 'portal-pre-launch']; + const holes = findHoles(contracts, { initialInputs, includeConstraints: false }); + expect(holes).toEqual([]); + }); +}); diff --git a/tools/registry-vocab-gate.test.mjs b/tools/registry-vocab-gate.test.mjs new file mode 100644 index 0000000..5d5586a --- /dev/null +++ b/tools/registry-vocab-gate.test.mjs @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { buildRegistry } from './skill-contract-registry.mjs'; +import { validateVocabulary } from './capability-vocabulary.mjs'; + +const A8 = ['owasp-zap', 'nuclei', 'ward', 'pdn-152fz-audit', 'threat-model', 'security-go-live']; +const load = (slug) => JSON.parse(readFileSync(`docs/registry/contracts/${slug}.contract.json`, 'utf8')); +const vocab = validateVocabulary(JSON.parse(readFileSync('docs/registry/capability-vocabulary.json', 'utf8'))); + +describe('замок словаря в buildRegistry', () => { + it('словарь сам валиден', () => { + expect(vocab.ok).toBe(true); + }); + + it('A8 со словарём → 0 ошибок, 6 контрактов собрано', () => { + const entries = A8.map((s) => ({ contract: load(s) })); + const r = buildRegistry(entries, { vocabTokens: vocab.tokens }); + expect(r.errors).toEqual([]); + expect(r.contracts).toHaveLength(6); + }); + + it('неизвестный токен → контракт в errors, не в contracts', () => { + const bad = { ...load('owasp-zap'), produces: ['ghost-token'] }; + const entries = [{ contract: bad }, ...A8.slice(1).map((s) => ({ contract: load(s) }))]; + const r = buildRegistry(entries, { vocabTokens: vocab.tokens }); + expect(r.contracts.map((c) => c.skill)).not.toContain('owasp-zap'); + const err = r.errors.find((e) => e.skill === 'owasp-zap'); + expect(err).toBeTruthy(); + expect(err.errors.join(' ')).toMatch(/ghost-token/); + }); + + it('обратная совместимость: БЕЗ словаря замок не срабатывает (прозовые needs проходят)', () => { + const prose = { skill: 'p', kind: 'own', needs: ['любая проза тут'], produces: ['и тут проза'], constraints: [], 'preview-form': 'none', defaults: [], 'key-decisions': [], 'acceptance-criteria': [] }; + const r = buildRegistry([{ contract: prose }]); + expect(r.errors).toEqual([]); + expect(r.contracts).toHaveLength(1); + }); +}); diff --git a/tools/skill-contract-registry.mjs b/tools/skill-contract-registry.mjs index 626f7f3..e7a0d38 100644 --- a/tools/skill-contract-registry.mjs +++ b/tools/skill-contract-registry.mjs @@ -6,15 +6,26 @@ */ import fsDefault from 'node:fs'; import { validateContract, normalizeContract, checkContractDrift } from './skill-contract.mjs'; +import { unknownTokens } from './capability-vocabulary.mjs'; -/** Чистая сборка: валидирует, ловит дубли, помечает дрейф external. */ -export function buildRegistry(entries) { +/** Чистая сборка: валидирует, ловит дубли, помечает дрейф external. + * vocabTokens (Set токенов словаря) — опциональный замок §3: передан → неизвестный + * токен в needs/produces валит контракт в errors; null = замок выключен (стадийная + * раскатка §8, пока ~144 контракта в прозе). */ +export function buildRegistry(entries, { vocabTokens = null } = {}) { const contracts = [], errors = [], driftFlags = [], seen = new Set(); for (const e of entries || []) { const c = normalizeContract(e.contract); const v = validateContract(c); if (!v.ok) { errors.push({ skill: c.skill || '(?)', errors: v.errors }); continue; } if (seen.has(c.skill)) { errors.push({ skill: c.skill, errors: ['duplicate skill contract'] }); continue; } + if (vocabTokens) { + const unknown = unknownTokens(c, vocabTokens); + if (unknown.length) { + errors.push({ skill: c.skill, errors: unknown.map((u) => `${u.field}: неизвестный токен "${u.token}" (нет в capability-vocabulary)`) }); + continue; + } + } seen.add(c.skill); if (c.kind === 'external') { const d = checkContractDrift({ contract: c, currentContent: e.currentContent }); @@ -42,7 +53,7 @@ export function dispatchContract(registry, skill) { /** Загрузка с диска: dir/*.contract.json. Для external читает source.path * (актуальный SKILL.md) для дрейф-сверки G4, если путь задан и доступен. */ -export function loadRegistry({ dir, fsImpl = fsDefault }) { +export function loadRegistry({ dir, fsImpl = fsDefault, vocabTokens = null }) { const files = fsImpl.readdirSync(dir).filter((f) => f.endsWith('.contract.json')); const entries = files.map((f) => { const raw = JSON.parse(fsImpl.readFileSync(`${dir}/${f}`, 'utf8')); @@ -52,5 +63,5 @@ export function loadRegistry({ dir, fsImpl = fsDefault }) { } return { contract: raw, currentContent }; }); - return buildRegistry(entries); + return buildRegistry(entries, { vocabTokens }); }