ktg-plugin-marketplace/plugins/voyage/tests/integration/schema-rollback.test.mjs
Kjell Tore Guttormsen c412f72605 test(voyage): add annotation roundtrip + rollback + source_annotations integration — v4.2 Step 7
Implements SC2/SC3/SC5b/SC7 + additive-field invariant for the v4.2
annotation pipeline:

Fixtures (tests/fixtures/annotation/):
  - annotation-brief.md           — brief-validator-clean fixture
  - annotation-plan.md            — plan-validator-clean (2 steps)
  - annotation-review.md          — review-validator-clean
  - annotation-plan-large.md      — 51 steps (SC3 scale fixture)

Integration tests:
  - tests/integration/annotation-roundtrip.test.mjs — 7 cases:
    SC2 byte-identical empty round-trip across brief/plan/review,
    SC3 scale (51 steps + 100 anchors) round-trip,
    SC7 parseAnchors(stripAnchors(addAnchors(...))) === [] per target.
  - tests/integration/schema-rollback.test.mjs — 4 cases:
    SC5b validator-FAIL -> revisionGuard rolls back byte-identical
    (sha256 invariant) for brief/plan/review + cross-target sweep.
    .local.bak deleted on rollback path (validator-PASS path tested
    in lib/util/revision-guard tests).
  - tests/lib/source-annotations.test.mjs — 6 cases mirroring
    tests/lib/source-findings.test.mjs additive-field pattern: each
    validator (brief/plan/review) accepts source_annotations as
    additive-optional, parser extracts as array of dicts, entries
    conform to documented shape, baseline forward-compat (artifacts
    without source_annotations still validate).

Verify: node --test tests/integration/annotation-roundtrip.test.mjs
       tests/integration/schema-rollback.test.mjs
       tests/lib/source-annotations.test.mjs -> 17 pass / 0 fail.
Full npm test: 577 pass / 0 fail / 2 skipped (Docker).

Refs plan.md Step 7 + plan-critic M4 + plan-critic B4.
2026-05-09 15:13:27 +02:00

135 lines
5.7 KiB
JavaScript

// tests/integration/schema-rollback.test.mjs
// SC5b: post-write validator failure rolls back byte-identical pre-revision state.
//
// Exercises lib/util/revision-guard.mjs revisionGuard():
// - Apply a deliberately-corrupting mutator that produces an artifact
// the validator will reject (missing required section / wrong type).
// - Assert outcome === 'rolled-back'.
// - Assert sha256_after === sha256_before (byte-identical recovery).
// - Assert .local.bak is deleted on the rollback path.
//
// Cases:
// 1. brief-rollback — strip a required body section
// 2. plan-rollback — break plan structure (rename Implementation Plan)
// 3. review-rollback — flip type to non-trekreview
// 4. sha256-invariance-cross-target — across all three targets, verify
// the byte-invariance holds for at least one common corrupting class
// (frontmatter `type:` flip).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { revisionGuard } from '../../lib/util/revision-guard.mjs';
import { validateBrief } from '../../lib/validators/brief-validator.mjs';
import { validatePlan } from '../../lib/validators/plan-validator.mjs';
import { validateReview } from '../../lib/validators/review-validator.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const FIX_DIR = join(ROOT, 'tests/fixtures/annotation');
function sha256(p) {
return createHash('sha256').update(readFileSync(p)).digest('hex');
}
function tmpCopy(name) {
const dir = mkdtempSync(join(tmpdir(), 'voyage-rollback-'));
const dst = join(dir, name);
copyFileSync(join(FIX_DIR, name), dst);
return { dir, path: dst };
}
test('brief-rollback: strip Goal section -> validator FAIL -> byte-identical restore', () => {
const { dir, path } = tmpCopy('annotation-brief.md');
try {
const sha_before = sha256(path);
const result = revisionGuard(
path,
({ frontmatter, body }) => ({
frontmatter,
body: body.replace(/## Goal[\s\S]*?(?=\n## Success Criteria)/, ''), // strip Goal section
}),
validateBrief,
);
assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`);
const sha_after = sha256(path);
assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback');
assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('plan-rollback: rename Implementation Plan heading -> validator FAIL -> byte-identical restore', () => {
const { dir, path } = tmpCopy('annotation-plan.md');
try {
const sha_before = sha256(path);
const result = revisionGuard(
path,
({ frontmatter, body }) => ({
frontmatter,
// Inject a forbidden phase-style heading the plan-schema rejects (PLAN_FORBIDDEN_HEADING)
body: body + '\n\n### Fase 99: This forbidden heading triggers PLAN_FORBIDDEN_HEADING\n',
}),
validatePlan,
);
assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`);
const sha_after = sha256(path);
assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback');
assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('review-rollback: flip type to non-trekreview -> validator FAIL -> byte-identical restore', () => {
const { dir, path } = tmpCopy('annotation-review.md');
try {
const sha_before = sha256(path);
const result = revisionGuard(
path,
({ frontmatter, body }) => ({
frontmatter: { ...frontmatter, type: 'not-a-real-type' },
body,
}),
validateReview,
);
assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`);
const sha_after = sha256(path);
assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback');
assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('sha256-invariance-cross-target: byte-identical rollback for all three targets', () => {
const cases = [
{ fixture: 'annotation-brief.md', validator: validateBrief, frontmatterCorruption: { type: 'wrong-type' } },
{ fixture: 'annotation-plan.md', validator: validatePlan, bodyCorruption: '\n\n### Fase 1: forbidden\n' },
{ fixture: 'annotation-review.md', validator: validateReview, frontmatterCorruption: { findings: 'not-an-array' } },
];
for (const c of cases) {
const { dir, path } = tmpCopy(c.fixture);
try {
const sha_before = sha256(path);
const result = revisionGuard(
path,
({ frontmatter, body }) => ({
frontmatter: c.frontmatterCorruption ? { ...frontmatter, ...c.frontmatterCorruption } : frontmatter,
body: c.bodyCorruption ? body + c.bodyCorruption : body,
}),
c.validator,
);
assert.strictEqual(result.outcome, 'rolled-back', `${c.fixture}: expected rolled-back, got ${result.outcome}`);
assert.strictEqual(sha256(path), sha_before, `${c.fixture}: sha256 must be byte-identical after rollback`);
assert.ok(!existsSync(path + '.local.bak'), `${c.fixture}: .local.bak must be deleted after rollback`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
});