feat(voyage): emit MANIFEST_PROFILE_DRIFT warning in plan-validator strict mode — brief assumption 7

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: <name>' 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).
This commit is contained in:
Kjell Tore Guttormsen 2026-05-09 10:02:53 +02:00
commit e98eba88c9
3 changed files with 149 additions and 0 deletions

View file

@ -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: <name>` and any step manifest
// declares `profile_used: <other>`, 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,

View file

@ -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
```

View file

@ -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',
);
});