refactor(#1190): extract ADR-22 drift-guard decision logic into a testable seam (#1242)

* 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>
This commit is contained in:
Tom Boucher
2026-06-14 20:52:36 -04:00
committed by GitHub
parent 6ef78fe417
commit 55eb1fd72e
9 changed files with 573 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
type: Added
pr: 1242
---
**`gsd-tools drift-guard` — deterministic plan-drift severity/authority decisions (ADR-22).** The plan-review source-grounding pass now classifies cited-symbol drift through a tested seam (5-rung authority ladder, `grep`→`intel` auto-upgrade, severity mapping, rung≥3 hard-block) instead of re-deriving the rules from workflow prose on each run. (#1190)
<!-- docs-exempt: internal gsd-tools seam invoked by the plan-review-convergence workflow; no user-facing command/flag surface (deferred rungs documented in ADR-22) -->

1
.gitignore vendored
View File

@@ -71,6 +71,7 @@ build/
/gsd-core/bin/lib/research-provider.cjs
/gsd-core/bin/lib/package-legitimacy.cjs
/gsd-core/bin/lib/semver-compare.cjs
/gsd-core/bin/lib/plan-drift-guard.cjs
/gsd-core/bin/lib/edge-probe.cjs
/gsd-core/bin/lib/probe-core.cjs
/gsd-core/bin/lib/config-types.cjs

View File

@@ -336,6 +336,7 @@
"phase-locator.cjs",
"phase.cjs",
"phases-command-router.cjs",
"plan-drift-guard.cjs",
"plan-scan.cjs",
"planning-workspace.cjs",
"probe-core.cjs",

View File

@@ -37,6 +37,7 @@ export default tseslint.config(
'**/*.generated.cjs',
// ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs.
'gsd-core/bin/lib/semver-compare.cjs',
'gsd-core/bin/lib/plan-drift-guard.cjs',
'gsd-core/bin/lib/cli-exit.cjs',
'gsd-core/bin/lib/edge-probe.cjs',
'gsd-core/bin/lib/probe-core.cjs',

View File

@@ -63,6 +63,13 @@
* Returns JSON { valid, errors[], slots: {role,capability,outcome} | null }
* --pick valid Emit bare boolean (for workflow boolean checks)
*
* Drift Guard (ADR-22):
* drift-guard authority Resolve effective source-grounding authority
* (reads plan_review.source_grounding_authority + intel.enabled from config)
* drift-guard severity --status <S> Classify a symbol verdict into { severity, hardBlock }
* [--authority <A>] Status: VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE
* Authority: grep|intel|treesitter|lsp|scip (default: config-resolved)
*
* Validation:
* validate consistency Check phase numbering, disk/roadmap sync
* validate health [--repair] Check .planning/ integrity, optionally repair
@@ -227,6 +234,7 @@ const { routeCheckCommand } = require('./lib/check-command-router.cjs');
const { routeTaskCommand } = require('./lib/task-command-router.cjs');
const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs');
const { getEffectiveAuthority, classifyDriftSeverity } = require('./lib/plan-drift-guard.cjs');
// ─── Bridge collapsed (Phase 4) ────────────────────────────────────────────────
// Non-family commands now run through their CJS handlers directly. Keep the
@@ -510,7 +518,7 @@ async function main() {
const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, ' +
'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
'current-timestamp, detect-custom-files, docs-init, effort, extract-messages, find-phase, ' +
'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
'capability, classify-confidence, git, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
@@ -2097,6 +2105,67 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
break;
}
case 'drift-guard': {
// ADR-22: deterministic authority resolution + severity classification.
// Subcommands:
// drift-guard authority → effective authority string
// drift-guard severity --status <S> [--authority <A>] → {severity, hardBlock}
const subcommand = args[1];
// Read config.json directly for both plan_review.source_grounding_authority
// and intel.enabled. Neither key is in the config-loader.cjs whitelist that
// core.loadConfig() returns; plan_review is only in config.cjs's private
// buildConfig(), and intel is a federated capability config key.
let configuredAuthority = 'grep';
let intelEnabled = false;
try {
const { planningDir } = require('./lib/planning-workspace.cjs');
const cfgPath = require('path').join(planningDir(cwd), 'config.json');
if (require('fs').existsSync(cfgPath)) {
const rawCfg = JSON.parse(require('fs').readFileSync(cfgPath, 'utf-8'));
if (rawCfg && rawCfg.plan_review && rawCfg.plan_review.source_grounding_authority) {
configuredAuthority = String(rawCfg.plan_review.source_grounding_authority);
}
if (rawCfg && rawCfg.intel && rawCfg.intel.enabled === true) {
intelEnabled = true;
}
}
} catch {
// not fatal — defaults apply
}
const effectiveAuthority = getEffectiveAuthority(configuredAuthority, intelEnabled);
if (subcommand === 'authority') {
// Pass rawValue as 3rd arg so --raw returns unquoted string (not JSON)
core.output(effectiveAuthority, raw, effectiveAuthority);
break;
}
if (subcommand === 'severity') {
const statusIdx = args.indexOf('--status');
const statusVal = statusIdx !== -1 ? args[statusIdx + 1] : undefined;
if (!statusVal || statusVal.startsWith('--')) {
error('drift-guard severity requires --status <VERIFIED|MISSING|AMBIGUOUS|UNCHECKABLE>', ERROR_REASON.SDK_UNKNOWN_COMMAND);
break;
}
const authIdx = args.indexOf('--authority');
const authVal = authIdx !== -1 ? args[authIdx + 1] : undefined;
const authorityForClassify = (authVal && !authVal.startsWith('--'))
? authVal
: effectiveAuthority;
const result = classifyDriftSeverity({ status: statusVal, authority: authorityForClassify });
core.output(result, raw);
break;
}
error(
`Unknown drift-guard subcommand: ${subcommand || '(none)'}. Available: authority, severity`,
ERROR_REASON.SDK_UNKNOWN_COMMAND,
);
break;
}
default: {
// ADR-959: try capability-registry dispatch before emitting the unknown-command error.
// An unmigrated command still hits its hardcoded `case` above — untouched.

View File

@@ -185,13 +185,23 @@ Run this pass unless `plan_review.source_grounding` is `false`. It verifies ever
1. **Enumerate cited symbols.** List every referenced symbol by kind, quoting the plan line for each (coverage must be auditable): decorators (`@name`), classes/methods (`Class.method`), functions (`module.function`), CLI flags (`--name`), file paths, dataclass/struct fields.
2. **Exclude new artifacts.** Do NOT verify symbols the plan declares under its "Artifacts this phase produces" section — those are created by this phase, not references to existing code.
3. **Resolve each remaining symbol** using the adapter named by `plan_review.source_grounding_authority` (default `grep`):
3. **Resolve each remaining symbol** using the effective authority adapter (resolved deterministically — see step 4a):
- `grep` — ripgrep / Read the source; confirm the name appears as a real declaration.
- `intel` — consult `.planning/intel/API-SURFACE.md` / `api-map.json` (only when `intel.enabled`).
Record one verdict per symbol: **VERIFIED** (quote `file:line`), **MISSING** (adapter can check this language/kind and the symbol is absent), **AMBIGUOUS** (multiple candidates), or **UNCHECKABLE** (adapter cannot analyze this language/kind — e.g. non-JS under `intel`, or any signature under `grep`). Never treat UNCHECKABLE as verified or missing.
4. **Severity & gating:**
- **MISSING** at authority `grep`/`intel` → `needs-acknowledgement`: the plan proceeds only if the author confirms the symbol is genuinely new or dynamically resolved, and that acknowledgement is recorded. A hard block is reserved for higher-authority adapters (LSP/SCIP) that can prove absence.
- **AMBIGUOUS** → MEDIUM. **UNCHECKABLE** → INFO.
4a. **Resolve effective authority** (deterministic — replaces manual `intel.enabled` reasoning):
```bash
EFFECTIVE_AUTHORITY=$(gsd_run drift-guard authority --raw)
```
4. **Severity & gating** — classify each symbol's verdict using the seam (do not apply the table manually):
```bash
# For each symbol, e.g.:
RESULT=$(gsd_run drift-guard severity --status <verdict> --authority "$EFFECTIVE_AUTHORITY")
# $RESULT is JSON: {"severity":"…","hardBlock":true|false}
```
- `hardBlock: true` (HIGH at authority `lsp`/`scip`) — stops the review cycle immediately; do not proceed until the plan author resolves the missing symbol.
- `hardBlock: false`, severity `needs-acknowledgement` — plan proceeds only if the author confirms the symbol is genuinely new or dynamically resolved, and that acknowledgement is recorded.
- `AMBIGUOUS` → MEDIUM. `UNCHECKABLE` → INFO.
- Signature mismatches cannot be asserted under `grep`/`intel`; report the signature as UNCHECKABLE.
5. **Coverage block.** Append a "Verification coverage" section to `REVIEWS.md` listing every UNCHECKABLE/skipped symbol and why — a clean review must never silently mean "nothing was checked."

150
src/plan-drift-guard.cts Normal file
View File

@@ -0,0 +1,150 @@
/**
* ADR-22 Drift-Guard Decision Module
*
* Implements the authority ladder and severity classification table from
* ADR-22 (docs/adr/0022-source-grounding-drift-guard.md).
*
* Design constraints:
* - Pure module: no I/O, no require() calls, no side effects.
* - All inputs are validated; unknown values throw a TypeError.
* - Consumed by the `gsd-tools drift-guard` CLI seam and by tests.
*
* Authority ladder (rung values determine MISSING severity):
* grep=0 intel=1 treesitter=2 lsp=3 scip=4
*
* Hard-block threshold: rung >= 3 (lsp, scip) — these adapters can prove
* absence, so MISSING is a definite error (severity HIGH, hardBlock true).
*/
/** The five authority adapter names defined by ADR-22. */
export type Authority = 'grep' | 'intel' | 'treesitter' | 'lsp' | 'scip';
/** Symbol verification verdict emitted by the source-grounding pass. */
export type VerificationStatus = 'VERIFIED' | 'MISSING' | 'AMBIGUOUS' | 'UNCHECKABLE';
/** Severity classification outcome. */
export type Severity = 'none' | 'needs-acknowledgement' | 'MEDIUM' | 'HIGH' | 'INFO';
/** Result of classifyDriftSeverity. */
export interface DriftSeverityResult {
severity: Severity;
hardBlock: boolean;
}
/**
* Frozen map from authority name to its rung number.
*
* Rung determines whether a MISSING symbol triggers a hard block:
* rung >= 3 (lsp, scip) → hard block; rung < 3 → acknowledgement only.
*/
export const AUTHORITY_RUNGS: Readonly<Record<Authority, number>> = Object.freeze({
grep: 0,
intel: 1,
treesitter: 2,
lsp: 3,
scip: 4,
} as const);
/** Rung at which MISSING transitions to hard-block (inclusive). */
const HARD_BLOCK_RUNG_THRESHOLD = 3;
const VALID_AUTHORITIES = new Set<string>(Object.keys(AUTHORITY_RUNGS));
const VALID_STATUSES = new Set<string>(['VERIFIED', 'MISSING', 'AMBIGUOUS', 'UNCHECKABLE']);
/**
* Validate and return an authority value, normalising undefined to 'grep'.
*
* Throws TypeError for any non-null unknown string value so callers surface
* configuration errors at call time rather than silently defaulting.
*
* @param value - raw authority string from config or CLI arg
* @returns a validated Authority value
*/
function validateAuthority(value: string | undefined | null): Authority {
if (value === undefined || value === null || value === '') {
return 'grep';
}
if (!VALID_AUTHORITIES.has(value)) {
throw new TypeError(
`Unknown authority: ${JSON.stringify(value)}. ` +
`Valid values: ${[...VALID_AUTHORITIES].join(', ')}`
);
}
return value as Authority;
}
/**
* Return the effective authority after applying the ADR-22 auto-upgrade rule.
*
* Auto-upgrade rule: if the configured authority is 'grep' AND intel is
* enabled (`intelEnabled === true`), upgrade to 'intel'. All other authority
* values are returned unchanged regardless of intelEnabled.
*
* @param authority - configured authority (undefined → 'grep')
* @param intelEnabled - whether the intel capability is active in this project
* @returns the effective Authority after upgrade
* @throws TypeError if authority is not one of the five valid values
*/
export function getEffectiveAuthority(
authority: string | undefined | null,
intelEnabled: boolean,
): Authority {
const validated = validateAuthority(authority);
if (validated === 'grep' && intelEnabled === true) {
return 'intel';
}
return validated;
}
/**
* Classify a symbol verification result into a drift severity and hard-block flag.
*
* ADR-22 decision table:
*
* | Status | Authority rung | severity | hardBlock |
* |------------- |--------------- |----------------------- |---------- |
* | VERIFIED | any | 'none' | false |
* | MISSING | rung >= 3 | 'HIGH' | true |
* | MISSING | rung 0-2 | 'needs-acknowledgement'| false |
* | AMBIGUOUS | any | 'MEDIUM' | false |
* | UNCHECKABLE | any | 'INFO' | false |
*
* @param opts.status - verdict from the source-grounding adapter
* @param opts.authority - the effective authority adapter used
* @returns { severity, hardBlock }
* @throws TypeError for unknown status or authority values
*/
export function classifyDriftSeverity({
status,
authority,
}: {
status: string;
authority: string;
}): DriftSeverityResult {
if (!VALID_STATUSES.has(status)) {
throw new TypeError(
`Unknown status: ${JSON.stringify(status)}. ` +
`Valid values: ${[...VALID_STATUSES].join(', ')}`
);
}
// authority validation (also catches unknown values)
const validatedAuthority = validateAuthority(authority);
const rung = AUTHORITY_RUNGS[validatedAuthority];
switch (status as VerificationStatus) {
case 'VERIFIED':
return { severity: 'none', hardBlock: false };
case 'MISSING':
if (rung >= HARD_BLOCK_RUNG_THRESHOLD) {
return { severity: 'HIGH', hardBlock: true };
}
return { severity: 'needs-acknowledgement', hardBlock: false };
case 'AMBIGUOUS':
return { severity: 'MEDIUM', hardBlock: false };
case 'UNCHECKABLE':
return { severity: 'INFO', hardBlock: false };
}
}

View File

@@ -0,0 +1,328 @@
// 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',
);
});
});

View File

@@ -52,7 +52,7 @@
"pause-work.md": 14397,
"plan-milestone-gaps.md": 11765,
"plan-phase.md": 92120,
"plan-review-convergence.md": 22949,
"plan-review-convergence.md": 23468,
"plant-seed.md": 11741,
"pr-branch.md": 9561,
"profile-user.md": 20650,