feat: роутер-реестр — словарь capability-токенов, прототип A8, замок словаря
This commit is contained in:
@@ -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 по безопасности перед публикацией."}
|
||||
]
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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, четвёртый кейс) — не только рассуждением.
|
||||
+114
@@ -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» у наставника. По умолчанию: «спорное» = совет, блокирует только «дыра».
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user