* refactor(#1190): extract ADR-22 drift-guard decision logic into a testable seam ADR-22's severity mapping, authority auto-upgrade, and rung>=3 hard-block lived only as prose in plan-review-convergence.md — untestable. Extracted into src/plan-drift-guard.cts (pure: AUTHORITY_RUNGS, getEffectiveAuthority, classifyDriftSeverity) + a gsd-tools drift-guard CLI seam (authority/severity), and rewired the workflow to call the seam deterministically instead of reasoning the decision in prose. 47 unit/e2e/structural tests cover the full severity table, the grep->intel auto-upgrade, and the rung>=3 HIGH hard-block. Closes #1190 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#1190): add changeset for ADR-22 drift-guard seam (#1242) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1190): register ADR-22 module in eslint-ignore + inventory manifest + docs-exempt changeset Full-matrix CI surfaced new-module/command governance ripples beyond the lint-tests chain: (1) tsc-generated plan-drift-guard.cjs must be in the eslint ignore list (551-eslint-bin-lib-coverage); (2) docs/INVENTORY-MANIFEST.json must include the new module/command (regen via gen-inventory-manifest.cjs --write); (3) a type:Added changeset triggers docs-required — added a docs-exempt marker (internal seam, no user-facing surface). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
// allow-test-rule: source-text-is-the-product #1190
|
|
|
|
/**
|
|
* ADR-22 Drift-Guard Tests — issue #1190
|
|
*
|
|
* Covers:
|
|
* 1. Pure unit tests for `classifyDriftSeverity` (every ADR-22 table cell).
|
|
* 2. Pure unit tests for `getEffectiveAuthority` (auto-upgrade + pass-through).
|
|
* 3. e2e CLI tests via `gsd-tools drift-guard severity/authority`.
|
|
* 4. Structural test that plan-review-convergence.md invokes `gsd_run drift-guard`.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { cleanup, runGsdTools } = require('./helpers.cjs');
|
|
|
|
// ── Pure-module imports ──────────────────────────────────────────────────────
|
|
|
|
const {
|
|
AUTHORITY_RUNGS,
|
|
getEffectiveAuthority,
|
|
classifyDriftSeverity,
|
|
} = require('../gsd-core/bin/lib/plan-drift-guard.cjs');
|
|
|
|
// ── 1. AUTHORITY_RUNGS sanity ────────────────────────────────────────────────
|
|
|
|
describe('AUTHORITY_RUNGS', () => {
|
|
test('has all five adapters with correct rung order', () => {
|
|
assert.equal(AUTHORITY_RUNGS.grep, 0);
|
|
assert.equal(AUTHORITY_RUNGS.intel, 1);
|
|
assert.equal(AUTHORITY_RUNGS.treesitter, 2);
|
|
assert.equal(AUTHORITY_RUNGS.lsp, 3);
|
|
assert.equal(AUTHORITY_RUNGS.scip, 4);
|
|
});
|
|
|
|
test('is frozen (no mutation)', () => {
|
|
assert.ok(Object.isFrozen(AUTHORITY_RUNGS));
|
|
});
|
|
});
|
|
|
|
// ── 2. getEffectiveAuthority unit tests ──────────────────────────────────────
|
|
|
|
describe('getEffectiveAuthority', () => {
|
|
test('grep + intel enabled → intel', () => {
|
|
assert.equal(getEffectiveAuthority('grep', true), 'intel');
|
|
});
|
|
|
|
test('grep + intel disabled → grep', () => {
|
|
assert.equal(getEffectiveAuthority('grep', false), 'grep');
|
|
});
|
|
|
|
test('undefined + intel enabled → intel (grep is the default)', () => {
|
|
assert.equal(getEffectiveAuthority(undefined, true), 'intel');
|
|
});
|
|
|
|
test('null + intel disabled → grep', () => {
|
|
assert.equal(getEffectiveAuthority(null, false), 'grep');
|
|
});
|
|
|
|
test('empty string + intel enabled → intel', () => {
|
|
assert.equal(getEffectiveAuthority('', true), 'intel');
|
|
});
|
|
|
|
test('intel + intel enabled → intel (no double upgrade)', () => {
|
|
// intel is already intel; auto-upgrade rule only applies to grep
|
|
assert.equal(getEffectiveAuthority('intel', true), 'intel');
|
|
});
|
|
|
|
test('intel + intel disabled → intel (pass-through)', () => {
|
|
assert.equal(getEffectiveAuthority('intel', false), 'intel');
|
|
});
|
|
|
|
test('treesitter + intel enabled → treesitter (auto-upgrade only for grep)', () => {
|
|
assert.equal(getEffectiveAuthority('treesitter', true), 'treesitter');
|
|
});
|
|
|
|
test('lsp + intel enabled → lsp (auto-upgrade only for grep)', () => {
|
|
assert.equal(getEffectiveAuthority('lsp', true), 'lsp');
|
|
});
|
|
|
|
test('scip + intel disabled → scip', () => {
|
|
assert.equal(getEffectiveAuthority('scip', false), 'scip');
|
|
});
|
|
|
|
test('unknown authority → TypeError', () => {
|
|
assert.throws(
|
|
() => getEffectiveAuthority('grok', false),
|
|
(err) => err instanceof TypeError && /Unknown authority/i.test(err.message),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 3. classifyDriftSeverity unit tests (every ADR-22 table cell) ──────────
|
|
|
|
describe('classifyDriftSeverity — VERIFIED', () => {
|
|
for (const authority of ['grep', 'intel', 'treesitter', 'lsp', 'scip']) {
|
|
test(`VERIFIED @ ${authority} → severity none, no hardBlock`, () => {
|
|
const result = classifyDriftSeverity({ status: 'VERIFIED', authority });
|
|
assert.equal(result.severity, 'none');
|
|
assert.equal(result.hardBlock, false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('classifyDriftSeverity — MISSING', () => {
|
|
test('MISSING @ grep → needs-acknowledgement, no hardBlock', () => {
|
|
const result = classifyDriftSeverity({ status: 'MISSING', authority: 'grep' });
|
|
assert.equal(result.severity, 'needs-acknowledgement');
|
|
assert.equal(result.hardBlock, false);
|
|
});
|
|
|
|
test('MISSING @ intel → needs-acknowledgement, no hardBlock', () => {
|
|
const result = classifyDriftSeverity({ status: 'MISSING', authority: 'intel' });
|
|
assert.equal(result.severity, 'needs-acknowledgement');
|
|
assert.equal(result.hardBlock, false);
|
|
});
|
|
|
|
test('MISSING @ treesitter → needs-acknowledgement, no hardBlock', () => {
|
|
const result = classifyDriftSeverity({ status: 'MISSING', authority: 'treesitter' });
|
|
assert.equal(result.severity, 'needs-acknowledgement');
|
|
assert.equal(result.hardBlock, false);
|
|
});
|
|
|
|
test('MISSING @ lsp → HIGH, hardBlock TRUE', () => {
|
|
const result = classifyDriftSeverity({ status: 'MISSING', authority: 'lsp' });
|
|
assert.equal(result.severity, 'HIGH');
|
|
assert.equal(result.hardBlock, true);
|
|
});
|
|
|
|
test('MISSING @ scip → HIGH, hardBlock TRUE', () => {
|
|
const result = classifyDriftSeverity({ status: 'MISSING', authority: 'scip' });
|
|
assert.equal(result.severity, 'HIGH');
|
|
assert.equal(result.hardBlock, true);
|
|
});
|
|
});
|
|
|
|
describe('classifyDriftSeverity — AMBIGUOUS', () => {
|
|
for (const authority of ['grep', 'intel', 'treesitter', 'lsp', 'scip']) {
|
|
test(`AMBIGUOUS @ ${authority} → MEDIUM, no hardBlock`, () => {
|
|
const result = classifyDriftSeverity({ status: 'AMBIGUOUS', authority });
|
|
assert.equal(result.severity, 'MEDIUM');
|
|
assert.equal(result.hardBlock, false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('classifyDriftSeverity — UNCHECKABLE', () => {
|
|
for (const authority of ['grep', 'intel', 'treesitter', 'lsp', 'scip']) {
|
|
test(`UNCHECKABLE @ ${authority} → INFO, no hardBlock`, () => {
|
|
const result = classifyDriftSeverity({ status: 'UNCHECKABLE', authority });
|
|
assert.equal(result.severity, 'INFO');
|
|
assert.equal(result.hardBlock, false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('classifyDriftSeverity — validation', () => {
|
|
test('unknown status → TypeError', () => {
|
|
assert.throws(
|
|
() => classifyDriftSeverity({ status: 'WRONG', authority: 'grep' }),
|
|
(err) => err instanceof TypeError && /Unknown status/i.test(err.message),
|
|
);
|
|
});
|
|
|
|
test('unknown authority → TypeError', () => {
|
|
assert.throws(
|
|
() => classifyDriftSeverity({ status: 'MISSING', authority: 'magic' }),
|
|
(err) => err instanceof TypeError && /Unknown authority/i.test(err.message),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 4. e2e CLI tests ─────────────────────────────────────────────────────────
|
|
|
|
describe('gsd-tools drift-guard — CLI e2e', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-drift-guard-'));
|
|
const planningDir = path.join(tmpDir, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// Helper: write config.json into the fixture
|
|
function writeConfig(cfg) {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify(cfg),
|
|
);
|
|
}
|
|
|
|
test('severity --status MISSING --authority lsp → {severity:HIGH, hardBlock:true}', () => {
|
|
writeConfig({ plan_review: { source_grounding_authority: 'lsp' } });
|
|
const res = runGsdTools(
|
|
['drift-guard', 'severity', '--status', 'MISSING', '--authority', 'lsp', '--raw'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(res.success, `Expected success, got: ${res.error}`);
|
|
const result = JSON.parse(res.output);
|
|
assert.equal(result.severity, 'HIGH');
|
|
assert.equal(result.hardBlock, true);
|
|
});
|
|
|
|
test('severity --status MISSING --authority grep → {severity:needs-acknowledgement, hardBlock:false}', () => {
|
|
writeConfig({});
|
|
const res = runGsdTools(
|
|
['drift-guard', 'severity', '--status', 'MISSING', '--authority', 'grep', '--raw'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(res.success, `Expected success, got: ${res.error}`);
|
|
const result = JSON.parse(res.output);
|
|
assert.equal(result.severity, 'needs-acknowledgement');
|
|
assert.equal(result.hardBlock, false);
|
|
});
|
|
|
|
test('severity --status VERIFIED --authority scip → {severity:none, hardBlock:false}', () => {
|
|
writeConfig({});
|
|
const res = runGsdTools(
|
|
['drift-guard', 'severity', '--status', 'VERIFIED', '--authority', 'scip', '--raw'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(res.success, `Expected success, got: ${res.error}`);
|
|
const result = JSON.parse(res.output);
|
|
assert.equal(result.severity, 'none');
|
|
assert.equal(result.hardBlock, false);
|
|
});
|
|
|
|
test('authority with source_grounding_authority=grep + intel.enabled=true → intel', () => {
|
|
writeConfig({
|
|
plan_review: { source_grounding_authority: 'grep' },
|
|
intel: { enabled: true },
|
|
});
|
|
const res = runGsdTools(
|
|
['drift-guard', 'authority', '--raw'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(res.success, `Expected success, got: ${res.error}`);
|
|
assert.equal(res.output, 'intel');
|
|
});
|
|
|
|
test('authority with source_grounding_authority=lsp + intel.enabled=true → lsp (no upgrade)', () => {
|
|
writeConfig({
|
|
plan_review: { source_grounding_authority: 'lsp' },
|
|
intel: { enabled: true },
|
|
});
|
|
const res = runGsdTools(
|
|
['drift-guard', 'authority', '--raw'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(res.success, `Expected success, got: ${res.error}`);
|
|
assert.equal(res.output, 'lsp');
|
|
});
|
|
|
|
test('authority with no config → grep (default)', () => {
|
|
writeConfig({});
|
|
const res = runGsdTools(
|
|
['drift-guard', 'authority', '--raw'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(res.success, `Expected success, got: ${res.error}`);
|
|
assert.equal(res.output, 'grep');
|
|
});
|
|
|
|
test('severity without --status flag → exits non-zero', () => {
|
|
writeConfig({});
|
|
const res = runGsdTools(['drift-guard', 'severity', '--raw'], tmpDir);
|
|
assert.equal(res.success, false, 'Expected non-zero exit for missing --status');
|
|
assert.ok(res.exitCode !== 0, `exitCode should be non-zero, got ${res.exitCode}`);
|
|
});
|
|
|
|
test('unknown subcommand → exits non-zero', () => {
|
|
writeConfig({});
|
|
const res = runGsdTools(['drift-guard', 'badcmd', '--raw'], tmpDir);
|
|
assert.equal(res.success, false, 'Expected non-zero exit for unknown subcommand');
|
|
assert.ok(res.exitCode !== 0, `exitCode should be non-zero, got ${res.exitCode}`);
|
|
});
|
|
});
|
|
|
|
// ── 5. Structural test: plan-review-convergence.md invokes gsd_run drift-guard
|
|
|
|
describe('plan-review-convergence.md uses gsd_run drift-guard seam', () => {
|
|
const WORKFLOW_PATH = path.join(
|
|
__dirname, '..', 'gsd-core', 'workflows', 'plan-review-convergence.md',
|
|
);
|
|
|
|
test('workflow contains gsd_run drift-guard authority call', () => {
|
|
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
|
assert.ok(
|
|
content.includes('gsd_run drift-guard authority'),
|
|
'plan-review-convergence.md must contain: gsd_run drift-guard authority',
|
|
);
|
|
});
|
|
|
|
test('workflow drift-guard authority call includes --raw (prevents JSON-quoted capture)', () => {
|
|
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
|
assert.match(
|
|
content,
|
|
/gsd_run drift-guard authority --raw/,
|
|
'plan-review-convergence.md authority capture must use --raw; without it the value is JSON-quoted ("intel") and --authority rejects it as unknown',
|
|
);
|
|
});
|
|
|
|
test('workflow contains gsd_run drift-guard severity call', () => {
|
|
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
|
assert.ok(
|
|
content.includes('gsd_run drift-guard severity'),
|
|
'plan-review-convergence.md must contain: gsd_run drift-guard severity',
|
|
);
|
|
});
|
|
|
|
test('workflow drift-guard severity call passes --authority flag', () => {
|
|
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
|
assert.match(
|
|
content,
|
|
/gsd_run drift-guard severity[^\n]*--authority/,
|
|
'plan-review-convergence.md severity invocation must pass --authority so the resolved authority is forwarded to classifyDriftSeverity',
|
|
);
|
|
});
|
|
});
|