* 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:
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user