feat: роутер-реестр — словарь capability-токенов, прототип A8, замок словаря

This commit is contained in:
Дмитрий
2026-06-18 20:33:42 +03:00
parent f62b5f25ab
commit 7cf91ecf12
15 changed files with 855 additions and 16 deletions
+15
View File
@@ -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 по безопасности перед публикацией."}
]
}
+2 -2
View File
@@ -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"],
@@ -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"],
@@ -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": ["инвентаризация ПДн в схеме/коде → проверка соответствия"],
@@ -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"],
@@ -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"],
+2 -2
View File
@@ -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"],
@@ -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` не меняется (только используется); правки — данные + новый тест, поэтому узкого прогона достаточно.
@@ -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, четвёртый кейс) — не только рассуждением.
@@ -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» у наставника. По умолчанию: «спорное» = совет, блокирует только «дыра».
+47
View File
@@ -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;
}
+89
View File
@@ -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' }]);
});
});
+28
View File
@@ -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([]);
});
});
+38
View File
@@ -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);
});
});
+15 -4
View File
@@ -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 });
}