599dca15ec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
7.0 KiB
JavaScript
148 lines
7.0 KiB
JavaScript
// tools/plan-lock.test.mjs
|
|
import { describe, it, expect } from 'vitest';
|
|
import { freezePlan, verifyFrozenPlan, planId } from './plan-lock.mjs';
|
|
import { actionMatchesStep, nextStep } from './plan-lock.mjs';
|
|
import { saveFrozenPlan, loadFrozenPlan } from './plan-lock.mjs';
|
|
import { reconcileJournalToPlan } from './plan-lock.mjs';
|
|
import { freezeArtifact, verifyFrozenArtifact, artifactId } from './plan-lock.mjs';
|
|
import { saveFrozenArtifact, loadFrozenArtifact } from './plan-lock.mjs';
|
|
import { refResolves } from './plan-lock.mjs';
|
|
|
|
const KEY = 'plan-lock-test-key';
|
|
const STEPS = [
|
|
{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'создать модуль' },
|
|
{ n: 2, op: 'Bash', object: 'npx vitest run foo', intent: 'прогнать тест' },
|
|
];
|
|
const ART = { sections: { '§1': 'палитра teal', '§2': 'шрифт Inter' }, goal: 'лендинг' };
|
|
|
|
function memFs() {
|
|
const s = new Map();
|
|
return { s,
|
|
readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); },
|
|
writeFileSync: (p, d) => s.set(String(p), String(d)) };
|
|
}
|
|
|
|
describe('freezePlan / verifyFrozenPlan', () => {
|
|
it('freezePlan returns a signed plan with stable plan_id', () => {
|
|
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1000 });
|
|
expect(p.plan_id).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(p.frozen_at).toBe(1000);
|
|
expect(p.sig).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(p.steps).toHaveLength(2);
|
|
});
|
|
it('plan_id is deterministic over steps content', () => {
|
|
expect(planId(STEPS)).toBe(planId(STEPS.map((s) => ({ ...s }))));
|
|
});
|
|
it('verifyFrozenPlan true for an intact signed plan', () => {
|
|
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
|
expect(verifyFrozenPlan(p, KEY)).toBe(true);
|
|
});
|
|
it('verifyFrozenPlan false when a step is tampered (re-seal required)', () => {
|
|
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
|
const tampered = { ...p, steps: [{ ...p.steps[0], object: 'tools/evil.mjs' }, p.steps[1]] };
|
|
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
|
|
});
|
|
it('verifyFrozenPlan false for unsigned / wrong key (no seal → fiction)', () => {
|
|
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
|
expect(verifyFrozenPlan({ ...p, sig: undefined }, KEY)).toBe(false);
|
|
expect(verifyFrozenPlan(p, 'other-key')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('actionMatchesStep (deterministic, P15-a/e)', () => {
|
|
const step = { n: 1, op: 'Write', object: 'tools/foo.mjs' };
|
|
const norm = (p) => p.replace(/\\/g, '/').toLowerCase(); // stub pathNormalize
|
|
|
|
it('matches when op AND object both equal (after normalize)', () => {
|
|
expect(actionMatchesStep(step, { op: 'Write', object: 'tools/FOO.mjs' }, { normalize: norm })).toBe(true);
|
|
});
|
|
it('does NOT match when op differs (operation axis, P15-e)', () => {
|
|
expect(actionMatchesStep(step, { op: 'Edit', object: 'tools/foo.mjs' }, { normalize: norm })).toBe(false);
|
|
});
|
|
it('does NOT match when object differs', () => {
|
|
expect(actionMatchesStep(step, { op: 'Write', object: 'tools/bar.mjs' }, { normalize: norm })).toBe(false);
|
|
});
|
|
it('Bash matches by normalized command', () => {
|
|
const bstep = { n: 1, op: 'Bash', object: 'npx vitest run foo' };
|
|
expect(actionMatchesStep(bstep, { op: 'Bash', object: 'npx vitest run foo' })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('nextStep (ordered pointer)', () => {
|
|
const steps = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
|
|
it('returns the step at the current pointer', () => {
|
|
expect(nextStep(steps, 0)).toEqual(steps[0]);
|
|
expect(nextStep(steps, 1)).toEqual(steps[1]);
|
|
});
|
|
it('returns null past the end', () => {
|
|
expect(nextStep(steps, 2)).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('frozen plan persistence', () => {
|
|
it('save then load round-trips and stays verifiable', () => {
|
|
const fs = memFs();
|
|
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
|
|
saveFrozenPlan({ plan: p, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
|
const loaded = loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
|
expect(verifyFrozenPlan(loaded, KEY)).toBe(true);
|
|
expect(loaded.plan_id).toBe(p.plan_id);
|
|
});
|
|
it('loadFrozenPlan returns null when no plan frozen', () => {
|
|
expect(loadFrozenPlan({ sessionId: 'none', runtimeDir: '/rt', fsImpl: memFs() })).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('reconcileJournalToPlan (P25-d)', () => {
|
|
const steps = [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }, { n: 2, op: 'Bash', object: 'npx vitest' }];
|
|
it('all journal actions map to a plan step → ok', () => {
|
|
const journal = [{ op: 'Write', object: 'tools/foo.mjs' }, { op: 'Bash', object: 'npx vitest' }];
|
|
expect(reconcileJournalToPlan(journal, steps).ok).toBe(true);
|
|
});
|
|
it('an orphan action (no matching step) is flagged', () => {
|
|
const journal = [{ op: 'Write', object: 'tools/foo.mjs' }, { op: 'Bash', object: 'rm -rf /' }];
|
|
const r = reconcileJournalToPlan(journal, steps);
|
|
expect(r.ok).toBe(false);
|
|
expect(r.orphans).toHaveLength(1);
|
|
expect(r.orphans[0].object).toBe('rm -rf /');
|
|
});
|
|
});
|
|
|
|
describe('freezeArtifact / verifyFrozenArtifact (вторая печать, C-10)', () => {
|
|
it('freezeArtifact returns a signed artifact with stable id', () => {
|
|
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
|
|
expect(a.artifact_id).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(a.sig).toMatch(/^[0-9a-f]{64}$/);
|
|
});
|
|
it('verify true for intact, false when a section tampered (re-seal required)', () => {
|
|
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
|
|
expect(verifyFrozenArtifact(a, KEY)).toBe(true);
|
|
const tampered = { ...a, sections: { ...a.sections, '§1': 'evil' } };
|
|
expect(verifyFrozenArtifact(tampered, KEY)).toBe(false);
|
|
});
|
|
it('artifactId deterministic over content', () => {
|
|
expect(artifactId(ART)).toBe(artifactId({ ...ART }));
|
|
});
|
|
it('artifact save/load round-trips and stays verifiable', () => {
|
|
const fs = memFs();
|
|
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
|
|
saveFrozenArtifact({ artifact: a, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
|
const loaded = loadFrozenArtifact({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
|
|
expect(verifyFrozenArtifact(loaded, KEY)).toBe(true);
|
|
expect(loaded.artifact_id).toBe(a.artifact_id);
|
|
});
|
|
});
|
|
|
|
describe('refResolves (закрытая дверь, C-5)', () => {
|
|
const ARTSEC = { sections: { '§1': 'teal', '§2': 'Inter' } };
|
|
it('ссылка шага резолвится в опечатанном артефакте', () => {
|
|
expect(refResolves({ n: 1, op: 'Write', object: 'x', ref: '§1' }, ARTSEC)).toBe(true);
|
|
});
|
|
it('несуществующая ссылка → не резолвится', () => {
|
|
expect(refResolves({ n: 1, op: 'Write', object: 'x', ref: '§9' }, ARTSEC)).toBe(false);
|
|
});
|
|
it('шаг без ref (простой/legacy) → резолв не требуется (true)', () => {
|
|
expect(refResolves({ n: 1, op: 'Write', object: 'x' }, ARTSEC)).toBe(true);
|
|
});
|
|
});
|