From e98eba88c923d74cbfd02efbf65b6a9e11a53c31 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 9 May 2026 10:02:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(voyage):=20emit=20MANIFEST=5FPROFILE=5FDRI?= =?UTF-8?q?FT=20warning=20in=20plan-validator=20strict=20mode=20=E2=80=94?= =?UTF-8?q?=20brief=20assumption=207?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 20 of v4.1 — implements drift detection in plan-validator.mjs per brief Assumptions block 7: "Mismatch (e.g. korrupt manuell endring) emitterer MANIFEST_PROFILE_DRIFT-warning fra plan-validator i --strict-modus." Logic (after validateAllManifests in validatePlanContent): 1. Strict-mode only — soft mode never emits drift warnings. 2. Plan frontmatter must declare 'profile: ' to establish baseline. 3. For each step manifest, if profile_used is set AND differs from plan profile, emit warning (NOT error) with code MANIFEST_PROFILE_DRIFT and location 'step N: profile_used = X, plan profile = Y'. Forward-compat preserved: drift is a warning, plan remains valid:true. Operators see the drift in --strict mode without parsing breaking. New files: tests/validators/plan-validator-profile-drift.test.mjs — 4 tests tests/fixtures/plan-profile-drift.md — drift fixture Tests verify: 1. drift detected in strict mode → MANIFEST_PROFILE_DRIFT in warnings 2. drift NOT detected in soft mode → strict gate honored 3. matching profile → no drift warning 4. no plan-level profile → drift detection silent (no baseline) Tests: 479 pass + 2 skipped (Docker not installed). --- .../voyage/lib/validators/plan-validator.mjs | 24 +++++++ .../tests/fixtures/plan-profile-drift.md | 57 ++++++++++++++++ .../plan-validator-profile-drift.test.mjs | 68 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 plugins/voyage/tests/fixtures/plan-profile-drift.md create mode 100644 plugins/voyage/tests/validators/plan-validator-profile-drift.test.mjs diff --git a/plugins/voyage/lib/validators/plan-validator.mjs b/plugins/voyage/lib/validators/plan-validator.mjs index 819ee43..0e501ef 100644 --- a/plugins/voyage/lib/validators/plan-validator.mjs +++ b/plugins/voyage/lib/validators/plan-validator.mjs @@ -5,6 +5,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { sliceSteps, validatePlanHeadings, extractPlanVersion } from '../parsers/plan-schema.mjs'; import { validateAllManifests } from '../parsers/manifest-yaml.mjs'; +import { parseDocument } from '../util/frontmatter.mjs'; import { issue, fail } from '../util/result.mjs'; export function validatePlanContent(text, opts = {}) { @@ -33,6 +34,29 @@ export function validatePlanContent(text, opts = {}) { warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is 1.7`)); } + // v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only. + // If plan frontmatter declares `profile: ` and any step manifest + // declares `profile_used: `, emit a warning (NOT an error) so + // operators see drift but parsing remains forward-compat. + if (strict) { + const planFm = parseDocument(text).parsed?.frontmatter; + const planProfile = + planFm && typeof planFm.profile === 'string' ? planFm.profile : null; + if (planProfile) { + for (const m of manRes.parsed) { + const stepProfile = m.manifest && m.manifest.profile_used; + if (typeof stepProfile === 'string' && stepProfile !== planProfile) { + warnings.push(issue( + 'MANIFEST_PROFILE_DRIFT', + `step ${m.n}: profile_used = ${stepProfile}, plan profile = ${planProfile}`, + 'A step manifest declares a different profile than the plan frontmatter; ' + + 'verify whether this is intentional (manual override) or accidental drift.', + )); + } + } + } + } + return { valid: errors.length === 0, errors, diff --git a/plugins/voyage/tests/fixtures/plan-profile-drift.md b/plugins/voyage/tests/fixtures/plan-profile-drift.md new file mode 100644 index 0000000..c8068a1 --- /dev/null +++ b/plugins/voyage/tests/fixtures/plan-profile-drift.md @@ -0,0 +1,57 @@ +--- +plan_version: "1.7" +profile: economy +phase_models: + - phase: brief + model: sonnet + - phase: research + model: sonnet + - phase: plan + model: sonnet + - phase: execute + model: sonnet + - phase: review + model: sonnet + - phase: continue + model: sonnet +--- + +# Test plan — profile drift fixture + +Frontmatter declares `profile: economy`. Step 1 manifest has matching +profile_used. Step 2 manifest declares `profile_used: premium` — the +drift case Step 20 of v4.1 plan-validator must catch in --strict mode. + +## Implementation Plan + +### Step 1: matching profile + +- Files: a.ts +- Manifest: + ```yaml + manifest: + expected_paths: + - a.ts + min_file_count: 1 + commit_message_pattern: "^feat:" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + profile_used: economy + ``` + +### Step 2: drift to premium + +- Files: b.ts +- Manifest: + ```yaml + manifest: + expected_paths: + - b.ts + min_file_count: 1 + commit_message_pattern: "^feat:" + bash_syntax_check: [] + forbidden_paths: [] + must_contain: [] + profile_used: premium + ``` diff --git a/plugins/voyage/tests/validators/plan-validator-profile-drift.test.mjs b/plugins/voyage/tests/validators/plan-validator-profile-drift.test.mjs new file mode 100644 index 0000000..58a5d21 --- /dev/null +++ b/plugins/voyage/tests/validators/plan-validator-profile-drift.test.mjs @@ -0,0 +1,68 @@ +// tests/validators/plan-validator-profile-drift.test.mjs +// SC #20 — MANIFEST_PROFILE_DRIFT warning per brief Assumptions block 7. +// In strict mode, plan-validator must emit a warning (NOT an error) when a +// step manifest's profile_used differs from the plan's frontmatter profile. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { validatePlanContent } from '../../lib/validators/plan-validator.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..', '..'); + +function loadFixture(name) { + return readFileSync(resolve(ROOT, 'tests/fixtures', name), 'utf-8'); +} + +const DRIFT_FIXTURE = loadFixture('plan-profile-drift.md'); + +test('drift detected in strict mode — emits MANIFEST_PROFILE_DRIFT warning, not error', () => { + const r = validatePlanContent(DRIFT_FIXTURE, { strict: true }); + assert.equal(r.valid, true, `plan must remain valid; errors: ${JSON.stringify(r.errors)}`); + const drift = r.warnings.filter((w) => w.code === 'MANIFEST_PROFILE_DRIFT'); + assert.equal(drift.length, 1, `expected 1 drift warning, got ${drift.length}: ${JSON.stringify(drift)}`); + assert.match(drift[0].message, /step 2/, `warning message must reference step 2: ${drift[0].message}`); + assert.match(drift[0].message, /premium/, 'warning must include offending profile_used value'); + assert.match(drift[0].message, /economy/, 'warning must include plan-level profile value'); +}); + +test('drift NOT detected in soft mode — strict gate honored', () => { + const r = validatePlanContent(DRIFT_FIXTURE, { strict: false }); + const drift = r.warnings.filter((w) => w.code === 'MANIFEST_PROFILE_DRIFT'); + assert.equal( + drift.length, + 0, + 'MANIFEST_PROFILE_DRIFT must only emit in strict mode (per brief assumption 7)', + ); +}); + +test('matching profile — no drift warning emitted', () => { + // Same fixture body, but rewrite step 2 profile_used to match plan profile. + // Use /g to catch both the doc-comment mention and the actual manifest entry. + const matching = DRIFT_FIXTURE.replace(/profile_used: premium/g, 'profile_used: economy'); + const r = validatePlanContent(matching, { strict: true }); + assert.equal(r.valid, true); + const drift = r.warnings.filter((w) => w.code === 'MANIFEST_PROFILE_DRIFT'); + assert.equal( + drift.length, + 0, + `no drift expected when all step profile_used match plan profile; got ${JSON.stringify(drift)}`, + ); +}); + +test('plan without frontmatter profile — no drift warnings emitted', () => { + // If plan-level profile is absent, step-level profile_used can be anything + // without triggering drift (drift is only meaningful relative to a baseline). + const noProfile = DRIFT_FIXTURE.replace(/profile: economy\n/, ''); + const r = validatePlanContent(noProfile, { strict: true }); + const drift = r.warnings.filter((w) => w.code === 'MANIFEST_PROFILE_DRIFT'); + assert.equal( + drift.length, + 0, + 'no plan-level profile means no baseline; drift detection must be silent', + ); +});