feat: роутер-реестр — словарь capability-токенов, прототип A8, замок словаря
This commit is contained in:
@@ -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