Files
portal/tools/skill-contract.test.mjs
T

109 lines
5.7 KiB
JavaScript

import { describe, it, expect } from 'vitest';
import {
CONTRACT_FIELDS, PREVIEW_FORMS, validateContract,
normalizeContract, needsOf, producesOf,
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);
});
});