From c412f726050ef0e672833ea3a2a484850c4edeca Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 9 May 2026 15:13:27 +0200 Subject: [PATCH] =?UTF-8?q?test(voyage):=20add=20annotation=20roundtrip=20?= =?UTF-8?q?+=20rollback=20+=20source=5Fannotations=20integration=20?= =?UTF-8?q?=E2=80=94=20v4.2=20Step=207?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fixtures/annotation/annotation-brief.md | 34 + .../annotation/annotation-plan-large.md | 1090 +++++++++++++++++ .../fixtures/annotation/annotation-plan.md | 64 + .../fixtures/annotation/annotation-review.md | 32 + .../integration/annotation-roundtrip.test.mjs | 133 ++ .../integration/schema-rollback.test.mjs | 135 ++ .../tests/lib/source-annotations.test.mjs | 244 ++++ 7 files changed, 1732 insertions(+) create mode 100644 plugins/voyage/tests/fixtures/annotation/annotation-brief.md create mode 100644 plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md create mode 100644 plugins/voyage/tests/fixtures/annotation/annotation-plan.md create mode 100644 plugins/voyage/tests/fixtures/annotation/annotation-review.md create mode 100644 plugins/voyage/tests/integration/annotation-roundtrip.test.mjs create mode 100644 plugins/voyage/tests/integration/schema-rollback.test.mjs create mode 100644 plugins/voyage/tests/lib/source-annotations.test.mjs diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-brief.md b/plugins/voyage/tests/fixtures/annotation/annotation-brief.md new file mode 100644 index 0000000..864b3b9 --- /dev/null +++ b/plugins/voyage/tests/fixtures/annotation/annotation-brief.md @@ -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. diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md b/plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md new file mode 100644 index 0000000..09a0aa5 --- /dev/null +++ b/plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md @@ -0,0 +1,1090 @@ +--- +plan_version: 1.7 +profile: balanced +--- + +# Scale plan for annotation round-trip (51 steps) + +This fixture is used by tests/integration/annotation-roundtrip.test.mjs to verify SC3 (>=50 steps + >=100 anchors) without breaking the parser at scale. + +## Context + +Each step is a sentinel-only step with a valid manifest. The plan validates against plan-validator --strict. + +## Implementation Plan + +### Step 1: Sentinel step 1 + +- **Files:** `tmp/sentinel-1.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-1". +- **Reuses:** none. +- **Test first:** none. +- **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: Sentinel step 2 + +- **Files:** `tmp/sentinel-2.txt` (new) +- **Changes:** Create 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: [] + ``` + +### Step 3: Sentinel step 3 + +- **Files:** `tmp/sentinel-3.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-3". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-3.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 3"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-3.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 3" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 4: Sentinel step 4 + +- **Files:** `tmp/sentinel-4.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-4". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-4.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 4"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-4.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 4" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 5: Sentinel step 5 + +- **Files:** `tmp/sentinel-5.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-5". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-5.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 5"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-5.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 5" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 6: Sentinel step 6 + +- **Files:** `tmp/sentinel-6.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-6". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-6.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 6"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-6.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 6" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 7: Sentinel step 7 + +- **Files:** `tmp/sentinel-7.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-7". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-7.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 7"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-7.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 7" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 8: Sentinel step 8 + +- **Files:** `tmp/sentinel-8.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-8". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-8.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 8"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-8.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 8" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 9: Sentinel step 9 + +- **Files:** `tmp/sentinel-9.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-9". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-9.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 9"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-9.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 9" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 10: Sentinel step 10 + +- **Files:** `tmp/sentinel-10.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-10". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-10.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 10"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-10.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 10" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 11: Sentinel step 11 + +- **Files:** `tmp/sentinel-11.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-11". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-11.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 11"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-11.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 11" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 12: Sentinel step 12 + +- **Files:** `tmp/sentinel-12.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-12". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-12.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 12"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-12.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 12" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 13: Sentinel step 13 + +- **Files:** `tmp/sentinel-13.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-13". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-13.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 13"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-13.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 13" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 14: Sentinel step 14 + +- **Files:** `tmp/sentinel-14.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-14". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-14.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 14"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-14.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 14" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 15: Sentinel step 15 + +- **Files:** `tmp/sentinel-15.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-15". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-15.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 15"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-15.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 15" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 16: Sentinel step 16 + +- **Files:** `tmp/sentinel-16.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-16". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-16.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 16"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-16.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 16" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 17: Sentinel step 17 + +- **Files:** `tmp/sentinel-17.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-17". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-17.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 17"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-17.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 17" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 18: Sentinel step 18 + +- **Files:** `tmp/sentinel-18.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-18". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-18.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 18"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-18.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 18" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 19: Sentinel step 19 + +- **Files:** `tmp/sentinel-19.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-19". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-19.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 19"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-19.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 19" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 20: Sentinel step 20 + +- **Files:** `tmp/sentinel-20.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-20". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-20.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 20"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-20.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 20" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 21: Sentinel step 21 + +- **Files:** `tmp/sentinel-21.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-21". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-21.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 21"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-21.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 21" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 22: Sentinel step 22 + +- **Files:** `tmp/sentinel-22.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-22". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-22.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 22"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-22.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 22" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 23: Sentinel step 23 + +- **Files:** `tmp/sentinel-23.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-23". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-23.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 23"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-23.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 23" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 24: Sentinel step 24 + +- **Files:** `tmp/sentinel-24.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-24". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-24.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 24"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-24.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 24" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 25: Sentinel step 25 + +- **Files:** `tmp/sentinel-25.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-25". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-25.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 25"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-25.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 25" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 26: Sentinel step 26 + +- **Files:** `tmp/sentinel-26.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-26". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-26.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 26"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-26.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 26" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 27: Sentinel step 27 + +- **Files:** `tmp/sentinel-27.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-27". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-27.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 27"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-27.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 27" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 28: Sentinel step 28 + +- **Files:** `tmp/sentinel-28.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-28". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-28.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 28"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-28.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 28" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 29: Sentinel step 29 + +- **Files:** `tmp/sentinel-29.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-29". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-29.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 29"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-29.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 29" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 30: Sentinel step 30 + +- **Files:** `tmp/sentinel-30.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-30". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-30.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 30"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-30.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 30" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 31: Sentinel step 31 + +- **Files:** `tmp/sentinel-31.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-31". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-31.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 31"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-31.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 31" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 32: Sentinel step 32 + +- **Files:** `tmp/sentinel-32.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-32". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-32.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 32"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-32.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 32" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 33: Sentinel step 33 + +- **Files:** `tmp/sentinel-33.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-33". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-33.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 33"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-33.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 33" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 34: Sentinel step 34 + +- **Files:** `tmp/sentinel-34.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-34". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-34.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 34"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-34.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 34" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 35: Sentinel step 35 + +- **Files:** `tmp/sentinel-35.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-35". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-35.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 35"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-35.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 35" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 36: Sentinel step 36 + +- **Files:** `tmp/sentinel-36.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-36". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-36.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 36"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-36.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 36" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 37: Sentinel step 37 + +- **Files:** `tmp/sentinel-37.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-37". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-37.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 37"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-37.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 37" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 38: Sentinel step 38 + +- **Files:** `tmp/sentinel-38.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-38". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-38.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 38"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-38.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 38" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 39: Sentinel step 39 + +- **Files:** `tmp/sentinel-39.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-39". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-39.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 39"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-39.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 39" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 40: Sentinel step 40 + +- **Files:** `tmp/sentinel-40.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-40". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-40.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 40"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-40.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 40" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 41: Sentinel step 41 + +- **Files:** `tmp/sentinel-41.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-41". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-41.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 41"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-41.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 41" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 42: Sentinel step 42 + +- **Files:** `tmp/sentinel-42.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-42". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-42.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 42"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-42.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 42" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 43: Sentinel step 43 + +- **Files:** `tmp/sentinel-43.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-43". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-43.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 43"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-43.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 43" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 44: Sentinel step 44 + +- **Files:** `tmp/sentinel-44.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-44". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-44.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 44"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-44.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 44" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 45: Sentinel step 45 + +- **Files:** `tmp/sentinel-45.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-45". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-45.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 45"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-45.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 45" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 46: Sentinel step 46 + +- **Files:** `tmp/sentinel-46.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-46". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-46.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 46"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-46.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 46" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 47: Sentinel step 47 + +- **Files:** `tmp/sentinel-47.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-47". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-47.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 47"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-47.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 47" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 48: Sentinel step 48 + +- **Files:** `tmp/sentinel-48.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-48". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-48.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 48"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-48.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 48" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 49: Sentinel step 49 + +- **Files:** `tmp/sentinel-49.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-49". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-49.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 49"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-49.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 49" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 50: Sentinel step 50 + +- **Files:** `tmp/sentinel-50.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-50". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-50.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 50"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-50.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 50" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +### Step 51: Sentinel step 51 + +- **Files:** `tmp/sentinel-51.txt` (new) +- **Changes:** Create sentinel file with the literal content "step-51". +- **Reuses:** none. +- **Test first:** none. +- **Verify:** `test -f tmp/sentinel-51.txt` +- **On failure:** revert. +- **Checkpoint:** `git commit -m "chore: sentinel step 51"` +- **Manifest:** + ```yaml + manifest: + expected_paths: + - tmp/sentinel-51.txt + min_file_count: 1 + commit_message_pattern: "^chore: sentinel step 51" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + ``` + +## Verification + +- All 51 sentinel files exist. +- npm test passes. diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-plan.md b/plugins/voyage/tests/fixtures/annotation/annotation-plan.md new file mode 100644 index 0000000..63f0341 --- /dev/null +++ b/plugins/voyage/tests/fixtures/annotation/annotation-plan.md @@ -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. diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-review.md b/plugins/voyage/tests/fixtures/annotation/annotation-review.md new file mode 100644 index 0000000..c680ea7 --- /dev/null +++ b/plugins/voyage/tests/fixtures/annotation/annotation-review.md @@ -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. diff --git a/plugins/voyage/tests/integration/annotation-roundtrip.test.mjs b/plugins/voyage/tests/integration/annotation-roundtrip.test.mjs new file mode 100644 index 0000000..d6f820e --- /dev/null +++ b/plugins/voyage/tests/integration/annotation-roundtrip.test.mjs @@ -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, []); +}); diff --git a/plugins/voyage/tests/integration/schema-rollback.test.mjs b/plugins/voyage/tests/integration/schema-rollback.test.mjs new file mode 100644 index 0000000..eb9b514 --- /dev/null +++ b/plugins/voyage/tests/integration/schema-rollback.test.mjs @@ -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 }); + } + } +}); diff --git a/plugins/voyage/tests/lib/source-annotations.test.mjs b/plugins/voyage/tests/lib/source-annotations.test.mjs new file mode 100644 index 0000000..c2aeb88 --- /dev/null +++ b/plugins/voyage/tests/lib/source-annotations.test.mjs @@ -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 }); + } +});