* 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:
7
.changeset/1190-adr22-drift-guard-seam.md
Normal file
7
.changeset/1190-adr22-drift-guard-seam.md
Normal 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
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
150
src/plan-drift-guard.cts
Normal 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 };
|
||||
}
|
||||
}
|
||||
328
tests/adr-22-plan-drift-guard.test.cjs
Normal file
328
tests/adr-22-plan-drift-guard.test.cjs
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user