diff --git a/.changeset/1190-adr22-drift-guard-seam.md b/.changeset/1190-adr22-drift-guard-seam.md new file mode 100644 index 000000000..f05578ce8 --- /dev/null +++ b/.changeset/1190-adr22-drift-guard-seam.md @@ -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) + + diff --git a/.gitignore b/.gitignore index 5f97772e5..fed1bfbaf 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 512aa7d94..c5248647b 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/eslint.config.mjs b/eslint.config.mjs index 5fe327de2..bda42a3e3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index e2d6178e4..c6a02f6db 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.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 Classify a symbol verdict into { severity, hardBlock } + * [--authority ] 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 [args] [--raw] [--pick ] [--cwd ] [--ws ] [--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 [--authority ] → {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 ', 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. diff --git a/gsd-core/workflows/plan-review-convergence.md b/gsd-core/workflows/plan-review-convergence.md index cbe2eb595..56a2caa1e 100644 --- a/gsd-core/workflows/plan-review-convergence.md +++ b/gsd-core/workflows/plan-review-convergence.md @@ -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 --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." diff --git a/src/plan-drift-guard.cts b/src/plan-drift-guard.cts new file mode 100644 index 000000000..f3e259cba --- /dev/null +++ b/src/plan-drift-guard.cts @@ -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> = 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(Object.keys(AUTHORITY_RUNGS)); +const VALID_STATUSES = new Set(['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 }; + } +} diff --git a/tests/adr-22-plan-drift-guard.test.cjs b/tests/adr-22-plan-drift-guard.test.cjs new file mode 100644 index 000000000..fb868a50a --- /dev/null +++ b/tests/adr-22-plan-drift-guard.test.cjs @@ -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', + ); + }); +}); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index c2b816e56..71497ad99 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -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,