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