Files
portal/tools/plan-lock.test.mjs
T
Дмитрий 09598dd5bd feat(seal): sealed-plan production pipeline (M7 Фаза 8 code-precondition)
Производство двух печатей (артефакт-решение + план-шаги), чтобы стене М2 было
что матчить — код-предусловие флипа. Inline TDD, спека/план одобрены владельцем.

- C1 artifact-from-spec.mjs: спека markdown -> {sections, source_sha} по якорям {#id} (P2-2).
- C2 plan-steps-parse.mjs: план -> [{op,object,ref}], fail-CLOSE, reject op:Task (VA-4),
  канон object = repo-relative POSIX (SE-5; pathNormalize только на матче в стене, не на парсе).
- C3/C4 plan-lock.mjs: judge_mode в ПОДПИСАННОЙ базе freezePlan (VA-2) + атомарный persist
  temp->rename для обоих save (SE-4/VA-3, артефакт ДО плана).
- C6 seal-orchestration.mjs: sealableArtifact/sealablePlan + judgedHashOf (SD-1) +
  sealArtifact/sealPlan на РЕАЛЬНОМ GO (SE-3 wired===true), штамп artifact_id из текущего
  артефакта (SD-3), judge_mode впрыснут в печать ПОСЛЕ хеш-сверки sealOnApproval (фикс TOCTOU).
- C5 enforce-judge-gate.mjs: SPEC_PATH_RE + sealOnWiredGo (печать на wired GO, инъекция в main,
  юнит-тесты hermetic) + judged_hash в вердикте runJudgeGate. extractGate2Product не тронут
  (Гейт-2 = планы; Гейт-1 spec-judging — отдельный заход перед флипом).
- Интеграция seal-to-wall: печать -> decideMode стены М2 (allow / non-match block / closed-door).

Тесты: full tools-only регрессия 3427 passed | 2 skipped, 0 регрессий (+29 новых кейсов).
Печать в рантайме НЕ производится до флипа (стена/судья не зарегистрированы) — сборка
готовит код-предусловие. Спека docs/superpowers/specs/2026-06-09-sealed-plan-production-design.md.
2026-06-09 17:50:25 +03:00

326 lines
17 KiB
JavaScript
Raw 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.
// 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: 'лендинг' };
describe('доменное разделение печатей (R-31)', () => {
it('замороженный план НЕ проходит как артефакт, и наоборот', () => {
const plan = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
const art = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
expect(verifyFrozenPlan(plan, KEY)).toBe(true);
expect(verifyFrozenArtifact(art, KEY)).toBe(true);
expect(verifyFrozenArtifact(plan, KEY)).toBe(false); // печать плана ≠ печать артефакта
expect(verifyFrozenPlan(art, KEY)).toBe(false);
});
});
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)),
renameSync: (a, b) => { s.set(String(b), s.get(String(a))); s.delete(String(a)); } };
}
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);
});
// fix: tools/plan-lock.mjs (F5, аудит M1-M4) — пустой object шага не должен быть джокером
it('шаг с пустым файловым object НЕ матчит действие с пустым object', () => {
expect(actionMatchesStep({ n: 1, op: 'Write', object: '' }, { op: 'Write', object: '' }, { normalize: norm })).toBe(false);
});
it('шаг Bash с пустой командой НЕ матчит пустую команду', () => {
expect(actionMatchesStep({ n: 1, op: 'Bash', object: '' }, { op: 'Bash', object: '' })).toBe(false);
});
});
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('sealed-plan production Task 3 — judge_mode in sig + atomic persist', () => {
it('freezePlan carries judge_mode in signed base (VA-2)', () => {
const key = 'k';
const p = freezePlan({ steps: [{ op: 'Edit', object: 'a', ref: 'r' }], artifactId: 'aid', judgeMode: 'shadow', key });
expect(p.judge_mode).toBe('shadow');
expect(verifyFrozenPlan(p, key)).toBe(true);
const tampered = { ...p, judge_mode: 'live-block' };
expect(verifyFrozenPlan(tampered, key)).toBe(false); // judge_mode в подписи
});
it('saveFrozenPlan writes atomically via temp+rename (SE-4)', () => {
const calls = [];
const fsImpl = { writeFileSync: (p, d) => calls.push(['write', p]), renameSync: (a, b) => calls.push(['rename', a, b]) };
saveFrozenPlan({ plan: { plan_id: 'x' }, sessionId: 's', runtimeDir: '/r', fsImpl });
expect(calls.some((c) => c[0] === 'rename')).toBe(true); // прошёл через rename
});
it('saveFrozenArtifact also writes atomically via temp+rename (SE-4/VA-3)', () => {
const calls = [];
const fsImpl = { writeFileSync: (p, d) => calls.push(['write', p]), renameSync: (a, b) => calls.push(['rename', a, b]) };
saveFrozenArtifact({ artifact: { artifact_id: 'a' }, sessionId: 's', runtimeDir: '/r', fsImpl });
expect(calls.some((c) => c[0] === 'rename')).toBe(true);
});
});
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);
});
});
// N3-shared (2026-06-07 аудит M1-M4): planPath/artifactPath строят путь из sessionId
// (event.session_id, недоверенный источник) — тот же guard формы, что action-journal.
describe('N3: plan-lock path-injection guard (planPath + artifactPath)', () => {
it('saveFrozenPlan с traversal-sessionId бросает (ничего не пишет)', () => {
const fs = memFs();
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
expect(() => saveFrozenPlan({ plan: p, sessionId: '../evil', runtimeDir: '/rt', fsImpl: fs })).toThrow();
expect(fs.s.size).toBe(0);
});
it('loadFrozenPlan/saveFrozenArtifact/loadFrozenArtifact с traversal/слэшем/точкой бросают', () => {
const fs = memFs();
const a = freezeArtifact({ artifact: ART, key: KEY, nowMs: 1 });
expect(() => loadFrozenPlan({ sessionId: '../../etc/passwd', runtimeDir: '/rt', fsImpl: fs })).toThrow();
expect(() => saveFrozenArtifact({ artifact: a, sessionId: 'a/b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
expect(() => loadFrozenArtifact({ sessionId: 'a.b', runtimeDir: '/rt', fsImpl: fs })).toThrow();
});
it('нормальный sessionId по-прежнему работает (план + артефакт)', () => {
const fs = memFs();
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
saveFrozenPlan({ plan: p, sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs });
expect(loadFrozenPlan({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }).plan_id).toBe(p.plan_id);
});
});
// 5.1 (Машина 5 Пакет 5, Блок 3) — plan-lock присваивает каждому шагу детерминированный
// criterion_id ИЗ ЗАПЕЧАТАННОГО содержимого: id воспроизводим (функция содержания шага),
// запечатан подписью плана (нельзя выдумать на демо-этапе — подмена ломает verifyFrozenPlan).
import { stepCriterionId, sealedCriterionIds } from './plan-lock.mjs';
describe('plan-lock sealed criterion_id (Пакет 5, 5.1)', () => {
it('freezePlan присваивает каждому шагу criterion_id (64-hex)', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
for (const s of p.steps) {
expect(s.criterion_id).toMatch(/^[0-9a-f]{64}$/);
}
});
it('criterion_id детерминирован: две заморозки тех же шагов → те же id', () => {
const a = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
const b = freezePlan({ steps: STEPS, key: KEY, nowMs: 2 });
expect(a.steps.map((s) => s.criterion_id)).toEqual(b.steps.map((s) => s.criterion_id));
});
it('id воспроизводим из содержимого шага (stepCriterionId)', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
expect(p.steps[0].criterion_id).toBe(stepCriterionId(STEPS[0]));
});
it('разное содержимое шага → разный id (подмена смысла при сохранении id невозможна)', () => {
expect(stepCriterionId({ n: 1, op: 'Write', object: 'a' })).not.toBe(
stepCriterionId({ n: 1, op: 'Write', object: 'b' }),
);
});
it('stepCriterionId идемпотентен на уже-обогащённом шаге (criterion_id исключён из хеша)', () => {
const cid = stepCriterionId(STEPS[0]);
expect(stepCriterionId({ ...STEPS[0], criterion_id: cid })).toBe(cid);
});
it('sealedCriterionIds возвращает запечатанный набор id плана', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
expect(sealedCriterionIds(p).sort()).toEqual(p.steps.map((s) => s.criterion_id).sort());
});
it('подмена criterion_id шага после печати → verifyFrozenPlan false (id запечатан подписью)', () => {
const p = freezePlan({ steps: STEPS, key: KEY, nowMs: 1 });
const tampered = { ...p, steps: [{ ...p.steps[0], criterion_id: 'deadbeef'.repeat(8) }, p.steps[1]] };
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
});
});
// ── R-08: дерево листьев / лист по указателю / валидация дерева ──
import { treeLeaves, treeLeafAt, validatePlanTree } from './plan-lock.mjs';
const R08TREE = [
{ n: 1, op: 'Write', object: 'a' },
{ n: 2, substeps: [{ n: '2.1', op: 'Edit', object: 'x' }, { n: '2.2', op: 'Bash', object: 'y' }] },
{ n: 3, op: 'Write', object: 'c' },
];
describe('treeLeaves (depth-first, V1/V2 вход)', () => {
it('дерево → все листья по порядку', () => {
expect(treeLeaves(R08TREE).map((s) => s.n)).toEqual([1, '2.1', '2.2', 3]);
});
it('плоский план → сами шаги (идентичность)', () => {
const flat = [{ n: 1, op: 'Write', object: 'a' }, { n: 2, op: 'Bash', object: 'b' }];
expect(treeLeaves(flat)).toEqual(flat);
});
});
describe('treeLeafAt (один лист по указателю, SE-2)', () => {
it('целое 0 → первый лист', () => {
expect(treeLeafAt(R08TREE, 0).n).toBe(1);
});
it('целое 1 (контейнер) → спуск к первому листу, не контейнер', () => {
const leaf = treeLeafAt(R08TREE, 1);
expect(leaf.n).toBe('2.1');
expect(leaf.substeps).toBeUndefined();
});
it('массив [1,1] → 2.2', () => {
expect(treeLeafAt(R08TREE, [1, 1]).n).toBe('2.2');
});
it('за концом → null', () => {
expect(treeLeafAt(R08TREE, 9)).toBe(null);
});
it('битый указатель → null', () => {
expect(treeLeafAt(R08TREE, 'x')).toBe(null);
});
});
describe('validatePlanTree (SE-2/SE-4 fail-CLOSED)', () => {
it('валидное дерево → ok', () => {
expect(validatePlanTree(R08TREE).ok).toBe(true);
});
it('пустой контейнер → не ok (SE-4)', () => {
expect(validatePlanTree([{ n: 1, substeps: [] }]).ok).toBe(false);
});
it('контейнер с op/object/ref → не ok (SE-2)', () => {
expect(validatePlanTree([{ n: 1, op: 'Write', object: 'a', substeps: [{ n: '1.1', op: 'Edit', object: 'x' }] }]).ok).toBe(false);
});
it('substeps не массив → не ok', () => {
expect(validatePlanTree([{ n: 1, substeps: 'x' }]).ok).toBe(false);
});
it('превышение глубины → не ok', () => {
let node = { n: 'leaf', op: 'Write', object: 'z' };
for (let i = 0; i < 12; i++) node = { substeps: [node] };
expect(validatePlanTree([node]).ok).toBe(false);
});
});
describe('freezePlan покрывает substeps подписью + criterion_id рекурсия (I9/I10)', () => {
it('правка под-шага ломает sig', () => {
const p = freezePlan({ steps: R08TREE, key: KEY, nowMs: 1 });
const tampered = { ...p, steps: [p.steps[0], { ...p.steps[1], substeps: [{ ...p.steps[1].substeps[0], object: 'evil' }, p.steps[1].substeps[1]] }, p.steps[2]] };
expect(verifyFrozenPlan(tampered, KEY)).toBe(false);
});
it('под-шаги получают criterion_id', () => {
const p = freezePlan({ steps: R08TREE, key: KEY, nowMs: 1 });
expect(p.steps[1].substeps[0].criterion_id).toMatch(/^[0-9a-f]{64}$/);
});
});