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.
244 lines
7.8 KiB
JavaScript
244 lines
7.8 KiB
JavaScript
// 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 });
|
|
}
|
|
});
|