397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
10 KiB
JavaScript
180 lines
10 KiB
JavaScript
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);
|
||
});
|
||
});
|