diff --git a/.changeset/mellow-moles-run.md b/.changeset/mellow-moles-run.md new file mode 100644 index 000000000..76bc57f1a --- /dev/null +++ b/.changeset/mellow-moles-run.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 4147 +--- +**`/gsd:plan-phase` now warns when RESEARCH.md/PATTERNS.md predate CONTEXT.md's newest decisions** — a new deterministic pre-check compares each artifact's git commit time against CONTEXT.md's before plan-phase silently reuses it; opt into blocking with `workflow.context_drift_action: block`. (#3348) diff --git a/capabilities/drift/capability.json b/capabilities/drift/capability.json index 4b1dfca01..fae5dfe7d 100644 --- a/capabilities/drift/capability.json +++ b/capabilities/drift/capability.json @@ -42,6 +42,20 @@ "type": "boolean", "default": true, "description": "Enable the non-blocking codebase drift pre-check at plan:pre, before /gsd:plan-phase spawns the planner. When enabled, a stale STRUCTURE.md (structural additions exceeding drift_threshold) is surfaced up front as a warn-only advisory pointing to /gsd:map-codebase; it never blocks planning and never spawns the mapper agent. Separate from schema_drift_gate so autonomous/CI runs can silence the plan-time advisory while keeping the execute:wave:post gates enabled." + }, + "workflow.context_drift_precheck": { + "type": "boolean", + "default": true, + "description": "Enable the non-blocking context-drift pre-check at plan:pre, before /gsd:plan-phase reuses an existing RESEARCH.md/PATTERNS.md/VALIDATION.md/SPEC.md. Compares each artifact's effective last-changed time (git commit time, falling back to mtime for uncommitted edits) against CONTEXT.md's own — an artifact that predates CONTEXT.md's newest decision was derived from a premise that has since changed. Warn-only by default (see workflow.context_drift_action); never blocks planning on its own." + }, + "workflow.context_drift_action": { + "type": "enum", + "values": [ + "warn", + "block" + ], + "default": "warn", + "description": "Action taken by the context-drift gate when a stale upstream artifact is found: warn (advisory message naming the stale artifacts and how to regenerate them) or block (halt plan-phase until the artifacts are regenerated or the check is disabled)." } }, "steps": [], @@ -73,6 +87,15 @@ "when": "workflow.plan_drift_precheck", "blocking": false, "onError": "skip" + }, + { + "point": "plan:pre", + "check": { + "query": "verify.context-drift" + }, + "when": "workflow.context_drift_precheck", + "blocking": false, + "onError": "skip" } ] } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a6e0b1a11..14516348f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -456,6 +456,8 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin | `workflow.drift_threshold` | number | `3` | Minimum number of new structural elements (new directories, barrel exports, migrations, route modules) before the codebase-drift gate takes action. The gate runs at two points: `plan:pre` (before `/gsd-plan-phase` plans — **non-blocking, warn-only**, so plans are authored against a fresh STRUCTURE.md) and `execute:wave:post` (after `/gsd-execute-phase` — honors `workflow.drift_action`). See [#2003](https://github.com/open-gsd/gsd-core/issues/2003). Added in v1.39 | | `workflow.drift_action` | string | `warn` | What to do when `workflow.drift_threshold` is exceeded **at `execute:wave:post`** (after `/gsd-execute-phase`). `warn` prints a message suggesting `/gsd-map-codebase --paths …`; `auto-remap` spawns `gsd-codebase-mapper` scoped to the affected paths. The `plan:pre` pre-check is always warn-only regardless of this setting — it never auto-spawns the mapper at plan entry. Added in v1.39 | | `workflow.plan_drift_precheck` | boolean | `true` | Enable the non-blocking codebase-drift pre-check at `plan:pre`, before `/gsd-plan-phase` spawns the planner. Surfaces a stale STRUCTURE.md (drift over `workflow.drift_threshold`) as a warn-only advisory pointing to `/gsd-map-codebase`; never blocks planning, never spawns the mapper. Separate from the `execute:wave:post` gates so autonomous/CI runs can silence the plan-time advisory while keeping execute-time drift detection on. Added in v1.6.0. See [#1592](https://github.com/open-gsd/gsd-core/issues/1592). | +| `workflow.context_drift_precheck` | boolean | `true` | Enable the non-blocking context-drift pre-check at `plan:pre`, before `/gsd-plan-phase` reuses an existing RESEARCH.md/PATTERNS.md/VALIDATION.md/SPEC.md. Compares each artifact's effective last-changed time (git commit time, falling back to mtime for uncommitted edits) against CONTEXT.md's own; an artifact that predates CONTEXT.md's newest decision was derived from a premise that has since changed. Warn-only by default (see `workflow.context_drift_action`); never blocks planning on its own. See [#3348](https://github.com/open-gsd/gsd-core/issues/3348). | +| `workflow.context_drift_action` | string | `warn` | What to do when the context-drift gate finds a stale upstream artifact. `warn` prints an advisory naming the stale artifacts and how to regenerate them; `block` halts `/gsd-plan-phase` until the artifacts are regenerated or the check is disabled. See [#3348](https://github.com/open-gsd/gsd-core/issues/3348). | | `workflow.build_command` | string | (none) | Shell command to build the project in the post-merge build gate (Step A of step 5.6 in execute-phase). When unset, the gate auto-detects: Xcode (`.xcodeproj` present) → `xcodebuild build`, `Makefile` with `build:` target → `make build`, Justfile → `just build`, `Cargo.toml` → `cargo build`, `go.mod` → `go build ./...`, Python → `python -m py_compile`, `package.json` with `build` script → `npm run build`. Runs with a 5-minute timeout; failure increments `WAVE_FAILURE_COUNT`. Added in v1.39 | | `workflow.test_command` | string | (none) | Shell command to run the project's test suite in the post-merge test gate (Step B of step 5.6 in execute-phase) and the regression gate. When unset, the gate auto-detects: Xcode (`.xcodeproj` present) → `xcodebuild test`, `Makefile` with `test:` target → `make test`, Justfile → `just test`, `package.json` → `npm test`, `Cargo.toml` → `cargo test`, `go.mod` → `go test ./...`, Python → `python -m pytest`. Runs with a 5-minute timeout; failure increments `WAVE_FAILURE_COUNT`. Added in v1.39 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 82c56f0fb..1d080e165 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -197,6 +197,7 @@ - [Machine-Readable State Contract (`.planning/state.json`)](#166-machine-readable-state-contract-planningstatejson) - [Stated Failing Direction](#167-stated-failing-direction) - [Runtime Identity](#168-runtime-identity) + - [Context Drift Gate](#3348-context-drift-gate) - ["Failure Is a Value" — Strict Argv Rejection and the `--pick` Absence Contract](#3884-failure-is-a-value--strict-argv-rejection-and-the---pick-absence-contract) - [No Silent Swallow, No Verdict From Dropped Data](#3885-no-silent-swallow-no-verdict-from-dropped-data) - [Runtime Marker Resolution, Derived Codex Sandbox, and In-Phase Short-Form Dependencies](#3897-runtime-marker-resolution-derived-codex-sandbox-and-in-phase-short-form-dependencies) @@ -3685,6 +3686,17 @@ _Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features --- +### 3348. Context Drift Gate + +**Purpose:** Warns (or optionally blocks) before `/gsd-plan-phase` reuses an existing +`RESEARCH.md`, `PATTERNS.md`, `VALIDATION.md`, or `SPEC.md` that predates a decision added to the +phase's `CONTEXT.md` after that artifact was derived from it. Deterministic — compares git commit +time (falling back to mtime for uncommitted edits), no model call. Sibling to the existing +codebase-drift and schema-drift gates in the `drift` capability. Configure with +`workflow.context_drift_precheck` (on/off) and `workflow.context_drift_action` (`warn`/`block`). + +--- + ### 3884. "Failure Is a Value" — Strict Argv Rejection and the `--pick` Absence Contract **Purpose:** ADR-3473 §8.4 states the rule directly: absence, emptiness, and diff --git a/docs/features/context-drift-gate.md b/docs/features/context-drift-gate.md new file mode 100644 index 000000000..69b1a2c09 --- /dev/null +++ b/docs/features/context-drift-gate.md @@ -0,0 +1,12 @@ +--- +id: 3348 +title: Context Drift Gate +group: v1.7.0 Features +--- + +**Purpose:** Warns (or optionally blocks) before `/gsd-plan-phase` reuses an existing +`RESEARCH.md`, `PATTERNS.md`, `VALIDATION.md`, or `SPEC.md` that predates a decision added to the +phase's `CONTEXT.md` after that artifact was derived from it. Deterministic — compares git commit +time (falling back to mtime for uncommitted edits), no model call. Sibling to the existing +codebase-drift and schema-drift gates in the `drift` capability. Configure with +`workflow.context_drift_precheck` (on/off) and `workflow.context_drift_action` (`warn`/`block`). diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 0f42e3b82..351a84c94 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -1554,6 +1554,20 @@ const capabilities = { "type": "boolean", "default": true, "description": "Enable the non-blocking codebase drift pre-check at plan:pre, before /gsd:plan-phase spawns the planner. When enabled, a stale STRUCTURE.md (structural additions exceeding drift_threshold) is surfaced up front as a warn-only advisory pointing to /gsd:map-codebase; it never blocks planning and never spawns the mapper agent. Separate from schema_drift_gate so autonomous/CI runs can silence the plan-time advisory while keeping the execute:wave:post gates enabled." + }, + "workflow.context_drift_precheck": { + "type": "boolean", + "default": true, + "description": "Enable the non-blocking context-drift pre-check at plan:pre, before /gsd:plan-phase reuses an existing RESEARCH.md/PATTERNS.md/VALIDATION.md/SPEC.md. Compares each artifact's effective last-changed time (git commit time, falling back to mtime for uncommitted edits) against CONTEXT.md's own — an artifact that predates CONTEXT.md's newest decision was derived from a premise that has since changed. Warn-only by default (see workflow.context_drift_action); never blocks planning on its own." + }, + "workflow.context_drift_action": { + "type": "enum", + "values": [ + "warn", + "block" + ], + "default": "warn", + "description": "Action taken by the context-drift gate when a stale upstream artifact is found: warn (advisory message naming the stale artifacts and how to regenerate them) or block (halt plan-phase until the artifacts are regenerated or the check is disabled)." } }, "steps": [], @@ -1585,6 +1599,15 @@ const capabilities = { "when": "workflow.plan_drift_precheck", "blocking": false, "onError": "skip" + }, + { + "point": "plan:pre", + "check": { + "query": "verify.context-drift" + }, + "when": "workflow.context_drift_precheck", + "blocking": false, + "onError": "skip" } ] }, @@ -4414,6 +4437,16 @@ const byLoopPoint = { "blocking": false, "onError": "skip" }, + { + "capId": "drift", + "point": "plan:pre", + "check": { + "query": "verify.context-drift" + }, + "when": "workflow.context_drift_precheck", + "blocking": false, + "onError": "skip" + }, { "capId": "ui", "point": "plan:pre", @@ -4805,6 +4838,8 @@ const configKeys = { "workflow.drift_action": "drift", "workflow.schema_drift_gate": "drift", "workflow.plan_drift_precheck": "drift", + "workflow.context_drift_precheck": "drift", + "workflow.context_drift_action": "drift", "external_job.enabled": "external-job", "external_job.backend": "external-job", "external_job.artifact_dir": "external-job", @@ -5023,6 +5058,22 @@ const configSchema = { "default": true, "description": "Enable the non-blocking codebase drift pre-check at plan:pre, before /gsd:plan-phase spawns the planner. When enabled, a stale STRUCTURE.md (structural additions exceeding drift_threshold) is surfaced up front as a warn-only advisory pointing to /gsd:map-codebase; it never blocks planning and never spawns the mapper agent. Separate from schema_drift_gate so autonomous/CI runs can silence the plan-time advisory while keeping the execute:wave:post gates enabled." }, + "workflow.context_drift_precheck": { + "owner": "drift", + "type": "boolean", + "default": true, + "description": "Enable the non-blocking context-drift pre-check at plan:pre, before /gsd:plan-phase reuses an existing RESEARCH.md/PATTERNS.md/VALIDATION.md/SPEC.md. Compares each artifact's effective last-changed time (git commit time, falling back to mtime for uncommitted edits) against CONTEXT.md's own — an artifact that predates CONTEXT.md's newest decision was derived from a premise that has since changed. Warn-only by default (see workflow.context_drift_action); never blocks planning on its own." + }, + "workflow.context_drift_action": { + "owner": "drift", + "type": "enum", + "default": "warn", + "description": "Action taken by the context-drift gate when a stale upstream artifact is found: warn (advisory message naming the stale artifacts and how to regenerate them) or block (halt plan-phase until the artifacts are regenerated or the check is disabled).", + "values": [ + "warn", + "block" + ] + }, "external_job.enabled": { "owner": "external-job", "type": "boolean", diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index 81e1045c4..4858a9ccf 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -287,6 +287,43 @@ fi If `AI_SPEC_FILE` is non-empty, pass `AI_SPEC_PATH` and `FRAMEWORK_LINE` to the planner in step 8 so it can reference the AI design contract. If it is empty, the active `ai-integration` capability hook in step 5.6 handles any AI-system nudge or `/gsd:ai-integration-phase` dispatch. +## 4.6. Context Drift Pre-Check (drift plan:pre gate) + +Capability-driven dispatch, same lazy-init pattern already used elsewhere in this file for +`PLAN_PRE_HOOKS_JSON`: + +```bash +if [ -z "${PLAN_PRE_HOOKS_JSON:-}" ]; then + PLAN_PRE_HOOKS_JSON=$(gsd_run loop render-hooks plan:pre --raw) +fi +``` + +If `activeHooks` (from `PLAN_PRE_HOOKS_JSON`) has a `kind == "gate"`, `capId == "drift"`, +`check.query == "verify.context-drift"` entry (`workflow.context_drift_precheck` on), run the +check before either the research-reuse decision (§5.1) or the pattern-mapper reuse decision +(§7.8) can fire — both would otherwise silently reuse a stale artifact with zero signal. +Otherwise skip to §5. + +```bash +DRIFT=$(gsd_run verify context-drift "${PHASE}" 2>/dev/null || echo '{"skipped":true}') +``` + +If `skipped` is true, continue silently to §5 — nothing to compare (no CONTEXT.md yet, no +upstream artifacts yet, or the phase directory did not resolve). + +If `stale_artifacts` is a non-empty array, print `message` verbatim (it names each stale +artifact and the command to regenerate it). Then: + +- If `DRIFT.block` is `false` (the default, `workflow.context_drift_action: warn`): continue to + §5 — this is advisory only, exactly like the codebase-drift pre-check at §5.65. +- If `DRIFT.block` is `true` (opt-in `workflow.context_drift_action: block`): **exit the + plan-phase workflow** rather than continuing. Do not spawn the researcher, the planner, or the + pattern mapper against a premise the user has not yet reconciled. Point the user at re-running + `/gsd:plan-phase {X}` once the named artifacts are regenerated, or at disabling the check with + `gsd_run query config-set workflow.context_drift_action warn` if the flag was a false positive. + +If `stale_artifacts` is empty, continue silently to §5 — nothing to report. + ## 5. Handle Research **Skip if:** `--gaps` flag or `--skip-research` flag or `--reviews` flag. diff --git a/scripts/lint-phase-enumeration-drift.cjs b/scripts/lint-phase-enumeration-drift.cjs index a21b3e3de..1ad5a756c 100644 --- a/scripts/lint-phase-enumeration-drift.cjs +++ b/scripts/lint-phase-enumeration-drift.cjs @@ -84,9 +84,13 @@ * shape as `collectDiskPhases` and the `audit.cts` scanners; it must see * every phase directory regardless of milestone window to catch a * naming/duplicate defect wherever it lives. - * - `src/verify.cts` `cmdVerifySchemaDrift`: resolves ONE caller-supplied + * - `src/verify.cts` `resolvePhaseDirByToken`: resolves ONE caller-supplied * `phase` argument to its directory (falling back to an exact-name * match) — a single-phase LOOKUP, not a current-milestone enumeration. + * Originally `cmdVerifySchemaDrift`'s own inline block; #3348 lifted it + * into this shared helper (also used by the new `cmdVerifyContextDrift`) + * without changing what question it asks, so the exemption moved with + * the call site rather than multiplying. * - `src/init.cts` `detectHasPriorPhases`: answers "has this project EVER * completed a phase", explicitly excluding the current one. A history * probe across all milestones, not a current-milestone enumeration. @@ -305,7 +309,7 @@ const OWNER_FILES = new Set([ // `lint-milestone-window-drift.cjs`'s FUNCTION_SCOPED_EXEMPTIONS mechanism. // See the header comment for the full written reason behind each entry. const FUNCTION_SCOPED_EXEMPTIONS = new Map([ - [path.join('src', 'verify.cts'), new Set(['cmdValidateHealth', 'cmdVerifySchemaDrift'])], + [path.join('src', 'verify.cts'), new Set(['cmdValidateHealth', 'resolvePhaseDirByToken'])], [path.join('src', 'init.cts'), new Set(['detectHasPriorPhases', 'detectUiPhaseActive'])], [path.join('src', 'milestone.cts'), new Set(['archivePhaseDirectories', 'cmdMilestoneComplete', 'cmdPhasesClear'])], // #3849: collectSiblingWorktreePhaseNums reads a SIBLING worktree's phases dir — diff --git a/src/check-command-router.cts b/src/check-command-router.cts index 46ffcb9fb..13e8c9914 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -29,7 +29,7 @@ import { checkUiPresence } from './ui-safety-gate.cjs'; import { hasStaticFrontendEvidence } from './ui-frontend-evidence.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import verifyModule = require('./verify.cjs'); -const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift } = verifyModule; +const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift, cmdVerifyContextDrift } = verifyModule; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapModule = require('./roadmap.cjs'); const { getRoadmapPhaseWithFallback } = roadmapModule; @@ -1720,6 +1720,13 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { cmdVerifyCodebaseDrift(cwd, raw); return; } + if (subcommand === 'verify-context-drift') { + // Delegates to verify.context-drift — drift capability gate at plan:pre (non-blocking). + // Dot-to-hyphen normalization means query "verify.context-drift" routes here. + const phaseArg = typeof args[2] === 'string' ? args[2] : ''; + cmdVerifyContextDrift(cwd, phaseArg, raw); + return; + } if (subcommand === 'predicate') { // Generic gate-predicate evaluator (#2008). The workflow gate-dispatch calls // this for any gate whose `check` carries a `predicate` (instead of a `query`), @@ -1737,7 +1744,7 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void { routeProhibitionEnforcement(args, raw); return; } - error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-command-paths, verify-failure-directions, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND); + error('Unknown check subcommand. Available: api-coverage-verify-pre, auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-command-paths, verify-failure-directions, verify-schema-drift, verify-codebase-drift, verify-context-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND); } export = { diff --git a/src/command-aliases.cts b/src/command-aliases.cts index d429b970a..18a215101 100644 --- a/src/command-aliases.cts +++ b/src/command-aliases.cts @@ -260,6 +260,14 @@ export const VERIFY_COMMAND_ALIASES: CommandAlias[] = [ ], "subcommand": "codebase-drift", "mutation": false + }, + { + "canonical": "verify.context-drift", + "aliases": [ + "verify context-drift" + ], + "subcommand": "context-drift", + "mutation": false } ]; diff --git a/src/commands.cts b/src/commands.cts index 357d0863b..565de44bf 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -2084,7 +2084,7 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u // #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely // idles ~4s on Windows before any task) — 10s is too tight, and a timeout // kill is NOT an ordinary failure. Same band as the push call below. - const commitResult = execGit(commitArgs, { cwd, timeout: COMMIT_TIMEOUT_MS, env: commitEnv }); + const commitResult = execGit(commitArgs, { cwd, env: commitEnv, timeout: COMMIT_TIMEOUT_MS }); if (commitResult.exitCode !== 0) { // #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the // partial stderr it flushed (often incidental CRLF warnings) is noise, diff --git a/src/verify-command-router.cts b/src/verify-command-router.cts index e3cd7d4c8..06b388052 100644 --- a/src/verify-command-router.cts +++ b/src/verify-command-router.cts @@ -23,6 +23,7 @@ interface VerifyModule { cmdVerifyKeyLinks(cwd: string, phase: string | undefined, raw: boolean): void; cmdVerifySchemaDrift(cwd: string, phase: string | undefined, skip: boolean, raw: boolean): void; cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void; + cmdVerifyContextDrift(cwd: string, phase: string | undefined, raw: boolean): void; } interface RouteVerifyCommandOptions { @@ -59,6 +60,7 @@ function routeVerifyCommand({ verify, args, cwd, raw, error }: RouteVerifyComman // per ADR/PRD 3524 §3 / L160 (CJS-only by design). Routing through // recursive dispatch would re-enter this router path. 'codebase-drift': () => verify.cmdVerifyCodebaseDrift(cwd, raw), + 'context-drift': () => verify.cmdVerifyContextDrift(cwd, args[2], raw), }, }); } diff --git a/src/verify.cts b/src/verify.cts index 12b4155cb..8ca5b8aab 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -20,6 +20,8 @@ import stateMod = require('./state.cjs'); import modelProfilesMod = require('./model-profiles.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module import planScanMod = require('./plan-scan.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module +import verificationMod = require('./verification.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module import coreUtilsMod = require('./core-utils.cjs'); const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod; @@ -56,6 +58,7 @@ import planningSnapshotMod = require('./planning-snapshot.cjs'); const { buildPlanningSnapshot } = planningSnapshotMod; const { planningDir } = planningWorkspace; +const { defaultPhaseCleanCommitTimesMs } = verificationMod; const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod; const { readStateHeadFreshness } = stateMod; @@ -1744,6 +1747,150 @@ function cmdValidateAgents(cwd: string, raw: boolean): void { ); } +// ─── Context drift (#3348) ─────────────────────────────────────────────────── + +/** + * Resolve a phase directory under `phasesDir` from a user-supplied `phaseArg`, + * via the canonical phase-directory matcher (phase-id.cjs::matchPhaseDirs) rather + * than a naive substring test — a bare `.includes(phaseArg)` lets a non-existent + * phase silently match a different phase whose directory name merely contains the + * requested token (e.g. "1" matching "11-expansion"). Falls back to an exact + * directory-name match. Returns null if neither resolves. (#1571, #2528) + */ +function resolvePhaseDirByToken(phasesDir: string, phaseArg: string): string | null { + const normalizedPhase = normalizePhaseName(phaseArg); + const dirEntries = fs.readdirSync(phasesDir, { withFileTypes: true }); + const dirNames = dirEntries.filter((e) => e.isDirectory()).map((e) => e.name); + const matched = matchPhaseDirs(dirNames, normalizedPhase).matches[0]; + if (matched) return path.join(phasesDir, matched); + const check = validatePath(phaseArg, phasesDir); + if (check.safe && fs.existsSync(check.resolved)) return check.resolved; + return null; +} + +interface ContextDriftEntry { + file: string; + effectiveMs: number; +} + +/** + * Pure comparator: which of `entries` have an effective last-changed time + * STRICTLY BEFORE `contextEffectiveMs` (CONTEXT.md's own effective time)? Strict + * `<` is "stale" (matches findStaleVerificationSummary's own strict `>` convention + * for "newer than" elsewhere in this codebase — an artifact committed in the SAME + * commit/second as CONTEXT.md is in sync, not stale). + */ +function computeContextDrift(contextEffectiveMs: number, entries: ContextDriftEntry[]): string[] { + return entries.filter((e) => e.effectiveMs < contextEffectiveMs).map((e) => e.file); +} + +function buildContextDriftMessage(staleArtifacts: string[], phaseArg: string): string { + const parts = [`CONTEXT.md decisions are newer than: ${staleArtifacts.join(', ')}.`]; + if (staleArtifacts.some((f) => f.endsWith('-RESEARCH.md'))) { + parts.push(`Regenerate research: /gsd:plan-phase ${phaseArg} --research.`); + } + if (staleArtifacts.some((f) => f.endsWith('-PATTERNS.md'))) { + parts.push('Regenerate patterns: delete the PATTERNS.md file, then re-run /gsd:plan-phase.'); + } + if ( + staleArtifacts.some( + (f) => f.endsWith('-VALIDATION.md') || (f.endsWith('-SPEC.md') && !f.endsWith('-AI-SPEC.md') && !f.endsWith('-UI-SPEC.md')), + ) + ) { + parts.push('Regenerate or manually reconcile VALIDATION.md / SPEC.md against the current decisions.'); + } + parts.push('Do not hand-inject the newer decisions into a prompt as a substitute for regenerating — that carries the staleness forward.'); + return parts.join(' '); +} + +function cmdVerifyContextDrift(cwd: string, phaseArg: string | undefined, raw: boolean): void { + if (!phaseArg) { + error('Usage: verify context-drift '); + return; + } + + const pDir = planningDir(cwd); + const phasesDir = path.join(pDir, 'phases'); + const emitSkip = (reason: string, message = ''): void => { + output({ block: false, skipped: true, reason, stale_artifacts: [], message }, raw); + }; + + if (!fs.existsSync(phasesDir)) { + emitSkip('phase-not-found', `Phase directory not found: ${phaseArg}`); + return; + } + + // Same phase-directory resolution rule cmdVerifySchemaDrift uses (#1571, #2528): + // matchPhaseDirs, never a naive substring test. + const phaseDir = resolvePhaseDirByToken(phasesDir, phaseArg); + if (!phaseDir) { + emitSkip('phase-not-found', `Phase directory not found: ${phaseArg}`); + return; + } + + let phaseFiles: string[]; + try { + phaseFiles = fs.readdirSync(phaseDir).slice().sort(); + } catch { + emitSkip('phase-not-found', `Phase directory not found: ${phaseArg}`); + return; + } + + const contextFile = phaseFiles.find((f) => f.endsWith('-CONTEXT.md')); + if (!contextFile) { + emitSkip('no-context-md'); + return; + } + + const researchFile = phaseFiles.find((f) => f.endsWith('-RESEARCH.md')); + const patternsFile = phaseFiles.find((f) => f.endsWith('-PATTERNS.md')); + const validationFile = phaseFiles.find((f) => f.endsWith('-VALIDATION.md')); + const specFile = phaseFiles.find( + (f) => f.endsWith('-SPEC.md') && !f.endsWith('-AI-SPEC.md') && !f.endsWith('-UI-SPEC.md'), + ); + const upstreamFiles = [researchFile, patternsFile, validationFile, specFile].filter( + (f): f is string => !!f, + ); + + if (upstreamFiles.length === 0) { + emitSkip('no-upstream-artifacts'); + return; + } + + const allFiles = [contextFile, ...upstreamFiles]; + const cleanCommitMs = defaultPhaseCleanCommitTimesMs(phaseDir, allFiles); + const effectiveTimeMs = (file: string): number => + cleanCommitMs.has(file) + ? (cleanCommitMs.get(file) as number) + : fs.statSync(path.join(phaseDir, file)).mtimeMs; + + const contextMs = effectiveTimeMs(contextFile); + const driftEntries: ContextDriftEntry[] = upstreamFiles.map((f) => ({ file: f, effectiveMs: effectiveTimeMs(f) })); + const staleArtifacts = computeContextDrift(contextMs, driftEntries); + + let wf: Record | undefined; + try { + const rawCfg = JSON.parse(fs.readFileSync(path.join(pDir, 'config.json'), 'utf-8')) as Record; + wf = rawCfg['workflow'] as Record | undefined; + } catch { + wf = undefined; + } + const action = wf?.context_drift_action === 'block' ? 'block' : 'warn'; + const block = staleArtifacts.length > 0 && action === 'block'; + const message = staleArtifacts.length > 0 ? buildContextDriftMessage(staleArtifacts, phaseArg) : ''; + + output( + { + block, + skipped: false, + stale_artifacts: staleArtifacts, + action, + message, + }, + raw, + ); +} + function cmdVerifySchemaDrift( cwd: string, phaseArg: string, @@ -1769,17 +1916,7 @@ function cmdVerifySchemaDrift( // matching "11-expansion"), making the drift gate inspect the wrong phase. // This shares the one selection rule with find-phase / verify // phase-completeness rather than restating it. (#1571, #2528) - let phaseDir: string | null = null; - const normalizedPhase = normalizePhaseName(phaseArg); - const entries = fs.readdirSync(phasesDir, { withFileTypes: true }); - const dirNames = entries.filter((e) => e.isDirectory()).map((e) => e.name); - const drift = matchPhaseDirs(dirNames, normalizedPhase).matches[0]; - if (drift) phaseDir = path.join(phasesDir, drift); - - if (!phaseDir) { - const exact = path.join(phasesDir, phaseArg); - if (fs.existsSync(exact)) phaseDir = exact; - } + const phaseDir = resolvePhaseDirByToken(phasesDir, phaseArg); if (!phaseDir) { output( @@ -2000,5 +2137,7 @@ export = { cmdValidateAgents, cmdVerifySchemaDrift, cmdVerifyCodebaseDrift, + computeContextDrift, + cmdVerifyContextDrift, STATE_HEAD_ADVISORY_COMMITS, }; diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 2007a267e..ae77241d0 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -7178,12 +7178,18 @@ describe('#1592 — drift plan:pre codebase-drift gate (registry, behavioral)', assert.deepStrictEqual( keys, [ + 'workflow.context_drift_action', + 'workflow.context_drift_precheck', 'workflow.drift_action', 'workflow.drift_threshold', 'workflow.plan_drift_precheck', 'workflow.schema_drift_gate', ], - 'the plan:pre gate adds exactly the dedicated plan_drift_precheck toggle — no other new keys', + // #3348 (separately) adds its own plan:pre context-drift gate's two dedicated + // toggles (workflow.context_drift_precheck / workflow.context_drift_action) — + // #1592's own contribution here remains exactly the one plan_drift_precheck key. + 'the plan:pre gate adds exactly the dedicated plan_drift_precheck toggle — no other new keys from #1592 ' + + '(workflow.context_drift_precheck / workflow.context_drift_action are #3348\'s separate context-drift gate keys)', ); }); }); diff --git a/tests/context-drift.test.cjs b/tests/context-drift.test.cjs new file mode 100644 index 000000000..31ac68331 --- /dev/null +++ b/tests/context-drift.test.cjs @@ -0,0 +1,375 @@ +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempGitProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs'); +const { gitOrThrow } = require('./helpers/git-fixture.cjs'); + +const VERIFY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'verify.cjs'); +const { computeContextDrift } = require(VERIFY_PATH); + +describe('computeContextDrift', () => { + test('returns no stale artifacts when there is nothing to compare', () => { + assert.deepStrictEqual(computeContextDrift(1000, []), []); + }); + + test('treats a newer upstream artifact as fresh', () => { + const stale = computeContextDrift(1000, [{ file: '01-RESEARCH.md', effectiveMs: 2000 }]); + assert.deepStrictEqual(stale, []); + }); + + test('flags an upstream artifact older than CONTEXT.md', () => { + const stale = computeContextDrift(2000, [{ file: '01-RESEARCH.md', effectiveMs: 1000 }]); + assert.deepStrictEqual(stale, ['01-RESEARCH.md']); + }); + + test('reports exactly the stale subset, not all entries', () => { + const stale = computeContextDrift(2000, [ + { file: '01-RESEARCH.md', effectiveMs: 1000 }, + { file: '01-PATTERNS.md', effectiveMs: 3000 }, + { file: '01-VALIDATION.md', effectiveMs: 500 }, + ]); + assert.deepStrictEqual(stale, ['01-RESEARCH.md', '01-VALIDATION.md']); + }); + + test('treats an equal timestamp as not stale (strict greater-than)', () => { + const stale = computeContextDrift(2000, [{ file: '01-RESEARCH.md', effectiveMs: 2000 }]); + assert.deepStrictEqual(stale, []); + }); + + test('flags an artifact exactly one second (1000ms) older', () => { + const stale = computeContextDrift(2000, [{ file: '01-RESEARCH.md', effectiveMs: 1000 }]); + assert.deepStrictEqual(stale, ['01-RESEARCH.md']); + }); + + test('treats an artifact exactly one second (1000ms) newer as fresh', () => { + const stale = computeContextDrift(2000, [{ file: '01-RESEARCH.md', effectiveMs: 3000 }]); + assert.deepStrictEqual(stale, []); + }); + + test('handles an empty entries array without throwing', () => { + assert.doesNotThrow(() => computeContextDrift(0, [])); + }); + + test('does not throw on a zero or negative timestamp', () => { + assert.deepStrictEqual(computeContextDrift(0, [{ file: 'a.md', effectiveMs: -5 }]), ['a.md']); + assert.deepStrictEqual(computeContextDrift(-5, [{ file: 'a.md', effectiveMs: 0 }]), []); + }); +}); + +describe('verify context-drift CLI', () => { + let tmp; + beforeEach(() => { + tmp = createTempGitProject('gsd-context-drift-cli-'); + }); + afterEach(() => cleanup(tmp)); + + function phaseDirPath(name) { + return path.join(tmp, '.planning', 'phases', name); + } + + test('errors with usage message on missing phase arg', () => { + const r = runGsdTools(['verify', 'context-drift'], tmp); + assert.strictEqual(r.success, false); + assert.match(r.error || '', /Usage: verify context-drift /); + }); + + test('treats an empty phase arg as missing', () => { + const r = runGsdTools(['verify', 'context-drift', ''], tmp); + assert.strictEqual(r.success, false); + assert.match(r.error || '', /Usage: verify context-drift /); + }); + + test('treats a whitespace phase arg as not found, not a usage error', () => { + const r = runGsdTools(['verify', 'context-drift', ' '], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'phase-not-found'); + }); + + test('degrades gracefully for an unresolvable phase', () => { + const r = runGsdTools(['verify', 'context-drift', '99'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'phase-not-found'); + assert.strictEqual(data.block, false); + }); + + test('does not interpret shell metacharacters in the phase arg', () => { + const r = runGsdTools(['verify', 'context-drift', '1; echo pwned'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'phase-not-found'); + }); + + test('does not path-traverse via a hostile phase arg', () => { + const r = runGsdTools(['verify', 'context-drift', '../../etc'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'phase-not-found'); + }); + + test('does not escape phasesDir via a deep traversal payload that would otherwise resolve to a real path', () => { + // Deep enough that path.join's .. collapsing would reach outside the temp + // sandbox entirely (unlike a shallow '../../etc', which lands harmlessly + // inside the sandbox as a nonexistent path and would pass for the wrong + // reason). validatePath must reject this by real-path containment, not by + // accidental non-existence. + const r = runGsdTools(['verify', 'context-drift', '../../../../../../../../../../etc'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'phase-not-found'); + }); + + test('skips when no CONTEXT.md exists', () => { + const dir = phaseDirPath('01-setup'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n'); + const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'no-context-md'); + assert.strictEqual(data.block, false); + }); + + test('skips when no upstream artifacts exist', () => { + const dir = phaseDirPath('01-setup'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n'); + const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'no-upstream-artifacts'); + }); + + test('excludes AI-SPEC.md and UI-SPEC.md from the SPEC.md comparison', () => { + const dir = phaseDirPath('01-setup'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n'); + fs.writeFileSync(path.join(dir, '01-AI-SPEC.md'), '# ai spec\n'); + fs.writeFileSync(path.join(dir, '01-UI-SPEC.md'), '# ui spec\n'); + const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, true); + assert.strictEqual(data.reason, 'no-upstream-artifacts'); + }); + + test('degrades to mtime comparison outside a git repo', () => { + const plain = createTempDir('gsd-context-drift-nogit-'); + try { + const dir = path.join(plain, '.planning', 'phases', '01-setup'); + fs.mkdirSync(dir, { recursive: true }); + // Deterministic mtimes (CONTRIBUTING.md: never assert elapsed wall-clock + // time) — two back-to-back writeFileSync calls can land in the SAME + // mtime granularity tick on a fast filesystem, producing a tie that + // computeContextDrift's strict `<` correctly treats as not-stale. Set + // distinct mtimes explicitly instead of relying on real timing. + const now = Date.now(); + fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n'); + fs.utimesSync(path.join(dir, '01-RESEARCH.md'), new Date(now - 5000), new Date(now - 5000)); + fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n'); + fs.utimesSync(path.join(dir, '01-CONTEXT.md'), new Date(now), new Date(now)); + const r = runGsdTools(['verify', 'context-drift', '01-setup'], plain); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, false); + assert.deepStrictEqual(data.stale_artifacts, ['01-RESEARCH.md']); + } finally { + cleanup(plain); + } + }); + + test('degrades to mtime comparison in a repo with no commits', () => { + const dir = phaseDirPath('01-setup'); + fs.mkdirSync(dir, { recursive: true }); + // Deterministic mtimes — see the identical rationale in 'degrades to mtime + // comparison outside a git repo' above. + const now = Date.now(); + fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n'); + fs.utimesSync(path.join(dir, '01-RESEARCH.md'), new Date(now - 5000), new Date(now - 5000)); + fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n'); + fs.utimesSync(path.join(dir, '01-CONTEXT.md'), new Date(now), new Date(now)); + const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, false); + assert.deepStrictEqual(data.stale_artifacts, ['01-RESEARCH.md']); + }); + + test('fresh RESEARCH.md (committed after CONTEXT.md) is not flagged', () => { + const dir = phaseDirPath('01-setup'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\nD-01\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'context'], { cwd: tmp }); + fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'research'], { cwd: tmp }); + const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, false); + assert.deepStrictEqual(data.stale_artifacts, []); + assert.strictEqual(data.block, false); + }); + + test('uses mtime, not a stale commit time, for a dirty CONTEXT.md', () => { + const dir = phaseDirPath('01-setup'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\nD-01\n'); + fs.writeFileSync(path.join(dir, '01-RESEARCH.md'), '# research\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'context+research'], { cwd: tmp }); + fs.appendFileSync(path.join(dir, '01-CONTEXT.md'), 'D-02\n'); + const r = runGsdTools(['verify', 'context-drift', '01-setup'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, false); + assert.deepStrictEqual(data.stale_artifacts, ['01-RESEARCH.md']); + }); + + test('#3348 regression: uncommitted new decisions flag existing RESEARCH and PATTERNS as stale', () => { + const dir = phaseDirPath('03-feature'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\nD-01\nD-02\n...\nD-09\n'); + fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research from D-01..D-09\n'); + fs.writeFileSync(path.join(dir, '03-PATTERNS.md'), '# patterns from D-01..D-09\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'phase 3: context, research, patterns'], { cwd: tmp }); + fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-10\nD-11\nD-12\nD-13\n'); + const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.skipped, false); + assert.deepStrictEqual( + data.stale_artifacts.slice().sort(), + ['03-PATTERNS.md', '03-RESEARCH.md'], + ); + }); + + test('defaults to warn when config.json is absent', () => { + const dir = phaseDirPath('03-feature'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n'); + fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'research'], { cwd: tmp }); + fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-99\n'); + assert.ok(!fs.existsSync(path.join(tmp, '.planning', 'config.json'))); + const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.action, 'warn'); + assert.strictEqual(data.block, false); + }); + + test('defaults to warn when config.json is malformed', () => { + const dir = phaseDirPath('03-feature'); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), '{ not valid json'); + fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n'); + fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'research'], { cwd: tmp }); + fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-99\n'); + const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.action, 'warn'); + assert.strictEqual(data.block, false); + }); + + test('falls back to warn for an unrecognized context_drift_action value', () => { + const dir = phaseDirPath('03-feature'); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ workflow: { context_drift_action: 'yolo' } }), + ); + fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n'); + fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'research'], { cwd: tmp }); + fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-99\n'); + const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.action, 'warn'); + assert.strictEqual(data.block, false); + }); + + test('sets block:true when context_drift_action is block and drift is found', () => { + const dir = phaseDirPath('03-feature'); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ workflow: { context_drift_action: 'block' } }), + ); + fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n'); + fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'research'], { cwd: tmp }); + fs.appendFileSync(path.join(dir, '03-CONTEXT.md'), 'D-99\n'); + const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.strictEqual(data.action, 'block'); + assert.strictEqual(data.block, true); + }); + + test('never blocks when nothing is stale, even with action:block', () => { + const dir = phaseDirPath('03-feature'); + fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ workflow: { context_drift_action: 'block' } }), + ); + fs.writeFileSync(path.join(dir, '03-CONTEXT.md'), '# context\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'context'], { cwd: tmp }); + fs.writeFileSync(path.join(dir, '03-RESEARCH.md'), '# research\n'); + gitOrThrow(['add', '.'], { cwd: tmp }); + gitOrThrow(['commit', '-m', 'research'], { cwd: tmp }); + const r = runGsdTools(['verify', 'context-drift', '03-feature'], tmp); + assert.strictEqual(r.success, true, r.error); + const data = JSON.parse(r.output); + assert.deepStrictEqual(data.stale_artifacts, []); + assert.strictEqual(data.block, false); + }); + + test('honors --raw', () => { + const dir = phaseDirPath('01-setup'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# context\n'); + const r = runGsdTools(['verify', 'context-drift', '01-setup', '--raw'], tmp); + assert.strictEqual(r.success, true, r.error); + }); + + test('always exits 0 (query command contract)', () => { + // Only cases that are legitimately part of the "always exits 0" JSON-output + // contract belong here — a missing phase arg is a DIFFERENT, already-covered + // contract ('errors with usage message on missing phase arg' above correctly + // asserts exitCode !== 0 / r.success === false for exactly that case). + const cases = [ + ['verify', 'context-drift', '99'], + ]; + for (const args of cases) { + const r = runGsdTools(args, tmp); + assert.strictEqual(r.exitCode, 0, `args=${JSON.stringify(args)} exitCode=${r.exitCode}`); + } + }); +}); diff --git a/tests/phase-locator.test.cjs b/tests/phase-locator.test.cjs index ecabaa228..ac2fba663 100644 --- a/tests/phase-locator.test.cjs +++ b/tests/phase-locator.test.cjs @@ -1776,7 +1776,6 @@ describe('migrated exemptions behave identically (#3882 rows E1/E2)', () => { [path.join('src', 'milestone.cts'), 'archivePhaseDirectories'], [path.join('src', 'milestone.cts'), 'cmdPhasesClear'], [path.join('src', 'verify.cts'), 'cmdValidateHealth'], - [path.join('src', 'verify.cts'), 'cmdVerifySchemaDrift'], [path.join('src', 'init.cts'), 'detectHasPriorPhases'], [path.join('src', 'init.cts'), 'detectUiPhaseActive'], ]; @@ -1793,6 +1792,16 @@ describe('migrated exemptions behave identically (#3882 rows E1/E2)', () => { const initExempt = driftGuard.FUNCTION_SCOPED_EXEMPTIONS.get(path.join('src', 'init.cts')); assert.ok(!initExempt || !initExempt.has('cmdInitMilestoneOp'), 'cmdInitMilestoneOp must no longer carry an exemption — its diskPhaseDirs lookup no longer hand-rolls a readdirSync'); + // #3348: cmdVerifySchemaDrift's own inline phasesDir readdirSync/matchPhaseDirs + // block was lifted into the shared resolvePhaseDirByToken helper (also used by + // the new cmdVerifyContextDrift) — same "call site no longer hand-rolls a + // readdirSync" shape as the roadmap/init migrations above, so the exemption + // moved with the call site rather than living at both names. + const verifyExempt = driftGuard.FUNCTION_SCOPED_EXEMPTIONS.get(path.join('src', 'verify.cts')); + assert.ok(!verifyExempt || !verifyExempt.has('cmdVerifySchemaDrift'), + 'cmdVerifySchemaDrift must no longer carry an exemption — its phasesDir readdirSync now lives in resolvePhaseDirByToken'); + assert.ok(verifyExempt && verifyExempt.has('resolvePhaseDirByToken'), + 'resolvePhaseDirByToken must carry the function-scoped exemption — it is the new owner of the extracted readdirSync'); }); }); diff --git a/tests/plan-pre-hook-e2e.test.cjs b/tests/plan-pre-hook-e2e.test.cjs index 6fc0916f9..19a0f312d 100644 --- a/tests/plan-pre-hook-e2e.test.cjs +++ b/tests/plan-pre-hook-e2e.test.cjs @@ -255,6 +255,7 @@ describe('plan:pre all-off — empty resolution', () => { pattern_mapper: false, schema_push_detection: false, plan_drift_precheck: false, + context_drift_precheck: false, assumption_delta: false, }, intel: { enabled: false },