Files
brain/tools/skill-contract.test.mjs

180 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import {
CONTRACT_FIELDS, PREVIEW_FORMS, validateContract,
normalizeContract, needsOf, producesOf, inherentNeedsOf, DEFAULT_MISTAKE_MARKERS,
contractHash, checkContractDrift, checkContractNeutrality,
} from './skill-contract.mjs';
describe('checkContractNeutrality (G1 — этикетка без проектных фактов, страж против ИНЪЕЦИРУЕМОГО списка)', () => {
const c = (arr) => ({ skill: 's', kind: 'own', needs: arr, produces: [], constraints: [], 'preview-form': 'none', defaults: [], 'key-decisions': [], 'acceptance-criteria': [] });
it('проектный термин в полях → not neutral + перечень совпадений', () => {
const r = checkContractNeutrality(c(['нужен teal в шапке']), { projectTerms: ['teal'] });
expect(r.neutral).toBe(false);
expect(r.hits).toContain('teal');
});
it('пустой список терминов → neutral (нечего судить, без хардкода проектных фактов)', () => {
expect(checkContractNeutrality(c(['нужен teal']), { projectTerms: [] }).neutral).toBe(true);
});
it('чистая нейтральная этикетка → neutral', () => {
expect(checkContractNeutrality(c(['spec']), { projectTerms: ['teal', 'лендинг'] }).neutral).toBe(true);
});
});
const OWN = {
skill: 'writing-plans', kind: 'own',
needs: ['spec'], produces: ['implementation-plan'], constraints: ['no code'],
'preview-form': 'outline', defaults: ['bite-sized tasks'],
'key-decisions': ['file structure'], 'acceptance-criteria': ['each step 2-5 min'],
};
const EXT = {
skill: 'operations:process-doc', kind: 'external',
needs: ['as-is process'], produces: ['process doc'], constraints: [],
'preview-form': 'none', defaults: [], 'key-decisions': [], 'acceptance-criteria': ['doc covers all steps'],
source: { version: '1.2.0', hash: 'a'.repeat(64), path: 'x/SKILL.md' },
};
describe('CONTRACT_FIELDS / PREVIEW_FORMS', () => {
it('canonical fields present (C-13 + L-поля)', () => {
for (const f of ['skill','kind','needs','produces','constraints','preview-form','defaults','key-decisions','acceptance-criteria'])
expect(CONTRACT_FIELDS).toContain(f);
});
it('preview-form includes none', () => { expect(PREVIEW_FORMS).toContain('none'); });
});
describe('validateContract (форма, без LLM)', () => {
it('valid own contract → ok', () => { expect(validateContract(OWN)).toEqual({ ok: true, errors: [] }); });
it('valid external contract → ok', () => { expect(validateContract(EXT).ok).toBe(true); });
it('non-object → error', () => { expect(validateContract(null).ok).toBe(false); });
it('empty skill → error', () => { expect(validateContract({ ...OWN, skill: '' }).ok).toBe(false); });
it('bad kind → error', () => { expect(validateContract({ ...OWN, kind: 'maybe' }).ok).toBe(false); });
it('needs not string-array → error', () => { expect(validateContract({ ...OWN, needs: 'spec' }).ok).toBe(false); });
it('bad preview-form → error', () => { expect(validateContract({ ...OWN, 'preview-form': 'fancy' }).ok).toBe(false); });
it('external without source → error', () => {
const { source, ...noSrc } = EXT; expect(validateContract(noSrc).ok).toBe(false);
});
it('external with bad hash → error', () => {
expect(validateContract({ ...EXT, source: { version: '1', hash: 'short' } }).ok).toBe(false);
});
});
describe('normalizeContract', () => {
it('missing arrays → [] and preview-form → none', () => {
const n = normalizeContract({ skill: ' x ', kind: 'own' });
expect(n.skill).toBe('x');
for (const f of ['needs','produces','constraints','defaults','key-decisions','acceptance-criteria']) expect(n[f]).toEqual([]);
expect(n['preview-form']).toBe('none');
});
it('does not mutate input', () => {
const raw = { skill: 'x', kind: 'own' };
normalizeContract(raw); expect(raw.needs).toBeUndefined();
});
});
describe('needsOf / producesOf', () => {
it('return copies of arrays', () => {
const c = { needs: ['a'], produces: ['b'] };
expect(needsOf(c)).toEqual(['a']); expect(producesOf(c)).toEqual(['b']);
needsOf(c).push('z'); expect(c.needs).toEqual(['a']);
});
it('missing → []', () => { expect(needsOf({})).toEqual([]); expect(producesOf({})).toEqual([]); });
});
describe('contractHash', () => {
it('sha256 hex, deterministic', () => {
expect(contractHash('abc')).toMatch(/^[0-9a-f]{64}$/);
expect(contractHash('abc')).toBe(contractHash('abc'));
expect(contractHash('abc')).not.toBe(contractHash('abd'));
});
});
describe('checkContractDrift (G4)', () => {
const ext = (hash) => ({ skill: 's', kind: 'external', source: { version: '1', hash } });
it('own contract → дрейф не сторожится (ok)', () => {
expect(checkContractDrift({ contract: { skill: 's', kind: 'own' }, currentContent: 'anything' }).ok).toBe(true);
});
it('external, hash совпал → ok, not drifted', () => {
const h = contractHash('SKILL body');
expect(checkContractDrift({ contract: ext(h), currentContent: 'SKILL body' })).toMatchObject({ ok: true, drifted: false });
});
it('external, содержание изменилось → drifted + fallback soft-reasoning', () => {
const h = contractHash('old body');
const r = checkContractDrift({ contract: ext(h), currentContent: 'NEW body' });
expect(r.drifted).toBe(true); expect(r.ok).toBe(false); expect(r.fallback).toBe('soft-reasoning');
});
it('external без сохранённого отпечатка → drifted (нельзя доверять)', () => {
const r = checkContractDrift({ contract: { skill: 's', kind: 'external', source: { version: '1' } }, currentContent: 'x' });
expect(r.drifted).toBe(true);
});
it('G-E: external с пустым source.path → дрейф не сторожится (инертен)', () => {
const c = { skill: 'x', kind: 'external', source: { version: '1', hash: '0'.repeat(64), path: '' } };
const r = checkContractDrift({ contract: c, currentContent: undefined });
expect(r.drifted).toBe(false);
expect(r.ok).toBe(true);
});
});
describe('inherent (#1 присущее по природе + гард R4 rationale)', () => {
const withInh = { ...OWN, inherent: [{ need: 'мобильный', rationale: 'фронт по природе делает адаптив' }] };
it('CONTRACT_FIELDS содержит inherent', () => {
expect(CONTRACT_FIELDS).toContain('inherent');
});
it('валидный inherent {need,rationale} → ok', () => {
expect(validateContract(withInh).ok).toBe(true);
});
it('контракт без inherent остаётся валидным (поле опционально)', () => {
expect(validateContract(OWN).ok).toBe(true);
});
it('гард R4: inherent без rationale → error', () => {
const r = validateContract({ ...OWN, inherent: [{ need: 'мобильный' }] });
expect(r.ok).toBe(false); expect(r.errors.some((e) => /rationale/i.test(e))).toBe(true);
});
it('inherent не массив → error', () => {
expect(validateContract({ ...OWN, inherent: 'мобильный' }).ok).toBe(false);
});
it('inherent.need пустой → error', () => {
expect(validateContract({ ...OWN, inherent: [{ need: '', rationale: 'x' }] }).ok).toBe(false);
});
it('inherentNeedsOf → список need-строк', () => {
expect(inherentNeedsOf(withInh)).toEqual(['мобильный']);
expect(inherentNeedsOf({})).toEqual([]);
});
it('normalizeContract: отсутствующий inherent → []', () => {
expect(normalizeContract({ skill: 'x', kind: 'own' }).inherent).toEqual([]);
});
});
describe('гард R4 усилен — rationale не должен быть исповедью прошлого промаха', () => {
it('DEFAULT_MISTAKE_MARKERS — непустой список', () => {
expect(Array.isArray(DEFAULT_MISTAKE_MARKERS)).toBe(true);
expect(DEFAULT_MISTAKE_MARKERS.length).toBeGreaterThan(0);
});
it('rationale с маркером промаха («в прошлый раз забыли») → error', () => {
const r = validateContract({ ...OWN, inherent: [{ need: 'мобильный', rationale: 'в прошлый раз забыли' }] });
expect(r.ok).toBe(false); expect(r.errors.some((e) => /промах|R4/i.test(e))).toBe(true);
});
it('честная природа-формулировка → ok', () => {
expect(validateContract({ ...OWN, inherent: [{ need: 'мобильный', rationale: 'фронт по природе делает адаптив' }] }).ok).toBe(true);
});
it('маркеры инъецируемы (портативно)', () => {
const r = validateContract({ ...OWN, inherent: [{ need: 'x', rationale: 'спецслово тут' }] }, { mistakeMarkers: ['спецслово'] });
expect(r.ok).toBe(false);
});
});
describe('checkContractNeutrality — скан ВСЕХ строковых полей, не только ARRAY_FIELDS (F4)', () => {
const base = { skill: 's', kind: 'own', needs: [], produces: [], constraints: [], 'preview-form': 'none', defaults: [], 'key-decisions': [], 'acceptance-criteria': [] };
it('проектный термин в inherent.rationale → not neutral', () => {
const r = checkContractNeutrality({ ...base, inherent: [{ need: 'мобильный', rationale: 'лендингу нужен teal' }] }, { projectTerms: ['teal'] });
expect(r.neutral).toBe(false);
expect(r.hits).toContain('teal');
});
it('проектный термин в inherent.need → not neutral', () => {
const r = checkContractNeutrality({ ...base, inherent: [{ need: 'лендинг', rationale: 'по природе' }] }, { projectTerms: ['лендинг'] });
expect(r.neutral).toBe(false);
});
it('чистый inherent → neutral (без ложных срабатываний)', () => {
const r = checkContractNeutrality({ ...base, inherent: [{ need: 'мобильный', rationale: 'адаптив по природе' }] }, { projectTerms: ['teal', 'лендинг'] });
expect(r.neutral).toBe(true);
});
});