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.
This commit is contained in:
parent
4fbc52bbb4
commit
c412f72605
7 changed files with 1732 additions and 0 deletions
34
plugins/voyage/tests/fixtures/annotation/annotation-brief.md
vendored
Normal file
34
plugins/voyage/tests/fixtures/annotation/annotation-brief.md
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "1.0"
|
||||
task: Demo task for annotation round-trip fixture
|
||||
slug: annotation-brief-demo
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
---
|
||||
|
||||
# Demo brief for annotation round-trip
|
||||
|
||||
This fixture is used by `tests/integration/annotation-roundtrip.test.mjs`
|
||||
to verify SC2 (byte-identical empty-anchor round-trip) and SC7 (per-target
|
||||
isolation against `validateBrief`).
|
||||
|
||||
It carries no anchors. The round-trip test runs:
|
||||
`stripAnchors(addAnchors(body, [])) === body`.
|
||||
|
||||
## Intent
|
||||
|
||||
Provide a minimal brief that validates against `brief-validator.mjs` so
|
||||
the round-trip integration test has a real artifact to revise.
|
||||
|
||||
## Goal
|
||||
|
||||
The brief should validate cleanly (no errors, no warnings) and contain
|
||||
enough body text that adding an anchor and stripping it back is a
|
||||
non-trivial operation.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- File parses via `parseDocument`.
|
||||
- `validateBrief` returns `valid: true`.
|
||||
- `stripAnchors(addAnchors(body, []))` is byte-identical to body.
|
||||
1090
plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md
vendored
Normal file
1090
plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md
vendored
Normal file
File diff suppressed because it is too large
Load diff
64
plugins/voyage/tests/fixtures/annotation/annotation-plan.md
vendored
Normal file
64
plugins/voyage/tests/fixtures/annotation/annotation-plan.md
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
plan_version: 1.7
|
||||
profile: balanced
|
||||
---
|
||||
|
||||
# Demo plan for annotation round-trip
|
||||
|
||||
This fixture is used by `tests/integration/annotation-roundtrip.test.mjs`
|
||||
to verify SC2 (byte-identical empty-anchor round-trip) and SC7 (per-target
|
||||
isolation against `validatePlan`).
|
||||
|
||||
## Context
|
||||
|
||||
A minimal plan with two steps. Each step has a Manifest block so
|
||||
`plan-validator --strict` accepts the file.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Touch a sentinel file
|
||||
|
||||
- **Files:** `tmp/sentinel-1.txt` (new)
|
||||
- **Changes:** Create the sentinel file with the literal content "step-1".
|
||||
- **Reuses:** none.
|
||||
- **Test first:** none — sentinel-only step.
|
||||
- **Verify:** `test -f tmp/sentinel-1.txt`
|
||||
- **On failure:** revert.
|
||||
- **Checkpoint:** `git commit -m "chore: sentinel step 1"`
|
||||
- **Manifest:**
|
||||
```yaml
|
||||
manifest:
|
||||
expected_paths:
|
||||
- tmp/sentinel-1.txt
|
||||
min_file_count: 1
|
||||
commit_message_pattern: "^chore: sentinel step 1"
|
||||
bash_syntax_check: []
|
||||
forbidden_paths: []
|
||||
must_contain: []
|
||||
```
|
||||
|
||||
### Step 2: Touch a second sentinel file
|
||||
|
||||
- **Files:** `tmp/sentinel-2.txt` (new)
|
||||
- **Changes:** Create the sentinel file with the literal content "step-2".
|
||||
- **Reuses:** none.
|
||||
- **Test first:** none.
|
||||
- **Verify:** `test -f tmp/sentinel-2.txt`
|
||||
- **On failure:** revert.
|
||||
- **Checkpoint:** `git commit -m "chore: sentinel step 2"`
|
||||
- **Manifest:**
|
||||
```yaml
|
||||
manifest:
|
||||
expected_paths:
|
||||
- tmp/sentinel-2.txt
|
||||
min_file_count: 1
|
||||
commit_message_pattern: "^chore: sentinel step 2"
|
||||
bash_syntax_check: []
|
||||
forbidden_paths: []
|
||||
must_contain: []
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm test` passes.
|
||||
- Both sentinel files exist.
|
||||
32
plugins/voyage/tests/fixtures/annotation/annotation-review.md
vendored
Normal file
32
plugins/voyage/tests/fixtures/annotation/annotation-review.md
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
type: trekreview
|
||||
review_version: "1.0"
|
||||
task: Demo review for annotation round-trip
|
||||
slug: annotation-review-demo
|
||||
project_dir: .claude/projects/2026-05-09-annotation-demo
|
||||
brief_path: .claude/projects/2026-05-09-annotation-demo/brief.md
|
||||
scope_sha_end: 0000000000000000000000000000000000000000
|
||||
reviewed_files_count: 0
|
||||
findings: []
|
||||
---
|
||||
|
||||
# Demo review for annotation round-trip
|
||||
|
||||
This fixture is used by `tests/integration/annotation-roundtrip.test.mjs`
|
||||
to verify SC2 (byte-identical empty-anchor round-trip) and SC7 (per-target
|
||||
isolation against `validateReview`).
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Verdict: ALLOW. No findings. This is a synthetic fixture used to exercise
|
||||
the round-trip mechanics; it does not represent a real review.
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment |
|
||||
|------|-----------|
|
||||
| _none_ | _no diff_ |
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
No remediation needed. ALLOW.
|
||||
133
plugins/voyage/tests/integration/annotation-roundtrip.test.mjs
Normal file
133
plugins/voyage/tests/integration/annotation-roundtrip.test.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// 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, []);
|
||||
});
|
||||
135
plugins/voyage/tests/integration/schema-rollback.test.mjs
Normal file
135
plugins/voyage/tests/integration/schema-rollback.test.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
244
plugins/voyage/tests/lib/source-annotations.test.mjs
Normal file
244
plugins/voyage/tests/lib/source-annotations.test.mjs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// tests/lib/source-annotations.test.mjs
|
||||
// Additive-field invariant for source_annotations: array (Handover 8).
|
||||
//
|
||||
// Mirrors tests/lib/source-findings.test.mjs:9-13 — the structural three-part
|
||||
// contract that v4.2 brief-validator + plan-validator + review-validator must
|
||||
// uphold for the new optional source_annotations frontmatter field:
|
||||
//
|
||||
// 1. validators accept an artifact with source_annotations (additive optional)
|
||||
// 2. frontmatter parser extracts source_annotations as an array
|
||||
// 3. each entry has the documented annotation shape
|
||||
// ({id, target_artifact, target_anchor, intent, ...})
|
||||
//
|
||||
// LLM behavior (the planner actually emitting source_annotations) is
|
||||
// non-testable without live invocation — this test only covers the schema
|
||||
// half. See Step 12 doc-pin for the operator-level contract.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.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 ID_RE = /^ANN-\d{4}$/;
|
||||
const VALID_INTENT = new Set(['fix', 'change', 'question', 'block']);
|
||||
|
||||
function makeFixture(name, body) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'voyage-source-ann-'));
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, body);
|
||||
return { dir, path };
|
||||
}
|
||||
|
||||
const BRIEF_WITH_SOURCE_ANNOTATIONS = `---
|
||||
type: trekbrief
|
||||
brief_version: "1.0"
|
||||
task: Demo brief with source_annotations
|
||||
slug: source-annotations-demo-brief
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
revision: 1
|
||||
annotation_digest: deadbeefcafe1234
|
||||
source_annotations:
|
||||
- id: ANN-0001
|
||||
target_artifact: brief.md
|
||||
target_anchor: goal
|
||||
line: 20
|
||||
intent: change
|
||||
- id: ANN-0002
|
||||
target_artifact: brief.md
|
||||
target_anchor: success-criteria
|
||||
line: 30
|
||||
intent: fix
|
||||
---
|
||||
|
||||
# Demo
|
||||
|
||||
## Intent
|
||||
|
||||
Test fixture.
|
||||
|
||||
## Goal
|
||||
|
||||
Test fixture.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- It validates.
|
||||
`;
|
||||
|
||||
const PLAN_WITH_SOURCE_ANNOTATIONS = `---
|
||||
plan_version: 1.7
|
||||
profile: balanced
|
||||
revision: 2
|
||||
annotation_digest: cafebabe98765432
|
||||
source_annotations:
|
||||
- id: ANN-0001
|
||||
target_artifact: plan.md
|
||||
target_anchor: step-1
|
||||
line: 25
|
||||
intent: fix
|
||||
---
|
||||
|
||||
# Demo plan
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Sentinel
|
||||
|
||||
- **Files:** \`tmp/x.txt\` (new)
|
||||
- **Changes:** Touch.
|
||||
- **Verify:** \`test -f tmp/x.txt\`
|
||||
- **On failure:** revert.
|
||||
- **Checkpoint:** \`git commit -m "chore: x"\`
|
||||
- **Manifest:**
|
||||
\`\`\`yaml
|
||||
manifest:
|
||||
expected_paths:
|
||||
- tmp/x.txt
|
||||
min_file_count: 1
|
||||
commit_message_pattern: "^chore: x"
|
||||
bash_syntax_check: []
|
||||
forbidden_paths: []
|
||||
must_contain: []
|
||||
\`\`\`
|
||||
|
||||
## Verification
|
||||
|
||||
- It validates.
|
||||
`;
|
||||
|
||||
const REVIEW_WITH_SOURCE_ANNOTATIONS = `---
|
||||
type: trekreview
|
||||
review_version: "1.0"
|
||||
task: Demo review with source_annotations
|
||||
slug: source-annotations-demo-review
|
||||
project_dir: .claude/projects/2026-05-09-demo
|
||||
brief_path: .claude/projects/2026-05-09-demo/brief.md
|
||||
scope_sha_end: 0000000000000000000000000000000000000000
|
||||
reviewed_files_count: 0
|
||||
findings: []
|
||||
revision: 1
|
||||
annotation_digest: 0123456789abcdef
|
||||
source_annotations:
|
||||
- id: ANN-0001
|
||||
target_artifact: review.md
|
||||
target_anchor: executive-summary
|
||||
line: 18
|
||||
intent: question
|
||||
---
|
||||
|
||||
# Demo
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Verdict: ALLOW.
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment |
|
||||
|------|-----------|
|
||||
| _none_ | _no diff_ |
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
ALLOW.
|
||||
`;
|
||||
|
||||
test('validators accept artifacts with source_annotations field (additive optional, brief)', () => {
|
||||
const { dir, path } = makeFixture('brief.md', BRIEF_WITH_SOURCE_ANNOTATIONS);
|
||||
try {
|
||||
const r = validateBrief(path, { strict: true });
|
||||
assert.ok(
|
||||
r.valid,
|
||||
`brief-validator rejected synthetic brief with source_annotations: ` +
|
||||
`${(r.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('validators accept artifacts with source_annotations field (additive optional, plan)', () => {
|
||||
const { dir, path } = makeFixture('plan.md', PLAN_WITH_SOURCE_ANNOTATIONS);
|
||||
try {
|
||||
const r = validatePlan(path, { strict: true });
|
||||
assert.ok(
|
||||
r.valid,
|
||||
`plan-validator rejected synthetic plan with source_annotations: ` +
|
||||
`${(r.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('validators accept artifacts with source_annotations field (additive optional, review)', () => {
|
||||
const { dir, path } = makeFixture('review.md', REVIEW_WITH_SOURCE_ANNOTATIONS);
|
||||
try {
|
||||
const r = validateReview(path, { strict: true });
|
||||
assert.ok(
|
||||
r.valid,
|
||||
`review-validator rejected synthetic review with source_annotations: ` +
|
||||
`${(r.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('frontmatter parser extracts source_annotations as array of dicts (per artifact)', () => {
|
||||
const cases = [
|
||||
{ name: 'brief.md', body: BRIEF_WITH_SOURCE_ANNOTATIONS, expected: 2 },
|
||||
{ name: 'plan.md', body: PLAN_WITH_SOURCE_ANNOTATIONS, expected: 1 },
|
||||
{ name: 'review.md', body: REVIEW_WITH_SOURCE_ANNOTATIONS, expected: 1 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const doc = parseDocument(c.body);
|
||||
assert.ok(doc.valid, `${c.name}: frontmatter did not parse: ${(doc.errors || []).map(e => e.message).join(', ')}`);
|
||||
const sa = doc.parsed.frontmatter && doc.parsed.frontmatter.source_annotations;
|
||||
assert.ok(Array.isArray(sa), `${c.name}: frontmatter.source_annotations is not an array (got ${typeof sa})`);
|
||||
assert.strictEqual(sa.length, c.expected, `${c.name}: expected ${c.expected} entries, got ${sa.length}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('source_annotations entries match documented annotation shape', () => {
|
||||
const doc = parseDocument(BRIEF_WITH_SOURCE_ANNOTATIONS);
|
||||
const entries = doc.parsed.frontmatter.source_annotations;
|
||||
for (const e of entries) {
|
||||
assert.strictEqual(typeof e, 'object', `source_annotations entry is not an object: ${JSON.stringify(e)}`);
|
||||
assert.ok(typeof e.id === 'string' && ID_RE.test(e.id), `source_annotations[*].id must match /^ANN-\\d{4}$/, got ${JSON.stringify(e.id)}`);
|
||||
assert.ok(typeof e.target_artifact === 'string' && e.target_artifact.endsWith('.md'),
|
||||
`source_annotations[*].target_artifact must be a *.md path, got ${JSON.stringify(e.target_artifact)}`);
|
||||
assert.ok(typeof e.target_anchor === 'string' && e.target_anchor.length > 0,
|
||||
`source_annotations[*].target_anchor must be a non-empty string, got ${JSON.stringify(e.target_anchor)}`);
|
||||
if (e.intent !== undefined && e.intent !== null) {
|
||||
assert.ok(VALID_INTENT.has(e.intent),
|
||||
`source_annotations[*].intent must be in {fix|change|question|block}, got ${JSON.stringify(e.intent)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('artifacts WITHOUT source_annotations still validate (forward-compat baseline)', () => {
|
||||
// Forward-compat: artifacts that predate v4.2 must still validate. Fall back
|
||||
// to an artifact with neither revision nor source_annotations.
|
||||
const baseline = BRIEF_WITH_SOURCE_ANNOTATIONS
|
||||
.replace(/^revision:.*\n/m, '')
|
||||
.replace(/^annotation_digest:.*\n/m, '')
|
||||
.replace(/^source_annotations:[\s\S]*?(?=^---$|^[A-Za-z])/m, '');
|
||||
const { dir, path } = makeFixture('brief.md', baseline);
|
||||
try {
|
||||
const r = validateBrief(path, { strict: true });
|
||||
assert.ok(
|
||||
r.valid,
|
||||
`brief-validator must accept artifacts WITHOUT source_annotations (forward-compat baseline): ` +
|
||||
`${(r.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue