ktg-plugin-marketplace/plugins/voyage/tests/integration/annotation-roundtrip.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

133 lines
6.1 KiB
JavaScript

// tests/integration/annotation-roundtrip.test.mjs
// SC2 + SC3 + SC7 integration tests for the annotation round-trip pipeline.
//
// SC2 (byte-identical empty round-trip):
// For each target fixture (brief/plan/review), assert that
// stripAnchors(addAnchors(body, [])) === body, byte-for-byte.
//
// SC3 (scale: >=50 steps + >=100 anchors):
// On the 51-step scale fixture, generate 100 anchors above varied lines,
// run addAnchors -> stripAnchors, assert the original body is restored
// byte-for-byte.
//
// SC7 (per-target isolation):
// parseAnchors(stripAnchors(addAnchors(body, anchors))) === [] — once
// anchors are stripped, no residual voyage:anchor markers remain that
// parseAnchors would re-detect.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
import { parseAnchors, addAnchors, stripAnchors } from '../../lib/parsers/anchor-parser.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const FIX_DIR = join(ROOT, 'tests/fixtures/annotation');
function readBody(fixture) {
const text = readFileSync(join(FIX_DIR, fixture), 'utf-8');
const doc = parseDocument(text);
assert.ok(doc.valid, `fixture ${fixture} did not parse: ${(doc.errors || []).map(e => e.message).join(', ')}`);
return doc.parsed.body;
}
test('annotation-brief.md byte-identical empty round-trip (SC2)', () => {
const body = readBody('annotation-brief.md');
const out = stripAnchors(addAnchors(body, []));
assert.strictEqual(out, body, 'empty addAnchors+stripAnchors must be byte-identical');
});
test('annotation-plan.md byte-identical empty round-trip (SC2)', () => {
const body = readBody('annotation-plan.md');
const out = stripAnchors(addAnchors(body, []));
assert.strictEqual(out, body, 'empty addAnchors+stripAnchors must be byte-identical');
});
test('annotation-review.md byte-identical empty round-trip (SC2)', () => {
const body = readBody('annotation-review.md');
const out = stripAnchors(addAnchors(body, []));
assert.strictEqual(out, body, 'empty addAnchors+stripAnchors must be byte-identical');
});
test('annotation-plan-large.md scale (51 steps + 100 anchors) round-trip (SC3)', () => {
const body = readBody('annotation-plan-large.md');
const lineCount = body.split('\n').length;
// Generate 100 anchors targeting safe paragraph lines. Place them above
// line numbers that are deliberately avoided by anchor-parser placement
// rules: skip anchor insertion above headings and inside fenced blocks.
// Strategy: pick 100 safe insertion points by walking blank lines outside
// fenced blocks; anchor at line N inserts above line N (so line N must
// be a content line, not a fence delimiter).
const lines = body.split('\n');
const safe = [];
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
if (/^```/.test(ln)) { inFence = !inFence; continue; }
if (inFence) continue;
// Skip headings, blank lines, list items, and structural anchors
if (ln.startsWith('#') || ln.trim() === '' || /^\s*[-*+]\s/.test(ln)) continue;
safe.push(i + 1); // 1-indexed line number
}
assert.ok(safe.length >= 100, `need >=100 safe insertion points; got ${safe.length}`);
const anchors = [];
for (let n = 0; n < 100; n++) {
anchors.push({
id: `ANN-${String(n + 1).padStart(4, '0')}`,
target: `step-${(n % 51) + 1}`,
line: safe[n],
intent: ['fix', 'change', 'question', 'block'][n % 4],
});
}
const annotated = addAnchors(body, anchors);
// sanity: 100 anchors produced
const parsed = parseAnchors(annotated);
assert.ok(parsed.valid, `parseAnchors on annotated body failed: ${(parsed.errors || []).map(e => e.message).join('; ')}`);
assert.strictEqual(parsed.parsed.length, 100, `expected 100 anchors after addAnchors, got ${parsed.parsed.length}`);
// Round-trip restores body byte-for-byte.
const restored = stripAnchors(annotated);
assert.strictEqual(restored, body, 'addAnchors -> stripAnchors must round-trip byte-identical at scale');
});
test('parseAnchors(stripAnchors(addAnchors(brief, anchors))) === [] (SC7 brief)', () => {
const body = readBody('annotation-brief.md');
const lines = body.split('\n');
// Pick a content line — first non-blank, non-heading line
const target = lines.findIndex(l => l.length > 0 && !l.startsWith('#')) + 1;
assert.ok(target > 0, 'brief fixture has no content lines');
const anchors = [{ id: 'ANN-0001', target: 'intent', line: target, intent: 'change' }];
const annotated = addAnchors(body, anchors);
const stripped = stripAnchors(annotated);
const result = parseAnchors(stripped);
assert.ok(result.valid, 'parseAnchors on stripped body should be valid');
assert.deepStrictEqual(result.parsed, [], 'no anchors should remain after stripAnchors');
});
test('parseAnchors(stripAnchors(addAnchors(plan, anchors))) === [] (SC7 plan)', () => {
const body = readBody('annotation-plan.md');
const lines = body.split('\n');
const target = lines.findIndex(l => l.startsWith('A minimal')) + 1;
assert.ok(target > 0, 'plan fixture missing expected content line');
const anchors = [{ id: 'ANN-0001', target: 'context', line: target, intent: 'fix' }];
const annotated = addAnchors(body, anchors);
const stripped = stripAnchors(annotated);
const result = parseAnchors(stripped);
assert.ok(result.valid);
assert.deepStrictEqual(result.parsed, []);
});
test('parseAnchors(stripAnchors(addAnchors(review, anchors))) === [] (SC7 review)', () => {
const body = readBody('annotation-review.md');
const lines = body.split('\n');
const target = lines.findIndex(l => l.startsWith('Verdict')) + 1;
assert.ok(target > 0, 'review fixture missing Verdict line');
const anchors = [{ id: 'ANN-0001', target: 'executive-summary', line: target, intent: 'question' }];
const annotated = addAnchors(body, anchors);
const stripped = stripAnchors(annotated);
const result = parseAnchors(stripped);
assert.ok(result.valid);
assert.deepStrictEqual(result.parsed, []);
});