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
+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 });
}