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).
68 lines
3.1 KiB
JavaScript
68 lines
3.1 KiB
JavaScript
// 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',
|
|
);
|
|
});
|