* test(#3348): add failing-first coverage for the context-drift gate * feat(#3348): add context-drift pre-check gate for plan-phase Compares each phase's *-RESEARCH.md/*-PATTERNS.md/*-VALIDATION.md/*-SPEC.md effective last-changed time (git commit time, falling back to mtime for uncommitted edits) against *-CONTEXT.md's, so plan-phase no longer silently reuses an upstream artifact that predates a decision added to CONTEXT.md after that artifact was derived from it. Deterministic, no model call. New `gsd_run verify context-drift <phase>` command, sibling to the existing verify.codebase-drift/verify.schema-drift gates in the drift capability. Warn-only by default (workflow.context_drift_precheck), with an opt-in workflow.context_drift_action: block escape hatch. Wired at plan:pre in plan-phase.md, before both the RESEARCH.md and PATTERNS.md reuse decisions. * fix(#3348): address code-review findings — raw-text-match, stale comment, import placement, duplicated phase resolution * fix(#3859): pin the real commit's diff.ignoreSubmodules to match the empty-diff probe The #3859 empty-diff guard decides whether a submodule bump would land using `--ignore-submodules=dirty`, overriding the caller's `diff.ignoreSubmodules` config. The real `git commit -- <paths>` that follows was never given the same override, so under a bare `diff.ignoreSubmodules=all` repo config the two calculations disagree: driven on git 2.39.5 (Debian bookworm, the linux-node24 test-matrix image), the guard correctly stands aside but the scoped commit itself then silently fails (exit 1, no error text) for a gitlink bump it had just confirmed would be recorded, surfacing as commit_failed instead of committed:true. Pin `-c diff.ignoreSubmodules=dirty` onto the scoped commit call too, so the probe and the commit it protects can never diverge. Harmless when no submodule path is involved (driven: identical outcome on an ordinary scoped file, with and without the flag). * fix(#3348): guard resolvePhaseDirByToken's exact-match fallback against path traversal * fix(#3348): retarget phase-enumeration-drift exemption to the consolidated resolvePhaseDirByToken helper cmdVerifySchemaDrift's inline readdirSync was already function-scoped-exempt in lint-phase-enumeration-drift.cjs as a single-phase LOOKUP (not a current-milestone enumeration). This PR's refactor pass lifted that block into a shared helper, resolvePhaseDirByToken, also used by the new cmdVerifyContextDrift — the guard tracks exemptions by enclosing function name, so the readdirSync now lives in an unexempted function and started firing. Move the exemption to resolvePhaseDirByToken (same written reason, now covering both callers) instead of migrating to listAllPhaseDirs, which would introduce two real behavior deltas here: it catches readdirSync failures internally (old code let them throw) and sorts results by phase number before matchPhaseDirs picks matches[0] (old code used raw, OS-dependent readdirSync order). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3348): satisfy lint:ci — slash form, capability registry regen - docs/features/context-drift-gate.md used the deprecated /gsd: colon form; docs are never passed through the install-time slash-form converters, so lint-docs-command-form requires the hyphen form. Regenerated docs/FEATURES.md from the corrected fragment. - Regenerated gsd-core/bin/lib/capability-registry.cjs after editing capabilities/drift/capability.json (lint:generated-sync). * fix(#3859): pin the real commit's diff.ignoreSubmodules via env, not argv -c The prior fix pinned `-c diff.ignoreSubmodules=dirty` onto the scoped commit's argv via `commitArgs.unshift(...)`. `-c key=val` must precede the `commit` subcommand, so this shifted `commitArgs[0]` from `'commit'` to `'-c'` for every scoped commit call, breaking 17 position-based assertions in the commit-files pathspec regression suite that read `a[0] === 'commit'` to find the commit invocation among recorded git calls. `execGit` already accepts an `env` option merged onto `process.env` before spawning. Git honors `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/`GIT_CONFIG_VALUE_0` as a per-invocation config override functionally identical to `-c key=val`, expressed via env instead of argv. Passing that env alongside the existing commitArgs (still `['commit', ..., '--', ...stagedPaths]`, argv unchanged) fixes the real commit's effective diff.ignoreSubmodules to match the empty-diff guard's probe without moving anything in argv position 0. Scoped to exactly the canScope branch, matching the probe's own preconditions and leaving no behavior change for commits the probe never evaluated. No test file changes needed — the 17 previously-failing assertions test argv[0] against the array passed into execGit, which never changes. * fix(#3348): register verify-context-drift in the check subcommand router The drift capability's new plan:pre gate declares check.query "verify.context-drift", which normalizes to `check verify-context-drift`, but no such subcommand was routed — phase6-capstone-conformance's uniform-block-field test failed with "Unknown check subcommand" for every declared gate query. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3348): extend #1592's exact-key-list snapshot for the new context-drift config keys tests/capability-registry.test.cjs asserted an exact, hardcoded snapshot of the drift capability's config keys. #3348 legitimately adds two new keys (workflow.context_drift_precheck, workflow.context_drift_action) for its own plan:pre context-drift gate — extend the expected set (and clarify the assertion message) without weakening the test's exactness. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3348): reconcile E2's exemption-migration pin with the resolvePhaseDirByToken extraction #3348 (an earlier commit on this branch, e4b80ad81) extracted cmdVerifySchemaDrift's inline phasesDir readdirSync/matchPhaseDirs block into the shared resolvePhaseDirByToken helper (also used by the new cmdVerifyContextDrift), and retargeted lint-phase-enumeration-drift.cjs's function-scoped exemption from cmdVerifySchemaDrift to resolvePhaseDirByToken accordingly — cmdVerifySchemaDrift no longer contains a line the guard's detectors match, so it needs no exemption. tests/phase-locator.test.cjs's E2 test still pinned the exemption to the old name (cmdVerifySchemaDrift), unaware of the migration. Update E2 to match the same "migrated call site's exemption must move, not duplicate" pattern the test already applies to cmdRoadmapAnalyze and cmdInitMilestoneOp just below it: drop cmdVerifySchemaDrift from the still-exempt list and add symmetric assertions that it no longer carries the exemption while resolvePhaseDirByToken now does. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3348): fix two self-contradicting/nondeterministic tests in context-drift.test.cjs 'always exits 0 (query command contract)' included the no-phase-arg case, which contradicts the file's own earlier 'errors with usage message on missing phase arg' test (that case legitimately exits 1 via the Usage error) — drop it from the always-exits-0 cases. 'degrades to mtime comparison outside a git repo' and '...in a repo with no commits' relied on real wall-clock ordering between two back-to-back writeFileSync calls to prove CONTEXT.md is newer than RESEARCH.md; on a fast filesystem both can land in the same mtime tick, producing a tie that computeContextDrift's strict `<` correctly treats as not-stale, so stale_artifacts comes back empty. Make both tests deterministic via explicit fs.utimesSync instead of relying on timing (CONTRIBUTING.md: never assert elapsed wall-clock time). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3348): add context_drift_precheck:false to the plan:pre all-off fixture The "all plan:pre when-keys false" fixture explicitly disables every known workflow.* plan:pre toggle, but didn't yet know about the new workflow.context_drift_precheck key (defaults to true), so the new drift context-drift gate stayed active and broke the empty-activeHooks assertion. Emitted-Drift-Ack-Growth: plan-phase.md — adds the #3348 context-drift plan:pre pre-check section (new ## 4.6); this PR's own diff, not incidental drift. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3348): backfill changeset PR number (pr:0 -> 4147) --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/mellow-moles-run.md
Normal file
5
.changeset/mellow-moles-run.md
Normal file
@@ -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)
|
||||||
@@ -42,6 +42,20 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"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."
|
"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": [],
|
"steps": [],
|
||||||
@@ -73,6 +87,15 @@
|
|||||||
"when": "workflow.plan_drift_precheck",
|
"when": "workflow.plan_drift_precheck",
|
||||||
"blocking": false,
|
"blocking": false,
|
||||||
"onError": "skip"
|
"onError": "skip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"point": "plan:pre",
|
||||||
|
"check": {
|
||||||
|
"query": "verify.context-drift"
|
||||||
|
},
|
||||||
|
"when": "workflow.context_drift_precheck",
|
||||||
|
"blocking": false,
|
||||||
|
"onError": "skip"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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.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.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.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 |
|
| `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 |
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,7 @@
|
|||||||
- [Machine-Readable State Contract (`.planning/state.json`)](#166-machine-readable-state-contract-planningstatejson)
|
- [Machine-Readable State Contract (`.planning/state.json`)](#166-machine-readable-state-contract-planningstatejson)
|
||||||
- [Stated Failing Direction](#167-stated-failing-direction)
|
- [Stated Failing Direction](#167-stated-failing-direction)
|
||||||
- [Runtime Identity](#168-runtime-identity)
|
- [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)
|
- ["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)
|
- [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)
|
- [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
|
### 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
|
**Purpose:** ADR-3473 §8.4 states the rule directly: absence, emptiness, and
|
||||||
|
|||||||
12
docs/features/context-drift-gate.md
Normal file
12
docs/features/context-drift-gate.md
Normal file
@@ -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`).
|
||||||
@@ -1554,6 +1554,20 @@ const capabilities = {
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"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."
|
"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": [],
|
"steps": [],
|
||||||
@@ -1585,6 +1599,15 @@ const capabilities = {
|
|||||||
"when": "workflow.plan_drift_precheck",
|
"when": "workflow.plan_drift_precheck",
|
||||||
"blocking": false,
|
"blocking": false,
|
||||||
"onError": "skip"
|
"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,
|
"blocking": false,
|
||||||
"onError": "skip"
|
"onError": "skip"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"capId": "drift",
|
||||||
|
"point": "plan:pre",
|
||||||
|
"check": {
|
||||||
|
"query": "verify.context-drift"
|
||||||
|
},
|
||||||
|
"when": "workflow.context_drift_precheck",
|
||||||
|
"blocking": false,
|
||||||
|
"onError": "skip"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"capId": "ui",
|
"capId": "ui",
|
||||||
"point": "plan:pre",
|
"point": "plan:pre",
|
||||||
@@ -4805,6 +4838,8 @@ const configKeys = {
|
|||||||
"workflow.drift_action": "drift",
|
"workflow.drift_action": "drift",
|
||||||
"workflow.schema_drift_gate": "drift",
|
"workflow.schema_drift_gate": "drift",
|
||||||
"workflow.plan_drift_precheck": "drift",
|
"workflow.plan_drift_precheck": "drift",
|
||||||
|
"workflow.context_drift_precheck": "drift",
|
||||||
|
"workflow.context_drift_action": "drift",
|
||||||
"external_job.enabled": "external-job",
|
"external_job.enabled": "external-job",
|
||||||
"external_job.backend": "external-job",
|
"external_job.backend": "external-job",
|
||||||
"external_job.artifact_dir": "external-job",
|
"external_job.artifact_dir": "external-job",
|
||||||
@@ -5023,6 +5058,22 @@ const configSchema = {
|
|||||||
"default": true,
|
"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."
|
"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": {
|
"external_job.enabled": {
|
||||||
"owner": "external-job",
|
"owner": "external-job",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|||||||
@@ -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.
|
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
|
## 5. Handle Research
|
||||||
|
|
||||||
**Skip if:** `--gaps` flag or `--skip-research` flag or `--reviews` flag.
|
**Skip if:** `--gaps` flag or `--skip-research` flag or `--reviews` flag.
|
||||||
|
|||||||
@@ -84,9 +84,13 @@
|
|||||||
* shape as `collectDiskPhases` and the `audit.cts` scanners; it must see
|
* shape as `collectDiskPhases` and the `audit.cts` scanners; it must see
|
||||||
* every phase directory regardless of milestone window to catch a
|
* every phase directory regardless of milestone window to catch a
|
||||||
* naming/duplicate defect wherever it lives.
|
* 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
|
* `phase` argument to its directory (falling back to an exact-name
|
||||||
* match) — a single-phase LOOKUP, not a current-milestone enumeration.
|
* 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
|
* - `src/init.cts` `detectHasPriorPhases`: answers "has this project EVER
|
||||||
* completed a phase", explicitly excluding the current one. A history
|
* completed a phase", explicitly excluding the current one. A history
|
||||||
* probe across all milestones, not a current-milestone enumeration.
|
* 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.
|
// `lint-milestone-window-drift.cjs`'s FUNCTION_SCOPED_EXEMPTIONS mechanism.
|
||||||
// See the header comment for the full written reason behind each entry.
|
// See the header comment for the full written reason behind each entry.
|
||||||
const FUNCTION_SCOPED_EXEMPTIONS = new Map([
|
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', 'init.cts'), new Set(['detectHasPriorPhases', 'detectUiPhaseActive'])],
|
||||||
[path.join('src', 'milestone.cts'), new Set(['archivePhaseDirectories', 'cmdMilestoneComplete', 'cmdPhasesClear'])],
|
[path.join('src', 'milestone.cts'), new Set(['archivePhaseDirectories', 'cmdMilestoneComplete', 'cmdPhasesClear'])],
|
||||||
// #3849: collectSiblingWorktreePhaseNums reads a SIBLING worktree's phases dir —
|
// #3849: collectSiblingWorktreePhaseNums reads a SIBLING worktree's phases dir —
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { checkUiPresence } from './ui-safety-gate.cjs';
|
|||||||
import { hasStaticFrontendEvidence } from './ui-frontend-evidence.cjs';
|
import { hasStaticFrontendEvidence } from './ui-frontend-evidence.cjs';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import verifyModule = require('./verify.cjs');
|
import verifyModule = require('./verify.cjs');
|
||||||
const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift } = verifyModule;
|
const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift, cmdVerifyContextDrift } = verifyModule;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import roadmapModule = require('./roadmap.cjs');
|
import roadmapModule = require('./roadmap.cjs');
|
||||||
const { getRoadmapPhaseWithFallback } = roadmapModule;
|
const { getRoadmapPhaseWithFallback } = roadmapModule;
|
||||||
@@ -1720,6 +1720,13 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
|
|||||||
cmdVerifyCodebaseDrift(cwd, raw);
|
cmdVerifyCodebaseDrift(cwd, raw);
|
||||||
return;
|
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') {
|
if (subcommand === 'predicate') {
|
||||||
// Generic gate-predicate evaluator (#2008). The workflow gate-dispatch calls
|
// Generic gate-predicate evaluator (#2008). The workflow gate-dispatch calls
|
||||||
// this for any gate whose `check` carries a `predicate` (instead of a `query`),
|
// 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);
|
routeProhibitionEnforcement(args, raw);
|
||||||
return;
|
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 = {
|
export = {
|
||||||
|
|||||||
@@ -260,6 +260,14 @@ export const VERIFY_COMMAND_ALIASES: CommandAlias[] = [
|
|||||||
],
|
],
|
||||||
"subcommand": "codebase-drift",
|
"subcommand": "codebase-drift",
|
||||||
"mutation": false
|
"mutation": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"canonical": "verify.context-drift",
|
||||||
|
"aliases": [
|
||||||
|
"verify context-drift"
|
||||||
|
],
|
||||||
|
"subcommand": "context-drift",
|
||||||
|
"mutation": false
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// #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
|
// 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.
|
// 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) {
|
if (commitResult.exitCode !== 0) {
|
||||||
// #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
|
// #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
|
||||||
// partial stderr it flushed (often incidental CRLF warnings) is noise,
|
// partial stderr it flushed (often incidental CRLF warnings) is noise,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface VerifyModule {
|
|||||||
cmdVerifyKeyLinks(cwd: string, phase: string | undefined, raw: boolean): void;
|
cmdVerifyKeyLinks(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||||
cmdVerifySchemaDrift(cwd: string, phase: string | undefined, skip: boolean, raw: boolean): void;
|
cmdVerifySchemaDrift(cwd: string, phase: string | undefined, skip: boolean, raw: boolean): void;
|
||||||
cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void;
|
cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void;
|
||||||
|
cmdVerifyContextDrift(cwd: string, phase: string | undefined, raw: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RouteVerifyCommandOptions {
|
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
|
// per ADR/PRD 3524 §3 / L160 (CJS-only by design). Routing through
|
||||||
// recursive dispatch would re-enter this router path.
|
// recursive dispatch would re-enter this router path.
|
||||||
'codebase-drift': () => verify.cmdVerifyCodebaseDrift(cwd, raw),
|
'codebase-drift': () => verify.cmdVerifyCodebaseDrift(cwd, raw),
|
||||||
|
'context-drift': () => verify.cmdVerifyContextDrift(cwd, args[2], raw),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/verify.cts
161
src/verify.cts
@@ -20,6 +20,8 @@ import stateMod = require('./state.cjs');
|
|||||||
import modelProfilesMod = require('./model-profiles.cjs');
|
import modelProfilesMod = require('./model-profiles.cjs');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
||||||
import planScanMod = require('./plan-scan.cjs');
|
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
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
||||||
import coreUtilsMod = require('./core-utils.cjs');
|
import coreUtilsMod = require('./core-utils.cjs');
|
||||||
const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod;
|
const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod;
|
||||||
@@ -56,6 +58,7 @@ import planningSnapshotMod = require('./planning-snapshot.cjs');
|
|||||||
const { buildPlanningSnapshot } = planningSnapshotMod;
|
const { buildPlanningSnapshot } = planningSnapshotMod;
|
||||||
|
|
||||||
const { planningDir } = planningWorkspace;
|
const { planningDir } = planningWorkspace;
|
||||||
|
const { defaultPhaseCleanCommitTimesMs } = verificationMod;
|
||||||
const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
|
const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
|
||||||
const { readStateHeadFreshness } = stateMod;
|
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 <phase>');
|
||||||
|
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<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
const rawCfg = JSON.parse(fs.readFileSync(path.join(pDir, 'config.json'), 'utf-8')) as Record<string, unknown>;
|
||||||
|
wf = rawCfg['workflow'] as Record<string, unknown> | 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(
|
function cmdVerifySchemaDrift(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
phaseArg: string,
|
phaseArg: string,
|
||||||
@@ -1769,17 +1916,7 @@ function cmdVerifySchemaDrift(
|
|||||||
// matching "11-expansion"), making the drift gate inspect the wrong phase.
|
// matching "11-expansion"), making the drift gate inspect the wrong phase.
|
||||||
// This shares the one selection rule with find-phase / verify
|
// This shares the one selection rule with find-phase / verify
|
||||||
// phase-completeness rather than restating it. (#1571, #2528)
|
// phase-completeness rather than restating it. (#1571, #2528)
|
||||||
let phaseDir: string | null = null;
|
const phaseDir = resolvePhaseDirByToken(phasesDir, phaseArg);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!phaseDir) {
|
if (!phaseDir) {
|
||||||
output(
|
output(
|
||||||
@@ -2000,5 +2137,7 @@ export = {
|
|||||||
cmdValidateAgents,
|
cmdValidateAgents,
|
||||||
cmdVerifySchemaDrift,
|
cmdVerifySchemaDrift,
|
||||||
cmdVerifyCodebaseDrift,
|
cmdVerifyCodebaseDrift,
|
||||||
|
computeContextDrift,
|
||||||
|
cmdVerifyContextDrift,
|
||||||
STATE_HEAD_ADVISORY_COMMITS,
|
STATE_HEAD_ADVISORY_COMMITS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7178,12 +7178,18 @@ describe('#1592 — drift plan:pre codebase-drift gate (registry, behavioral)',
|
|||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
keys,
|
keys,
|
||||||
[
|
[
|
||||||
|
'workflow.context_drift_action',
|
||||||
|
'workflow.context_drift_precheck',
|
||||||
'workflow.drift_action',
|
'workflow.drift_action',
|
||||||
'workflow.drift_threshold',
|
'workflow.drift_threshold',
|
||||||
'workflow.plan_drift_precheck',
|
'workflow.plan_drift_precheck',
|
||||||
'workflow.schema_drift_gate',
|
'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)',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
375
tests/context-drift.test.cjs
Normal file
375
tests/context-drift.test.cjs
Normal file
@@ -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 <phase>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 <phase>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1776,7 +1776,6 @@ describe('migrated exemptions behave identically (#3882 rows E1/E2)', () => {
|
|||||||
[path.join('src', 'milestone.cts'), 'archivePhaseDirectories'],
|
[path.join('src', 'milestone.cts'), 'archivePhaseDirectories'],
|
||||||
[path.join('src', 'milestone.cts'), 'cmdPhasesClear'],
|
[path.join('src', 'milestone.cts'), 'cmdPhasesClear'],
|
||||||
[path.join('src', 'verify.cts'), 'cmdValidateHealth'],
|
[path.join('src', 'verify.cts'), 'cmdValidateHealth'],
|
||||||
[path.join('src', 'verify.cts'), 'cmdVerifySchemaDrift'],
|
|
||||||
[path.join('src', 'init.cts'), 'detectHasPriorPhases'],
|
[path.join('src', 'init.cts'), 'detectHasPriorPhases'],
|
||||||
[path.join('src', 'init.cts'), 'detectUiPhaseActive'],
|
[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'));
|
const initExempt = driftGuard.FUNCTION_SCOPED_EXEMPTIONS.get(path.join('src', 'init.cts'));
|
||||||
assert.ok(!initExempt || !initExempt.has('cmdInitMilestoneOp'),
|
assert.ok(!initExempt || !initExempt.has('cmdInitMilestoneOp'),
|
||||||
'cmdInitMilestoneOp must no longer carry an exemption — its diskPhaseDirs lookup no longer hand-rolls a readdirSync');
|
'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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ describe('plan:pre all-off — empty resolution', () => {
|
|||||||
pattern_mapper: false,
|
pattern_mapper: false,
|
||||||
schema_push_detection: false,
|
schema_push_detection: false,
|
||||||
plan_drift_precheck: false,
|
plan_drift_precheck: false,
|
||||||
|
context_drift_precheck: false,
|
||||||
assumption_delta: false,
|
assumption_delta: false,
|
||||||
},
|
},
|
||||||
intel: { enabled: false },
|
intel: { enabled: false },
|
||||||
|
|||||||
Reference in New Issue
Block a user