diff --git a/.changeset/wise-jaguars-cheer.md b/.changeset/wise-jaguars-cheer.md new file mode 100644 index 000000000..9ee3202cb --- /dev/null +++ b/.changeset/wise-jaguars-cheer.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3629 +--- +**Executor dispatch no longer blocks on a plugin-marketplace install** — the compiled runtime library is a build artifact produced at publish time and gitignored, so a plugin or git-clone install materializes a tree that never has it. Every hook that required one of those modules did so without the existing self-heal build seam that the CLI entrypoint already calls, so the agent-isolation guard's missing-module error landed in its fail-closed catch and was reported as `could not read or resolve dispatch-isolation configuration` — blocking every `gsd-executor` dispatch from the first dispatch of a session, while the statusline and update-check worker crashed at module load on the same tree. All seven affected hook files now self-heal first: the isolation guards surface the build seam's own actionable error instead of a misleading config message and stay fail-closed, and the cosmetic hooks degrade quietly rather than taking down the prompt. The guards also now emit a machine-readable `reason_code` alongside the human message. Installs from npm are unaffected — the seam's already-built fast path returns immediately. (#3582) diff --git a/hooks/gsd-agent-isolation-guard.js b/hooks/gsd-agent-isolation-guard.js index 2cc715806..ba5b21ab1 100644 --- a/hooks/gsd-agent-isolation-guard.js +++ b/hooks/gsd-agent-isolation-guard.js @@ -64,6 +64,15 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js'); +const { REASON_CODE } = require('./lib/isolation-deny-reason.js'); +// #3582: gsd-core/bin/lib/*.cjs (runtime-name-policy.cjs, capability-registry.cjs +// below) are tsc build artifacts (ADR-457), gitignored and absent on a raw +// plugin-marketplace / git-clone install that never ran `npm run build:lib`. +// Self-heal before the first such require (resolveRegistryIsolation, below) — +// see ensureRuntimeBuild's own header for the full rationale. This module +// itself (gsd-core/bin/ensure-runtime-build.cjs) depends on nothing under +// ./lib, so requiring it here is always safe. +const { ensureRuntimeBuild, RuntimeBuildError } = require('../gsd-core/bin/ensure-runtime-build.cjs'); // No other executor-shaped subagent_type exists in agents/ today // (verified: only agents/gsd-executor.md). A Set, not a bare string compare, @@ -239,6 +248,15 @@ function resolveHarnessFlag(runtimeId, runtimes) { * run, e.g. a manual Agent() call before any sentinel has been written). */ function resolveRegistryIsolation(cwd, configPath) { + // #3582: self-heal the compiled runtime library BEFORE either require + // below — this is the only reaching path to both (resolveRegistryIsolation + // is the sole caller of each), so one call here covers both. Throws + // RuntimeBuildError on an unbuildable tree; the caller (resolveIsolationState) + // already wraps this whole function in try/catch and folds any error into + // its fail-closed `error` result — evaluateDispatch below distinguishes a + // RuntimeBuildError there so it surfaces this seam's actionable message + // instead of being misreported as an unreadable config.json (#3050 lesson). + ensureRuntimeBuild(); const { resolveRuntimeNameFromCandidates } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); @@ -416,13 +434,27 @@ function evaluateDispatch(data, { clock = Date } = {}) { if (!state.gsdProject) return { action: 'allow' }; if (state.error) { - const reason = - `Agent isolation guard: could not read or resolve this project's dispatch-isolation ` + - `configuration ('.planning/config.json' under '${cwd}'). Refusing to dispatch ` + - `subagent_type="${subagentType}" without being able to verify whether isolation is ` + - `required — a guard that cannot verify must not answer "safe" (#3050). Retry once the ` + - `project configuration is readable.`; - return { action: 'block', reason }; + // #3582: a missing/unbuildable compiled runtime library (RuntimeBuildError, + // thrown by ensureRuntimeBuild in resolveRegistryIsolation) is a DIFFERENT, + // actionable failure from an unreadable/unparsable config.json — surface + // its own message instead of misreporting it as the generic + // "could not read or resolve ... configuration" text (the exact #3050 + // misreport this issue exists to fix). Both cases still fail closed + // (block); only the message differs. + const isBuildFailure = state.error instanceof RuntimeBuildError; + const reason = isBuildFailure + ? `Agent isolation guard: cannot resolve this project's dispatch-isolation ` + + `configuration because the GSD runtime library failed to self-build. ` + + `${state.error.message} Refusing to dispatch subagent_type="${subagentType}" until ` + + `the runtime library is built — a guard that cannot verify must not answer "safe" ` + + `(#3050).` + : `Agent isolation guard: could not read or resolve this project's dispatch-isolation ` + + `configuration ('.planning/config.json' under '${cwd}'). Refusing to dispatch ` + + `subagent_type="${subagentType}" without being able to verify whether isolation is ` + + `required — a guard that cannot verify must not answer "safe" (#3050). Retry once the ` + + `project configuration is readable.`; + const reasonCode = isBuildFailure ? REASON_CODE.RUNTIME_BUILD_FAILED : REASON_CODE.CONFIG_UNREADABLE; + return { action: 'block', reason, reasonCode }; } if (state.isolation !== 'harness-worktree') return { action: 'allow' }; @@ -438,7 +470,7 @@ function evaluateDispatch(data, { clock = Date } = {}) { `${parsed.param}="${parsed.value}". Add ${parsed.param}="${parsed.value}" to the Agent() ` + `call so the executor runs in an isolated worktree instead of the primary checkout ` + `(gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md).`; - return { action: 'block', reason }; + return { action: 'block', reason, reasonCode: REASON_CODE.HARNESS_FLAG_MISSING }; } /* istanbul ignore next -- stdin adapter, exercised via spawnSync in tests */ @@ -453,7 +485,7 @@ function main() { const data = JSON.parse(input); const decision = evaluateDispatch(data); if (decision.action === 'block') { - const out = { decision: 'block', reason: decision.reason }; + const out = { decision: 'block', reason: decision.reason, reason_code: decision.reasonCode }; process.stdout.write(JSON.stringify(out)); // Kimi feeds stderr (not stdout) back to the model on exit 2. process.stderr.write(decision.reason); diff --git a/hooks/gsd-check-update-worker.js b/hooks/gsd-check-update-worker.js index 41e2bb022..d6c1871a6 100644 --- a/hooks/gsd-check-update-worker.js +++ b/hooks/gsd-check-update-worker.js @@ -11,18 +11,61 @@ const fs = require('fs'); const path = require('path'); -const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs'); -// Latest-version lookup is delegated to the single deterministic adapter -// (#498). checkLatestVersion() owns the npm-view call, the timeout/semver -// policy, and the package name — sourced from the baked Package Identity seam. -// The previous `require('../package.json').name` (#378) never yielded a name in -// the installed tree — at the time it resolved to the synthetic -// {"type":"commonjs"} marker GSD wrote at the config root, which has no `.name`, -// so the background check never reported updates. Since #2544 GSD writes no -// marker there at all, so that require would now fail to resolve outright. -// Either way the name must come from the baked seam, never a walk-up. -const { checkLatestVersion } = require('../gsd-core/bin/check-latest-version.cjs'); -const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs'); + +// #3582: gsd-core/bin/lib/semver-compare.cjs and package-identity.cjs (and, +// transitively, check-latest-version.cjs's own gsd-core/bin/lib/cli-exit.cjs +// + shell-command-projection.cjs) are tsc build artifacts (ADR-457), +// gitignored and absent on a raw plugin-marketplace / git-clone install that +// never ran `npm run build:lib`. This worker is a DETACHED SessionStart +// background process (spawned with stdio: 'ignore') — a build failure here +// must DEGRADE to the no-signal fallbacks below (mirroring the +// managed-hooks-registry.cjs degrade just below) so the worker still runs to +// completion and writes a result cache record, rather than dying silently +// with no visible signal and no cache-file write at all. +// +// This try/require/ensureRuntimeBuild/require/catch shape repeats (with +// different destructured names) in hooks/gsd-check-update.js and +// hooks/gsd-update-banner.js. It is deliberately NOT extracted into a shared +// hooks/lib/ helper: scripts/lint-hooks-runtime-build-seam.cjs enforces this +// exact seam textually, PER FILE — it greps each hooks/ file for its OWN +// literal `require('.../ensure-runtime-build.cjs')` + `ensureRuntimeBuild(` +// call co-occurring with its OWN literal `require('.../gsd-core/bin/lib/*.cjs')`. +// A generic helper taking the compiled module's path as a variable would move +// the literal compiled-lib require OUT of this file and into the helper, +// called with a non-literal argument — the scan's regex (see that script's +// "Known limitations") cannot see a require() called with a variable, so this +// file would then read as "requires nothing" and the lint would stop +// protecting it. A ceremony-only helper (just the ensureRuntimeBuild call, +// each caller keeping its own literal compiled-lib require) fails the SAME +// way from the other side: it would remove this file's own literal +// `require('.../ensure-runtime-build.cjs')` + `ensureRuntimeBuild(` call, +// which the lint also requires to be textually present in THIS file. Either +// shape needs the lint script itself widened to special-case the helper, +// which is a bigger, riskier change than the ~6 duplicated lines it would +// save; kept inline instead. +let isSemverNewer = () => false; +let checkLatestVersion = () => ({ ok: false }); +let PACKAGE_NAME = null; +try { + const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs'); + ensureRuntimeBuild(); + ({ isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs')); + // Latest-version lookup is delegated to the single deterministic adapter + // (#498). checkLatestVersion() owns the npm-view call, the timeout/semver + // policy, and the package name — sourced from the baked Package Identity seam. + // The previous `require('../package.json').name` (#378) never yielded a name in + // the installed tree — at the time it resolved to the synthetic + // {"type":"commonjs"} marker GSD wrote at the config root, which has no `.name`, + // so the background check never reported updates. Since #2544 GSD writes no + // marker there at all, so that require would now fail to resolve outright. + // Either way the name must come from the baked seam, never a walk-up. + ({ checkLatestVersion } = require('../gsd-core/bin/check-latest-version.cjs')); + ({ PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs')); +} catch (e) { + // Runtime library missing/broken and could not self-build — degrade to the + // no-signal fallbacks declared above; the worker still writes a result + // cache record (package_name: null, update_available: false). +} // Authoritative list of managed hooks — shared with tests to retire source-grep // assertions (pending-migration-to-typed-ir [#455]). // NOTE: managed-hooks-registry.cjs must be in HOOKS_TO_COPY (scripts/build-hooks.js) diff --git a/hooks/gsd-check-update.js b/hooks/gsd-check-update.js index cc54ffa11..6b983c46e 100755 --- a/hooks/gsd-check-update.js +++ b/hooks/gsd-check-update.js @@ -8,7 +8,25 @@ const path = require('path'); const os = require('os'); const { spawn } = require('child_process'); -const { updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); +// #3582: gsd-core/bin/lib/package-identity.cjs is a tsc build artifact +// (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone +// install that never ran `npm run build:lib`. This SessionStart hook must +// DEGRADE (fall back to a generic cache filename) rather than crash session +// start. gsd-check-update-worker.js — the process this hook spawns — degrades +// identically and independently, so the shared fallback literal keeps the +// cache path consistent between writer and reader even in the (rare) +// doubly-degraded case. This try/require/ensureRuntimeBuild/require/catch +// shape is deliberately duplicated (not extracted to hooks/lib/) — see +// gsd-check-update-worker.js's identical #3582 comment for why. +let updateCacheFileName = 'gsd-update-check.json'; +try { + const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs'); + ensureRuntimeBuild(); + ({ updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs')); +} catch (e) { + // Runtime library missing/broken and could not self-build — degrade to the + // fallback filename above rather than crash the SessionStart hook. +} const homeDir = os.homedir(); const cwd = process.cwd(); diff --git a/hooks/gsd-cursor-subagent-start.js b/hooks/gsd-cursor-subagent-start.js index f8ae6d010..9c564b4d3 100644 --- a/hooks/gsd-cursor-subagent-start.js +++ b/hooks/gsd-cursor-subagent-start.js @@ -62,6 +62,15 @@ const os = require('os'); // writeCursorHooksJson so the require always resolves post-install. const { resolveStatePath } = require('./lib/cursor-workspace.js'); const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js'); +const { REASON_CODE } = require('./lib/isolation-deny-reason.js'); +// #3582: gsd-core/bin/lib/*.cjs (runtime-homes.cjs, worktree-safety.cjs, +// runtime-name-policy.cjs, capability-registry.cjs — required below, inside +// resolveIsolationEvidence and resolveFallbackIsolation) are tsc build +// artifacts (ADR-457), gitignored and absent on a raw plugin-marketplace / +// git-clone install that never ran `npm run build:lib`. Self-heal once, in +// evaluateRootIsolation, before any of those four requires run — see the +// call site below. This module itself depends on nothing under ./lib. +const { ensureRuntimeBuild, RuntimeBuildError } = require('../gsd-core/bin/ensure-runtime-build.cjs'); const MSG_PRESENT = 'GSD: Subagent session started — review .planning/STATE.md for the current phase and any blockers before acting.'; @@ -458,6 +467,28 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds = } if (!isGsdProject) return { action: 'allow' }; + // #3582: self-heal the compiled runtime library BEFORE any of its four + // downstream requires (resolveFallbackIsolation's two, resolveIsolationEvidence's + // two — reached only below this point). Checked separately from the + // sentinel/fallback try block below so a build failure surfaces its own + // actionable RuntimeBuildError message rather than being folded into the + // generic "could not read or resolve ... configuration" deny reason (the + // #3050 misreport this issue exists to fix). Still fails closed either way. + try { + ensureRuntimeBuild(); + } catch (err) { + return { + action: 'deny', + reason: + `GSD subagent isolation guard: cannot resolve this project's dispatch-isolation ` + + `configuration because the GSD runtime library failed to self-build. ` + + `${err instanceof RuntimeBuildError ? err.message : String(err && err.message || err)} ` + + `Refusing to allow this subagent to spawn until the runtime library is built — a guard ` + + `that cannot verify must not answer "safe" (#3050).`, + reasonCode: REASON_CODE.RUNTIME_BUILD_FAILED, + }; + } + let declaredIsolation; try { // #3045 BLOCKER fix: a fresh sentinel is authoritative for THIS @@ -478,6 +509,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds = `Refusing to allow this subagent to spawn without being able to verify whether ` + `isolation is required — a guard that cannot verify must not answer "safe" (#3050). ` + `Retry once the project configuration is readable.`, + reasonCode: REASON_CODE.CONFIG_UNREADABLE, }; } @@ -494,6 +526,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds = `"harness-worktree", but the subagentStart payload for this dispatch carries no usable ` + `subagent_type. Refusing to allow it to spawn without being able to confirm whether it ` + `is a GSD executor — a guard that cannot verify must not answer "safe" (#3050).`, + reasonCode: REASON_CODE.NO_SUBAGENT_TYPE, }; } @@ -510,6 +543,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds = `could not be determined (git did not respond). Refusing to allow subagent_type=` + `"${subagentType}" to spawn without being able to verify isolation — a guard that ` + `cannot verify must not answer "safe" (#3050). Retry once git is responsive.`, + reasonCode: REASON_CODE.CANNOT_DETERMINE_ISOLATION, }; } @@ -522,6 +556,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds = `directly, with no consent and no warning. Start an isolated session first (the ` + `"--worktree" CLI flag or the "/worktree" chat command; Cursor manages these worktrees ` + `under "~/.cursor/worktrees/") and retry.`, + reasonCode: REASON_CODE.NOT_ISOLATED_WORKTREE, }; } @@ -571,7 +606,7 @@ function main() { decision = { action: 'allow' }; } if (decision.action === 'deny') { - const out = { permission: 'deny', user_message: decision.reason }; + const out = { permission: 'deny', user_message: decision.reason, reason_code: decision.reasonCode }; if (additionalContext !== null) out.additional_context = additionalContext; process.stdout.write(JSON.stringify(out)); return; diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index bcd0f6a72..16e785bcd 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -9,6 +9,24 @@ const os = require('os'); // Namespace (not destructured) so tests can inject spawn failures by // monkeypatching childProcess.execFileSync. const childProcess = require('child_process'); +// #3582: gsd-core/bin/lib/*.cjs (semver-compare.cjs, state-document.cjs, +// active-workstream-store.cjs, planning-workspace.cjs — required below) and +// package-identity.cjs are tsc build artifacts (ADR-457), gitignored and +// absent on a raw plugin-marketplace / git-clone install that never ran +// `npm run build:lib`. The statusline renders on EVERY prompt, so a build +// failure here must DEGRADE (print nothing, exit 0) rather than crash +// Claude Code's per-render statusline hook. Scoped to the spawned-as-a-script +// path (`require.main === module`) — a test `require()` of this module for +// its pure helpers assumes a built tree, same as every other hook test. +if (require.main === module) { + try { + const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs'); + ensureRuntimeBuild(); + } catch (e) { + process.stdout.write(''); + process.exit(0); + } +} const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs'); const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs'); diff --git a/hooks/gsd-update-banner.js b/hooks/gsd-update-banner.js index f69068eec..593b1058e 100755 --- a/hooks/gsd-update-banner.js +++ b/hooks/gsd-update-banner.js @@ -15,7 +15,28 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); + +// #3582: gsd-core/bin/lib/package-identity.cjs is a tsc build artifact +// (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone +// install that never ran `npm run build:lib`. This is an opt-in SessionStart +// hook — a build failure here must DEGRADE, not crash session start. With +// PACKAGE_NAME left null, buildBannerOutput's own lineage guard +// (`!cache.package_name || cache.package_name !== PACKAGE_NAME`) always +// treats the cache as untrusted, so main() falls through to its existing +// silent "print nothing" path below — no separate degrade branch needed. +// This try/require/ensureRuntimeBuild/require/catch shape is deliberately +// duplicated (not extracted to hooks/lib/) — see +// gsd-check-update-worker.js's identical #3582 comment for why. +let PACKAGE_NAME = null; +let updateCacheFileName = 'gsd-update-check.json'; +try { + const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs'); + ensureRuntimeBuild(); + ({ PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs')); +} catch (e) { + // Runtime library missing/broken and could not self-build — degrade to the + // fallbacks above rather than crash the SessionStart hook. +} // Suppress repeat parse-error banners for 24 hours so a genuinely broken // cache file doesn't nag the user every session. diff --git a/hooks/lib/isolation-deny-reason.js b/hooks/lib/isolation-deny-reason.js new file mode 100644 index 000000000..64972b8e1 --- /dev/null +++ b/hooks/lib/isolation-deny-reason.js @@ -0,0 +1,39 @@ +'use strict'; +// hooks/lib/isolation-deny-reason.js — shared, frozen reason-code enum for +// the #3045 dispatch-isolation guards' block/deny decisions +// (hooks/gsd-agent-isolation-guard.js, hooks/gsd-cursor-subagent-start.js). +// +// CONTRIBUTING.md ("Prohibited: Raw Text Matching on Test Outputs") bans +// asserting on a hook's free-form, human-readable reason/user_message prose +// — that text is for the operator/model reading the denial and may change +// wording without notice. Every block/deny decision therefore ALSO carries +// one of these STABLE codes (surfaced on the hook's stdout JSON as +// `reason_code`), so tests assert `out.reason_code === REASON_CODE.X` +// instead of regexing the message (mirrors the REASON enum convention in +// gsd-core/bin/verify-reapply-patches.cjs). +// +// Adding a new code requires updating this enum AND any test that locks the +// documented set. +const REASON_CODE = Object.freeze({ + // The compiled runtime library (gsd-core/bin/lib/*.cjs) is missing and + // could not be self-built (ensure-runtime-build.cjs's RuntimeBuildError). + RUNTIME_BUILD_FAILED: 'runtime_build_failed', + // The project's dispatch-isolation configuration ('.planning/config.json') + // could not be read or resolved for a reason OTHER than a runtime-build + // failure (unreadable/malformed config, unexpected resolver error). + CONFIG_UNREADABLE: 'config_unreadable', + // Isolation resolves to "harness-worktree" but the Agent()/Task() dispatch + // is missing the harness's isolation flag/kwarg. + HARNESS_FLAG_MISSING: 'harness_flag_missing', + // Isolation resolves to "harness-worktree" but the dispatch payload carries + // no usable subagent_type, so the guard cannot confirm it is a GSD executor. + NO_SUBAGENT_TYPE: 'no_subagent_type', + // Isolation resolves to "harness-worktree" but whether the workspace root + // is an isolated worktree could not be determined (e.g. git unresponsive). + CANNOT_DETERMINE_ISOLATION: 'cannot_determine_isolation', + // Isolation resolves to "harness-worktree" and the workspace root is + // confirmed NOT an isolated worktree. + NOT_ISOLATED_WORKTREE: 'not_isolated_worktree', +}); + +module.exports = { REASON_CODE }; diff --git a/hooks/lib/isolation-sentinel.js b/hooks/lib/isolation-sentinel.js index 88ee26f48..d8c15341b 100644 --- a/hooks/lib/isolation-sentinel.js +++ b/hooks/lib/isolation-sentinel.js @@ -130,6 +130,15 @@ function resolveSentinelRoot(cwd) { if (fs.existsSync(path.join(cwd, '.planning'))) { return cwd; } + // #3582: worktree-safety.cjs / project-root.cjs are tsc build artifacts + // (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone + // install that never ran `npm run build:lib`. Self-heal before either + // require below; a RuntimeBuildError (or any other failure) falls through + // to the existing catch's degrade-to-raw-`cwd` — unchanged behavior, just + // now attempted-healed-first rather than silently degrading on the first + // cold-tree encounter. + const { ensureRuntimeBuild } = require('../../gsd-core/bin/ensure-runtime-build.cjs'); + ensureRuntimeBuild(); const { resolveWorktreeRoot } = require('../../gsd-core/bin/lib/worktree-safety.cjs'); const { root } = resolveWorktreeRoot(cwd); const { findProjectRoot } = require('../../gsd-core/bin/lib/project-root.cjs'); diff --git a/package.json b/package.json index cea1b1be4..d34066d9d 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/check-contract-drift.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", @@ -132,6 +132,7 @@ "lint:qa-smells": "node scripts/qa-smell-ratchet.cjs", "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs", "lint:docs-command-form": "node scripts/lint-docs-command-form.cjs", + "lint:hooks-runtime-build-seam": "node scripts/lint-hooks-runtime-build-seam.cjs", "ci:test-scope": "node scripts/ci-test-scope.cjs", "changeset": "node scripts/changeset/new.cjs", "changelog:render": "node scripts/changeset/cli.cjs render", diff --git a/scripts/lint-hooks-runtime-build-seam.cjs b/scripts/lint-hooks-runtime-build-seam.cjs new file mode 100644 index 000000000..118cdd26a --- /dev/null +++ b/scripts/lint-hooks-runtime-build-seam.cjs @@ -0,0 +1,262 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-hooks-runtime-build-seam.cjs — every `hooks/**` file that requires a + * compiled runtime-library module (`gsd-core/bin/lib/*.cjs`) must also go + * through the self-healing build seam (`gsd-core/bin/ensure-runtime-build.cjs`, + * #2002) before that require can be reached (#3582). + * + * ## Why + * + * `gsd-core/bin/lib/*.cjs` are compiled from `src/*.cts` by `npm run + * build:lib` (ADR-457, "build-at-publish") and are gitignored. The npm + * package builds before publishing, so the artifacts exist on that path. + * `gsd-core/bin/gsd-tools.cjs` calls `ensureRuntimeBuild()` before requiring + * its own `./lib` — but a plugin-marketplace / git-clone install that never + * runs `npm run build:lib` materializes the raw tree with the compiled + * `./lib` absent, and NO hook self-healed before this issue. A hook that + * requires a compiled module directly dies at module load (or first call) + * with `Cannot find module`, and — in a guard whose own catch treats "cannot + * resolve" as fail-closed — that `Cannot find module` gets misreported as an + * unrelated "could not read or resolve …configuration" denial instead of the + * seam's own actionable message (#3050 lesson: a guard that cannot verify + * must not silently misreport why). + * + * ## What this enforces + * + * For every `.js`/`.cjs` file under `hooks/` (excluding the generated + * `hooks/dist/` build-output copy, which is not source): if the file's code + * (comments stripped) contains a `require(...)` of a path that names + * `gsd-core/bin/lib/.cjs` (any relative-path spelling — `../gsd-core/…`, + * `../../gsd-core/…`, etc. — the segment `gsd-core/bin/lib/` plus a trailing + * `.cjs` is the discriminator, not the exact prefix), the SAME file must ALSO + * contain BOTH: + * 1. a `require(...)` of a path naming `gsd-core/bin/ensure-runtime-build.cjs` + * (the seam module itself), AND + * 2. an actual INVOCATION `ensureRuntimeBuild(` — not merely a destructuring + * import with no call, which would import the seam but never run it. + * + * File-level co-occurrence, not line-by-line ordering: this repo's hooks + * define helper functions in whatever order reads best and call them in a + * different order at runtime (e.g. `hooks/gsd-cursor-subagent-start.js`'s + * `resolveFallbackIsolation` is defined textually ABOVE the single + * `ensureRuntimeBuild()` call site in `evaluateRootIsolation`, which is its + * only caller) — a strict "seam call must appear on an earlier LINE than the + * require" check would false-positive on exactly that, real, correct + * pattern. This guard is therefore a drift ratchet at the file level ("did + * the seam get wired in at all"), not a full control-flow verifier; exact + * placement (does the call precede every reaching path) is a code-review + * concern, same tradeoff every other structural drift guard in this repo + * makes (see e.g. `lint-unreachable-guard-drift.cjs`'s own per-line, not + * per-flow, scope). + * + * ## What PASSES + * + * - A hook that never requires `gsd-core/bin/lib/*.cjs` at all (most of + * `hooks/`) — nothing to check. + * - A hook that requires a compiled module AND requires + calls + * `ensureRuntimeBuild()` somewhere in the same file. + * - A prose comment that mentions `gsd-core/bin/lib/…` without a real, + * well-formed `require('....cjs')` call (comments are stripped before + * scanning). + * + * ## What FAILS + * + * - A hook that requires a compiled `gsd-core/bin/lib/*.cjs` module but never + * requires `ensure-runtime-build.cjs`, or requires it but never calls + * `ensureRuntimeBuild(...)`. + * + * ## Known limitations (documented, not defects — read before trusting a green run) + * + * - **Literal-string matching only.** `REQUIRE_RE` matches `require(...)` + * called with a single- OR double-quoted string literal argument. A + * concatenated/computed path (`require(base + '/gsd-core/bin/lib/x.cjs')`), + * a template literal (`` require(`${dir}/x.cjs`) ``), or `createRequire(...)` + * / `require.resolve` used indirectly all evade `isCompiledLibRequire` and + * `isSeamRequire` — none of them appear as a literal quoted `require(...)` + * call, so a file using one of these forms scans as "requires nothing", + * even if it genuinely needs the seam. + * - **`hooks/` only.** `scanRepo`'s `walk()` only descends `SCAN_ROOT` + * (`hooks/`, minus `hooks/dist/`). A compiled `gsd-core/bin/lib/*.cjs` + * require sitting inside a NON-hooks helper module that a hook file then + * requires (directly or transitively) is invisible to this scan — the scan + * only ever reads the hook file's OWN text, never follows its require + * graph. Deliberate: this is a same-file drift ratchet ("did the hook wire + * the seam in at all"), not a whole-program static analyzer. + * + * Because of both limits, a file that passes this lint is not a + * correctness proof — it is a co-occurrence heuristic that catches the + * exact regression shape #3582 fixed (a bare literal compiled-lib require + * with no seam call anywhere in the same hook file). Anything routed around + * a literal require or around `hooks/` needs a code-review check, not this + * script, to catch a missing self-heal. + */ + +const fs = require('fs'); +const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.join(__dirname, '..'); +const SCAN_ROOT = 'hooks'; +const SCAN_EXT = new Set(['.js', '.cjs']); +// hooks/dist/ is a generated build-output copy (scripts/build-hooks.js), +// gitignored and not source — never scanned. +const EXCLUDE_DIR_NAMES = new Set(['dist']); + +// Line-based comment stripper (deliberately NOT the naive two-regex +// `/\*[\s\S]*?\*\//g` then `//` approach other tests in this repo use for +// simpler files): this module's own source comments legitimately spell +// `gsd-core/bin/lib/*.cjs` (a glob) inside a `//` line, which forms a bare +// `/*` token — a whole-text block-comment regex applied naively would read +// that as an OPENING block comment and swallow everything up to the next +// unrelated `*/` anywhere later in the file, silently deleting real code +// (verified while authoring this guard: it ate the very require() line the +// guard exists to check for). Processing line-by-line and stripping a +// trailing `//` comment BEFORE ever checking that line's remainder for `/*` +// means a `/*`-shaped token inside a `//` comment is never seen at all. +function stripComments(text) { + const lines = text.split(/\r?\n/); + const out = []; + let inBlock = false; + for (const raw of lines) { + let line = raw; + if (inBlock) { + const end = line.indexOf('*/'); + if (end === -1) { out.push(''); continue; } + line = line.slice(end + 2); + inBlock = false; + } + if (line.trim().startsWith('//')) { out.push(''); continue; } + // Strip a trailing `//` comment (not a `://` URL) BEFORE any `/*` check + // on the remainder — see the function header for why this ordering + // matters. + const trailing = /(^|[^:])\/\/.*$/.exec(line); + if (trailing) line = line.slice(0, trailing.index + trailing[1].length); + // Same-line and newly-opened block comments in what remains. + for (;;) { + const start = line.indexOf('/*'); + if (start === -1) break; + const end = line.indexOf('*/', start + 2); + if (end !== -1) { + line = line.slice(0, start) + line.slice(end + 2); + // keep scanning from the same position in case of a second + // same-line block comment + } else { + line = line.slice(0, start); + inBlock = true; + break; + } + } + out.push(line); + } + return out.join('\n'); +} + +// Captures the quoted path of every require(...) call. Single bounded +// negated-class quantifier per alternative, no nesting. +const REQUIRE_RE = /require\(\s*(['"])([^'"]+)\1\s*\)/g; + +function isCompiledLibRequire(requirePath) { + return requirePath.includes('gsd-core/bin/lib/') && requirePath.endsWith('.cjs'); +} + +function isSeamRequire(requirePath) { + return requirePath.endsWith('gsd-core/bin/ensure-runtime-build.cjs'); +} + +// An actual invocation, not merely a `{ ensureRuntimeBuild }` destructuring +// import — the identifier immediately followed by `(`. +const SEAM_CALL_RE = /\bensureRuntimeBuild\s*\(/; + +/** + * Pure: scan one file's contents for the violation described above. + * Returns `{ compiledLibRequires: string[], hasSeamRequire: boolean, + * hasSeamCall: boolean }`. `compiledLibRequires` is `[]` when the file + * requires no compiled module — the caller treats that as "nothing to + * check" regardless of the other two fields. + */ +function scanFile(text) { + const code = stripComments(text); + const compiledLibRequires = []; + let hasSeamRequire = false; + let m; + REQUIRE_RE.lastIndex = 0; + while ((m = REQUIRE_RE.exec(code)) !== null) { + const requirePath = m[2]; + if (isCompiledLibRequire(requirePath)) compiledLibRequires.push(requirePath); + if (isSeamRequire(requirePath)) hasSeamRequire = true; + } + const hasSeamCall = SEAM_CALL_RE.test(code); + return { compiledLibRequires, hasSeamRequire, hasSeamCall }; +} + +function walk(dir) { + const out = []; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return out; // missing root is not an error here — caller decides + } + for (const entry of entries) { + if (entry.isDirectory() && EXCLUDE_DIR_NAMES.has(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walk(full)); + } else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) { + out.push(full); + } + } + return out; +} + +/** + * Scan `root`'s hooks/ tree and return every violating file. + * @param {string} root repo root (or a fixture root for tests) + * @returns {{ file: string, compiledLibRequires: string[], missing: string[] }[]} + */ +function scanRepo(root) { + const violations = []; + for (const full of walk(path.join(root, SCAN_ROOT))) { + const text = fs.readFileSync(full, 'utf8'); + const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text); + if (compiledLibRequires.length === 0) continue; + if (hasSeamRequire && hasSeamCall) continue; + const missing = []; + if (!hasSeamRequire) missing.push('require(".../gsd-core/bin/ensure-runtime-build.cjs")'); + if (!hasSeamCall) missing.push('a call to ensureRuntimeBuild(...)'); + violations.push({ + file: path.relative(root, full).split(path.sep).join('/'), + compiledLibRequires, + missing, + }); + } + return violations; +} + +function main() { + const violations = scanRepo(ROOT); + if (violations.length > 0) { + const detail = violations + .map((v) => ( + ` ${v.file}\n` + + ` requires: ${v.compiledLibRequires.join(', ')}\n` + + ` missing: ${v.missing.join(' AND ')}` + )) + .join('\n'); + throw new ExitError( + 1, + `lint-hooks-runtime-build-seam: ${violations.length} hooks/ file(s) require a compiled\n` + + `gsd-core/bin/lib/*.cjs module without also self-healing via\n` + + `ensureRuntimeBuild() from gsd-core/bin/ensure-runtime-build.cjs first (#3582) — on a\n` + + `plugin-marketplace/git-clone install that never ran \`npm run build:lib\`, that\n` + + `require crashes (or is silently misreported) instead of self-building:\n\n${detail}\n`, + ); + } + console.log('ok lint-hooks-runtime-build-seam: every hooks/ compiled-lib require goes through ensureRuntimeBuild()'); +} + +module.exports = { scanFile, scanRepo, walk, stripComments, isCompiledLibRequire, isSeamRequire, SEAM_CALL_RE }; + +if (require.main === module) runMain(main); diff --git a/tests/check-update-config-dir.test.cjs b/tests/check-update-config-dir.test.cjs index b1d2404d5..bed38ef33 100644 --- a/tests/check-update-config-dir.test.cjs +++ b/tests/check-update-config-dir.test.cjs @@ -69,13 +69,16 @@ function buildProbeSource(hookPath) { * @param {string} opts.cwd - fake cwd (project base) for this run. * @param {object} [opts.envOverrides] - applied after HOME/USERPROFILE and * after CLAUDE_CONFIG_DIR is deleted, so a row can reintroduce it. + * @param {string} [opts.hookPath] - override the hook under test (defaults to + * the real hooks/gsd-check-update.js). Used by the #3582 cold-tree suite + * below to point at a fixture copy instead. */ -function probe({ homeDir, cwd, envOverrides = {} }) { +function probe({ homeDir, cwd, envOverrides = {}, hookPath = CHECK_UPDATE_PATH }) { const childEnv = { ...process.env, HOME: homeDir, USERPROFILE: homeDir }; delete childEnv.CLAUDE_CONFIG_DIR; Object.assign(childEnv, envOverrides); - const result = runNode(['-e', buildProbeSource(CHECK_UPDATE_PATH)], { + const result = runNode(['-e', buildProbeSource(hookPath)], { cwd, env: childEnv, timeoutMs: PROBE_TIMEOUT_MS, @@ -307,3 +310,39 @@ describe('detectConfigDir runtime behavior (#1860)', () => { ); }); }); + +// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degraded cache filename ─ +// +// gsd-core/bin/lib/package-identity.cjs is a tsc build artifact (ADR-457), +// gitignored and absent on a raw plugin-marketplace / git-clone install that +// never ran `npm run build:lib`. This SessionStart hook degrades to the +// hardcoded fallback cache filename ('gsd-update-check.json') rather than +// crash session start (see the hook's own #3582 comment). The DEGRADED +// VERDICT this test locks is observable via the SAME spawn-env probe seam +// used above: the GSD_CACHE_FILE env var the hook hands to its worker must +// end with the fallback literal, not throw and not silently vanish. +// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the +// REAL gsd-core/bin/lib/ is never touched. +describe('gsd-check-update.js: #3582 cold tree — degrades to the fallback cache filename', () => { + const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs'); + + test('missing compiled runtime library -> worker still launched, with the hardcoded fallback cache filename', (t) => { + const cold = buildColdInstallTree(); + t.after(cold.cleanup); + const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-cold-home-'))); + const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-cold-project-'))); + t.after(() => { cleanup(home); cleanup(project); }); + + const result = probe({ + homeDir: home, + cwd: project, + hookPath: path.join(cold.hooksDir, 'gsd-check-update.js'), + }); + + assert.equal( + path.basename(result.cache), + 'gsd-update-check.json', + `expected the hardcoded degrade fallback cache filename when package-identity.cjs cannot be built; got: ${result.cache}`, + ); + }); +}); diff --git a/tests/cursor-subagent-isolation.test.cjs b/tests/cursor-subagent-isolation.test.cjs index 8fb0b78bd..de04beede 100644 --- a/tests/cursor-subagent-isolation.test.cjs +++ b/tests/cursor-subagent-isolation.test.cjs @@ -29,6 +29,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS } = require('../hooks/lib/isolation-sentinel.js'); +const { REASON_CODE } = require('../hooks/lib/isolation-deny-reason.js'); const { runNode } = require('./helpers/process-seam.cjs'); const { gitOrThrow, toLegacyResult } = require('./helpers/git-fixture.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); @@ -997,3 +998,50 @@ describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marke ); }); }); + +// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — self-heal surfacing ──── +// +// Mirrors tests/gsd-agent-isolation-guard.test.cjs's identical #3582 case for +// the sibling Claude guard. evaluateRootIsolation now calls +// ensureRuntimeBuild() immediately after the GSD-project existence check — +// before resolveFallbackIsolation's runtime-name-policy.cjs/ +// capability-registry.cjs requires or resolveIsolationEvidence's +// runtime-homes.cjs/worktree-safety.cjs requires — so a missing compiled +// runtime library denies with the seam's own actionable message rather than +// the generic "could not read or resolve ... configuration" text (#3050). +// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the +// REAL gsd-core/bin/lib/ is never touched. +describe('gsd-cursor-subagent-start.js: #3582 cold tree — RuntimeBuildError surfaces distinctly', () => { + const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs'); + + test('missing compiled runtime library -> DENY (fail-closed) with the seam\'s own actionable message, not the generic config-unreadable text', (t) => { + const cold = buildColdInstallTree(); + t.after(cold.cleanup); + const project = createTempDir('gsd-cs-cold-'); + t.after(() => cleanup(project)); + fs.mkdirSync(path.join(project, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(project, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' })); + + const env = { ...process.env }; + delete env.GSD_RUNTIME; + delete env.CURSOR_CONFIG_DIR; + const r = toLegacyResult(runNode([path.join(cold.hooksDir, 'gsd-cursor-subagent-start.js')], { + input: JSON.stringify(subagentPayload([project])), + cwd: require('node:os').tmpdir(), + env, + timeoutMs: PROBE_TIMEOUT_MS, + })); + assert.equal(r.status, 0, `this hook always exits 0; stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + // Typed reason code (CONTRIBUTING.md "Prohibited: Raw Text Matching on + // Test Outputs" — assert the stable code, not the free-form + // `user_message` prose). RUNTIME_BUILD_FAILED and CONFIG_UNREADABLE are + // distinct codes, so this equality check itself proves the build + // failure is NOT misreported as the generic unreadable-config case. + assert.equal(out.reason_code, REASON_CODE.RUNTIME_BUILD_FAILED); + // `user_message` remains free-form operator-facing text — not asserted here. + assert.equal(typeof out.user_message, 'string'); + assert.ok(out.user_message.length > 0); + }); +}); diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index 1359e6aee..816bdc72d 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -398,6 +398,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index a1d4dfe54..ba01bd841 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -469,6 +469,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index eaddb062c..77116f382 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -469,6 +469,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index 1a5a4b4f4..aecaa5d4d 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -398,6 +398,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index 7e4dac904..ae5d9a5ca 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -469,6 +469,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index 9b72023d1..8f0cfe008 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -375,6 +375,7 @@ "hooks/gsd-cursor-subagent-start.js", "hooks/gsd-cursor-subagent-stop.js", "hooks/lib/cursor-workspace.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/package.json", "scripts/changeset/README.md", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index 085531ad1..f991e61f9 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -398,6 +398,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index d086797a5..2d2993dc8 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -469,6 +469,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index fab9ade7e..3dcd63e0a 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -399,6 +399,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index 443ca1000..2e7ed75c7 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -469,6 +469,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index 4f97d5963..26c4a1f1e 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -366,6 +366,7 @@ "gsd-hooks/lib/git-cmd.js", "gsd-hooks/lib/gsd-graphify-rebuild.sh", "gsd-hooks/lib/injection-patterns.js", + "gsd-hooks/lib/isolation-deny-reason.js", "gsd-hooks/lib/isolation-sentinel.js", "gsd-hooks/managed-hooks-registry.cjs", "gsd-hooks/package.json", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index e1cd21e60..faf26b4a4 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -398,6 +398,7 @@ "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", "hooks/lib/injection-patterns.js", + "hooks/lib/isolation-deny-reason.js", "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", diff --git a/tests/gsd-agent-isolation-guard.test.cjs b/tests/gsd-agent-isolation-guard.test.cjs index caa142524..ab3dbeaae 100644 --- a/tests/gsd-agent-isolation-guard.test.cjs +++ b/tests/gsd-agent-isolation-guard.test.cjs @@ -54,6 +54,7 @@ const { toLegacyResult, gitOrThrow } = require('./helpers/git-fixture.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { createTempDir, createTempProject, runGsdTools, cleanup } = require('./helpers.cjs'); const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS, readSentinel } = require('../hooks/lib/isolation-sentinel.js'); +const { REASON_CODE } = require('../hooks/lib/isolation-deny-reason.js'); const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js'); @@ -1271,3 +1272,52 @@ describe('#2486 regression: inspect-dispatch-isolation is the sentinel-free read assert.ok(inspectedJson.exec, 'precondition: this branch actually populates exec, so the comparison means something'); }); }); + +// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — self-heal surfacing ──── +// +// gsd-core/bin/lib/*.cjs are tsc build artifacts (ADR-457), gitignored and +// absent on a raw plugin-marketplace / git-clone install that never ran +// `npm run build:lib`. Before #3582, resolveRegistryIsolation's +// require('../gsd-core/bin/lib/runtime-name-policy.cjs') threw a bare +// "Cannot find module", which resolveIsolationState's catch folded into the +// SAME generic "could not read or resolve ... configuration" reason as an +// unreadable config.json (row 8/12 above) — a misreport of a completely +// different failure (#3050 lesson). The fix: resolveRegistryIsolation now +// calls ensureRuntimeBuild() first; a RuntimeBuildError surfaces its own +// actionable message instead. Simulated hermetically via a fixture install +// tree that copies hooks/ + the seam module but never gsd-core/bin/lib/ or +// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the +// REAL gsd-core/bin/lib/ is never touched. +describe('gsd-agent-isolation-guard.js: #3582 cold tree — RuntimeBuildError surfaces distinctly', () => { + const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs'); + + test('missing compiled runtime library -> DENY (fail-closed) with the seam\'s own actionable message, not the generic config-unreadable text', (t) => { + const cold = buildColdInstallTree(); + t.after(cold.cleanup); + const project = mkProject('gsd-aig-cold-'); + t.after(() => cleanup(project)); + writeConfig(project, JSON.stringify({ runtime: 'claude' })); + + const env = { ...process.env }; + delete env.GSD_RUNTIME; + const r = runHookSeam(path.join(cold.hooksDir, 'gsd-agent-isolation-guard.js'), [], { + input: JSON.stringify(agentPayload()), + cwd: project, + env, + timeoutMs: PROBE_TIMEOUT_MS, + }); + const result = toLegacyResult(r); + assert.equal(result.status, 2, `expected fail-closed DENY; stdout: ${result.stdout} stderr: ${result.stderr}`); + const out = JSON.parse(result.stdout); + assert.equal(out.decision, 'block'); + // Typed reason code (CONTRIBUTING.md "Prohibited: Raw Text Matching on + // Test Outputs" — assert the stable code, not the free-form `reason` + // prose). RUNTIME_BUILD_FAILED and CONFIG_UNREADABLE are distinct codes, + // so this equality check itself proves the build failure is NOT + // misreported as the generic unreadable-config case (rows 8/12 above). + assert.equal(out.reason_code, REASON_CODE.RUNTIME_BUILD_FAILED); + // `reason` remains free-form operator-facing text — not asserted here. + assert.equal(typeof out.reason, 'string'); + assert.ok(out.reason.length > 0); + }); +}); diff --git a/tests/gsd-check-update-worker-platform-gate.test.cjs b/tests/gsd-check-update-worker-platform-gate.test.cjs index d90cb222a..336854147 100644 --- a/tests/gsd-check-update-worker-platform-gate.test.cjs +++ b/tests/gsd-check-update-worker-platform-gate.test.cjs @@ -88,6 +88,62 @@ describe('worker delegates the npm spawn (does not re-open the gate, #498)', () }); }); +// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ───── +// +// gsd-core/bin/lib/semver-compare.cjs, package-identity.cjs, and (via +// check-latest-version.cjs's own transitive requires) gsd-core/bin/lib/ +// cli-exit.cjs + shell-command-projection.cjs are tsc build artifacts +// (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone +// install that never ran `npm run build:lib`. This worker is a DETACHED +// SessionStart background process (spawned with stdio: 'ignore' by +// hooks/gsd-check-update.js) — before #3582 a missing library crashed the +// worker at module load with no visible signal (stderr discarded by the +// parent) and no cache-file write at all, so the statusline/banner would +// silently never see an update signal. The fix wraps ensureRuntimeBuild() +// and the three compiled-lib requires in one try/catch and degrades to +// no-signal fallbacks (isSemverNewer -> false, checkLatestVersion -> not ok, +// PACKAGE_NAME -> null) on failure — the worker still runs to completion and +// writes a result cache record. Simulated hermetically via a fixture install +// tree that copies hooks/ + the seam module but never gsd-core/bin/lib/ or +// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the REAL +// gsd-core/bin/lib/ is never touched. +{ + const { describe, test } = require('node:test'); + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const path = require('node:path'); + const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); + const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs'); + const { createTempDir, cleanup } = require('./helpers.cjs'); + + describe('gsd-check-update-worker.js: #3582 cold tree — degrade, not crash', () => { + test('missing compiled runtime library -> worker still writes a degraded result cache, no crash', (t) => { + const cold = buildColdInstallTree(); + t.after(cold.cleanup); + const cacheDir = createTempDir('gsd-worker-cold-'); + t.after(() => cleanup(cacheDir)); + const cacheFile = path.join(cacheDir, 'cache.json'); + + const env = { + ...process.env, + GSD_CACHE_FILE: cacheFile, + GSD_PROJECT_VERSION_FILE: path.join(cacheDir, 'no-such-project', 'VERSION'), + GSD_GLOBAL_VERSION_FILE: path.join(cacheDir, 'no-such-global', 'VERSION'), + }; + const r = runHookSeam(path.join(cold.hooksDir, 'gsd-check-update-worker.js'), [], { + env, + timeoutMs: 8000, + }); + assert.equal(r.exitCode, 0, `worker must exit 0 on a build failure; stderr: ${r.stderr}`); + assert.ok(fs.existsSync(cacheFile), 'worker must still reach the end and write a cache record'); + const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + assert.equal(cache.package_name, null, 'degraded package_name must be null (no-signal, never a stale/foreign value)'); + assert.ok(!cache.update_available, 'degraded update_available must be falsy'); + assert.equal(cache.installed, '0.0.0', 'installed detection is unaffected by the compiled-lib degrade'); + }); + }); +} + // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2992-check-latest-version.test.cjs — consolidation epic #1969 (B5 #1974) @@ -783,6 +839,58 @@ describe('gsd-update-banner.js end-to-end', () => { }); }); +// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ───── +// +// gsd-core/bin/lib/package-identity.cjs is a tsc build artifact (ADR-457), +// gitignored and absent on a raw plugin-marketplace / git-clone install that +// never ran `npm run build:lib`. This is an opt-in SessionStart hook — a +// build failure must degrade (PACKAGE_NAME stays null), not crash session +// start. The DEGRADED VERDICT this locks: buildBannerOutput's own lineage +// guard (`!cache.package_name || cache.package_name !== PACKAGE_NAME`) +// unconditionally distrusts ANY cache once PACKAGE_NAME is null, so even a +// cache written by a healthy worker (real package_name, update_available: +// true) must be suppressed rather than surfaced — the hook stays SILENT +// (exit 0, empty stdout), never a crash and never a stale/wrong banner. +// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the +// REAL gsd-core/bin/lib/ is never touched. +describe('gsd-update-banner.js: #3582 cold tree — degrades to silent, never crashes', () => { + const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs'); + + test('missing compiled runtime library -> exits 0 with empty stdout even for an otherwise-valid update-available cache', (t) => { + const cold = buildColdInstallTree(); + t.after(cold.cleanup); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-cold-home-')); + t.after(() => cleanup(home)); + fs.mkdirSync(path.join(home, '.cache', 'gsd'), { recursive: true }); + // The generic fallback filename the hook's own #3582 degrade uses when + // package-identity.cjs cannot be built (mirrors gsd-check-update.js's + // identical fallback literal) — this is the SAME cache path a degraded + // worker would also have written to. + fs.writeFileSync( + path.join(home, '.cache', 'gsd', 'gsd-update-check.json'), + JSON.stringify({ + update_available: true, + installed: '1.39.0', + latest: '1.40.0', + package_name: '@opengsd/gsd-core', + }), + ); + + const r = seamRunHook(path.join(cold.hooksDir, 'gsd-update-banner.js'), [], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + timeoutMs: 10_000, + }); + + assert.equal(r.exitCode, 0, `hook must exit 0 on a build failure; stderr: ${r.stderr}`); + assert.equal( + r.stdout.trim(), + '', + 'a cold tree must silently suppress the banner (PACKAGE_NAME degrades to null, ' + + 'so the lineage guard distrusts every cache) rather than crash or print stale content', + ); + }); +}); + // ─── Install.js wiring: prompt + SessionStart entry registration ──────────── // // These tests load bin/install.js as a module via GSD_TEST_MODE and assert on diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index 5fbc4a16c..f4a8cd723 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -2415,3 +2415,49 @@ describe('evaluateUpdateCache lineage guard', () => { }); }); } + +// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ───── +// +// gsd-core/bin/lib/semver-compare.cjs, package-identity.cjs, +// state-document.cjs, active-workstream-store.cjs, and planning-workspace.cjs +// are tsc build artifacts (ADR-457), gitignored and absent on a raw +// plugin-marketplace / git-clone install that never ran `npm run build:lib`. +// The statusline renders on EVERY prompt — before #3582 a missing library +// crashed the whole hook process at module load (bare "Cannot find module"), +// so Claude Code's statusline would show nothing AND emit a visible error on +// every single render. The fix: the spawned-as-a-script path +// (`require.main === module`) calls ensureRuntimeBuild() first and, on +// failure, writes empty stdout and exits 0 — the SAME quiet no-signal +// behavior every other internal failure in this hook already degrades to +// (see e.g. the `try { ... } catch (e) { /* Silent fail */ }` wrapping +// runStatusline's own body). Simulated hermetically via a fixture install +// tree that copies hooks/ + the seam module but never gsd-core/bin/lib/ or +// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the REAL +// gsd-core/bin/lib/ is never touched. +{ + const { describe, test } = require('node:test'); + const assert = require('node:assert/strict'); + const os = require('node:os'); + const path = require('node:path'); + const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); + const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs'); + + describe('gsd-statusline.js: #3582 cold tree — degrade to empty output, exit 0', () => { + test('missing compiled runtime library -> empty stdout, exit 0, no crash', (t) => { + const cold = buildColdInstallTree(); + t.after(cold.cleanup); + + const payload = JSON.stringify({ + model: { display_name: 'Claude' }, + workspace: { current_dir: os.tmpdir() }, + session_id: `test-3582-${Date.now()}`, + }); + const r = runHookSeam(path.join(cold.hooksDir, 'gsd-statusline.js'), [], { + input: payload, + timeoutMs: 4000, + }); + assert.equal(r.exitCode, 0, `must exit 0 on a build failure; stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stdout, '', 'must degrade to empty output, not throw a stack trace to stdout'); + }); + }); +} diff --git a/tests/helpers/cold-runtime-lib-fixture.cjs b/tests/helpers/cold-runtime-lib-fixture.cjs new file mode 100644 index 000000000..8a22dacd0 --- /dev/null +++ b/tests/helpers/cold-runtime-lib-fixture.cjs @@ -0,0 +1,60 @@ +'use strict'; + +/** + * Build a hermetic "cold tree" install fixture for #3582 — a copy of hooks/ + * plus gsd-core/bin/ensure-runtime-build.cjs with the compiled + * gsd-core/bin/lib/*.cjs directory and tsconfig.build.json deliberately + * ABSENT, mirroring a raw plugin-marketplace / git-clone install that never + * ran `npm run build:lib`. + * + * Deliberately NOT `tests/helpers/copy-script-fixture.cjs`'s + * `copyScriptWithDeps`: that helper walks the require graph and copies every + * dependency it finds — including gsd-core/bin/lib/*.cjs, which exist in + * THIS repo's already-built tree — so it would faithfully reproduce a WARM + * tree, the opposite of what a cold-tree test needs. This helper copies only + * hooks/ and the seam module itself, and never touches gsd-core/bin/lib/ or + * tsconfig.build.json — so `ensureRuntimeBuild()` inside the fixture + * deterministically throws `RuntimeBuildError` ("tsconfig.build.json not + * found") the first time any fixture hook reaches it, without ever deleting + * or touching the real repo's gsd-core/bin/lib/. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); + +/** + * @param {(dir: string) => void} [t.after] optional node:test `t` for auto-cleanup registration; caller may also ignore and use the returned `cleanup`. + * @returns {{ dir: string, hooksDir: string, cleanup: () => void }} + */ +function buildColdInstallTree() { + const dir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-cold-tree-')); + + // hooks/ — entire directory (top-level hook scripts + hooks/lib/*.js + + // managed-hooks-registry.cjs + hooks.json). hooks/dist/ (gitignored, + // build-hooks.js output) is excluded — it is not present in a raw + // marketplace checkout either. + fs.cpSync(path.join(REPO_ROOT, 'hooks'), path.join(dir, 'hooks'), { + recursive: true, + filter: (src) => path.basename(src) !== 'dist', + }); + + // gsd-core/bin/ensure-runtime-build.cjs — the seam itself. Deliberately + // NOT gsd-core/bin/lib/ (absent — isBuilt() reads false) and NOT + // tsconfig.build.json at the fixture root (absent — ensureRuntimeBuild's + // "cannot auto-build" branch fires deterministically). + fs.mkdirSync(path.join(dir, 'gsd-core', 'bin'), { recursive: true }); + fs.copyFileSync( + path.join(REPO_ROOT, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'), + path.join(dir, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'), + ); + + function cleanup() { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); + } + + return { dir, hooksDir: path.join(dir, 'hooks'), cleanup }; +} + +module.exports = { buildColdInstallTree, REPO_ROOT }; diff --git a/tests/isolation-sentinel.test.cjs b/tests/isolation-sentinel.test.cjs new file mode 100644 index 000000000..3805b0ed2 --- /dev/null +++ b/tests/isolation-sentinel.test.cjs @@ -0,0 +1,122 @@ +'use strict'; + +/** + * Tests for hooks/lib/isolation-sentinel.js's resolveSentinelRoot() — specifically + * the #3582 self-heal call it makes before resolving a linked-worktree/ancestor + * project root. That call is reached ONLY when '.planning' is NOT directly under + * the cwd passed in (the early return at the top of the function fires first + * otherwise). + * + * #3582 review finding 1a: every existing #3582 cold-tree fixture + * (tests/helpers/cold-runtime-lib-fixture.cjs) puts '.planning' directly under + * the fixture project root, so none of them ever reach this branch — deleting + * the seam call would fail nothing in those suites. The one existing test that + * DOES pass a cwd whose '.planning' is not directly present + * (tests/gsd-agent-isolation-guard.test.cjs's "#3045 MINOR" linked-worktree + * test) runs against the REAL, already-built dev tree, where + * ensureRuntimeBuild() is a fast successful no-op — removing the seam call + * there would not change that test's outcome either, since the next require + * (worktree-safety.cjs) would resolve identically either way. + * + * This file closes that gap. Rather than a real tsc build (which would need + * this repo's own node_modules/typescript to be reachable from a throwaway + * fixture root, or a directory symlink into it — the latter a privileged, + * CI-unsafe operation on Windows per tests/ensure-runtime-build.test.cjs's own + * comment), it substitutes the THREE modules resolveSentinelRoot requires + * (the seam itself, worktree-safety.cjs, project-root.cjs) via require.cache, + * keyed by their real resolved absolute paths. This directly OBSERVES whether + * the seam call ran (a spy counter), rather than inferring it from a return + * value that a missing call could coincidentally also produce — and never + * touches gsd-core/bin/lib on disk. Injected cache entries are restored (or + * deleted, if absent beforehand) in `t.after()` so no other test sharing this + * worker process ever observes the substitution. + * + * Mutation check performed while authoring this test (not re-run on every CI + * pass — see the assertions' own doc comments): removing the two-line seam + * call from resolveSentinelRoot makes `seamCalls` stay 0 and + * `worktreeSafetyCalls` become 1 (the fake, reachable stub now answers), so + * this test fails exactly when the fix regresses. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SENTINEL_MODULE_PATH = path.join(REPO_ROOT, 'hooks', 'lib', 'isolation-sentinel.js'); +const SEAM_PATH = require.resolve(path.join(REPO_ROOT, 'gsd-core', 'bin', 'ensure-runtime-build.cjs')); +const WORKTREE_SAFETY_PATH = require.resolve(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'worktree-safety.cjs')); +const PROJECT_ROOT_PATH = require.resolve(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'project-root.cjs')); +const SENTINEL_RESOLVED = require.resolve(SENTINEL_MODULE_PATH); + +/** Minimal shape Node's Module cache expects; only `.exports` is read by require(). */ +function fakeModule(filename, exportsObj) { + return { id: filename, filename, loaded: true, exports: exportsObj, children: [], paths: [] }; +} + +describe('hooks/lib/isolation-sentinel.js: resolveSentinelRoot self-heal reachability (#3582 review finding 1a)', () => { + test('the seam call fires — and short-circuits the downstream requires — when .planning is not directly under cwd', (t) => { + const savedSeam = require.cache[SEAM_PATH]; + const savedWorktreeSafety = require.cache[WORKTREE_SAFETY_PATH]; + const savedProjectRoot = require.cache[PROJECT_ROOT_PATH]; + const savedSentinel = require.cache[SENTINEL_RESOLVED]; + + t.after(() => { + const restore = (key, saved) => { if (saved) require.cache[key] = saved; else delete require.cache[key]; }; + restore(SEAM_PATH, savedSeam); + restore(WORKTREE_SAFETY_PATH, savedWorktreeSafety); + restore(PROJECT_ROOT_PATH, savedProjectRoot); + restore(SENTINEL_RESOLVED, savedSentinel); + }); + + let seamCalls = 0; + let worktreeSafetyCalls = 0; + let projectRootCalls = 0; + + class FakeRuntimeBuildError extends Error {} + require.cache[SEAM_PATH] = fakeModule(SEAM_PATH, { + RuntimeBuildError: FakeRuntimeBuildError, + ensureRuntimeBuild: () => { + seamCalls += 1; + throw new FakeRuntimeBuildError('fake cold-tree build failure (#3582 reachability test)'); + }, + }); + require.cache[WORKTREE_SAFETY_PATH] = fakeModule(WORKTREE_SAFETY_PATH, { + resolveWorktreeRoot: () => { + worktreeSafetyCalls += 1; + return { root: 'SHOULD-NOT-BE-REACHED' }; + }, + }); + require.cache[PROJECT_ROOT_PATH] = fakeModule(PROJECT_ROOT_PATH, { + findProjectRoot: () => { + projectRootCalls += 1; + return 'SHOULD-NOT-BE-REACHED'; + }, + }); + // Fresh require of isolation-sentinel.js itself — not strictly required + // (its own three requires live inside the function body and are + // re-evaluated on every call regardless of module-cache state), but keeps + // this test independent of whatever load order other files in the same + // worker already forced. + delete require.cache[SENTINEL_RESOLVED]; + const { resolveSentinelRoot } = require(SENTINEL_MODULE_PATH); + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-iso-sentinel-reach-')); + t.after(() => cleanup(cwd)); + assert.equal( + fs.existsSync(path.join(cwd, '.planning')), + false, + 'precondition: no .planning directly under cwd, so the early return must NOT fire', + ); + + const result = resolveSentinelRoot(cwd); + + assert.equal(seamCalls, 1, 'ensureRuntimeBuild() must be called exactly once when .planning is not directly under cwd'); + assert.equal(worktreeSafetyCalls, 0, 'a thrown RuntimeBuildError must short-circuit before resolveWorktreeRoot is ever reached'); + assert.equal(projectRootCalls, 0, 'a thrown RuntimeBuildError must short-circuit before findProjectRoot is ever reached'); + assert.equal(result, cwd, 'resolveSentinelRoot degrades to the raw cwd on a build failure, same as any other resolution failure'); + }); +}); diff --git a/tests/lint-hooks-runtime-build-seam.test.cjs b/tests/lint-hooks-runtime-build-seam.test.cjs new file mode 100644 index 000000000..c82a8de56 --- /dev/null +++ b/tests/lint-hooks-runtime-build-seam.test.cjs @@ -0,0 +1,255 @@ +'use strict'; + +/** + * Tests for `scripts/lint-hooks-runtime-build-seam.cjs` — the CI guard that + * every `hooks/**` file requiring a compiled `gsd-core/bin/lib/*.cjs` module + * must also self-heal via `ensureRuntimeBuild()` first (#3582). + * + * Mirrors the sibling `lint-*-drift.cjs` test convention (see e.g. + * `tests/lint-planning-artifact-writer-drift.test.cjs`): pure-function unit + * tests against in-memory strings for the fast cases, plus an on-disk + * fixture-tree test (via `scanRepo`) proving the guard genuinely detects a + * real violating file — "a ratchet that has never been proven to fail is + * worthless" (CLAUDE.md). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + scanFile, + scanRepo, + stripComments, +} = require('../scripts/lint-hooks-runtime-build-seam.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); + +// ─── Case 1: the guard passes clean against the REAL hooks/ tree ─────────── + +describe('scanRepo — real hooks/ tree', () => { + test('reports zero violations against the actual hooks/ tree', () => { + const violations = scanRepo(REPO_ROOT); + assert.deepStrictEqual( + violations, + [], + `unexpected hooks/ runtime-build-seam violation(s): ${JSON.stringify(violations, null, 2)}`, + ); + }); + + test('the real tree has REAL compiled-lib requires (the detector is not silently inert)', () => { + // A detector that never matches anything would also report zero + // violations. Prove it actually found the six #3582 hook files' real + // gsd-core/bin/lib/*.cjs requires and confirmed each is seam-wired. + const hookFiles = [ + 'gsd-agent-isolation-guard.js', + 'gsd-cursor-subagent-start.js', + 'gsd-statusline.js', + 'gsd-check-update-worker.js', + 'gsd-check-update.js', + 'gsd-update-banner.js', + ]; + for (const name of hookFiles) { + const text = fs.readFileSync(path.join(REPO_ROOT, 'hooks', name), 'utf8'); + const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text); + assert.ok(compiledLibRequires.length > 0, `${name}: expected at least one compiled-lib require`); + assert.ok(hasSeamRequire, `${name}: expected a require of ensure-runtime-build.cjs`); + assert.ok(hasSeamCall, `${name}: expected an ensureRuntimeBuild(...) call`); + } + }); +}); + +// ─── Case 2: pure-function unit tests (in-memory strings) ───────────────── + +describe('scanFile — pure detection', () => { + test('a file with no compiled-lib require has nothing to check', () => { + const text = "'use strict';\nconst fs = require('fs');\nmodule.exports = {};\n"; + const { compiledLibRequires } = scanFile(text); + assert.deepStrictEqual(compiledLibRequires, []); + }); + + test('a compiled-lib require with NO seam require/call is detected', () => { + const text = [ + "'use strict';", + "const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');", + 'module.exports = { runtimes };', + '', + ].join('\n'); + const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text); + assert.deepStrictEqual(compiledLibRequires, ['../gsd-core/bin/lib/capability-registry.cjs']); + assert.equal(hasSeamRequire, false); + assert.equal(hasSeamCall, false); + }); + + test('a compiled-lib require WITH a seam require + call is not flagged', () => { + const text = [ + "'use strict';", + "const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');", + 'function f() {', + ' ensureRuntimeBuild();', + " const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');", + ' return runtimes;', + '}', + 'module.exports = { f };', + '', + ].join('\n'); + const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text); + assert.deepStrictEqual(compiledLibRequires, ['../gsd-core/bin/lib/capability-registry.cjs']); + assert.equal(hasSeamRequire, true); + assert.equal(hasSeamCall, true); + }); + + test('a seam REQUIRE with no CALL still counts as missing (import-only bypass)', () => { + const text = [ + "'use strict';", + // Imported but never invoked — must not satisfy the guard. + "const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');", + "const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');", + 'module.exports = { runtimes, ensureRuntimeBuild };', + '', + ].join('\n'); + const { hasSeamRequire, hasSeamCall } = scanFile(text); + assert.equal(hasSeamRequire, true); + assert.equal(hasSeamCall, false); + }); + + test('a real require() inside a // comment is NOT counted (comment-only mention)', () => { + const text = [ + "'use strict';", + "// example: require('../gsd-core/bin/lib/capability-registry.cjs')", + "const fs = require('fs');", + '', + ].join('\n'); + const { compiledLibRequires } = scanFile(text); + assert.deepStrictEqual(compiledLibRequires, []); + }); + + // Regression: this repo's own hook comments legitimately spell the glob + // `gsd-core/bin/lib/*.cjs` inside a `//` line — a `/` immediately followed + // by `*` forms a bare `/*` token. A naive whole-text + // `/\*[\s\S]*?\*\//g` block-comment stripper reads that as an OPENING + // block comment and silently deletes everything up to the next unrelated + // `*/` later in the file — including real require() lines. Caught while + // authoring this guard (it ate its own seam require in + // hooks/gsd-agent-isolation-guard.js); locked here so it cannot regress. + test('a `//` comment containing a glob like lib/*.cjs does not swallow later real code', () => { + const text = [ + "'use strict';", + '// gsd-core/bin/lib/*.cjs (foo.cjs, bar.cjs) are compiled artifacts.', + "const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');", + 'function f() {', + ' ensureRuntimeBuild();', + " const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');", + ' return runtimes;', + '}', + '/* a real, later, unrelated block comment */', + 'module.exports = { f };', + '', + ].join('\n'); + const stripped = stripComments(text); + assert.ok( + stripped.includes("require('../gsd-core/bin/ensure-runtime-build.cjs')"), + 'the seam require must survive comment-stripping', + ); + assert.ok(stripped.includes('ensureRuntimeBuild();'), 'the seam call must survive comment-stripping'); + const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text); + assert.deepStrictEqual(compiledLibRequires, ['../gsd-core/bin/lib/capability-registry.cjs']); + assert.equal(hasSeamRequire, true); + assert.equal(hasSeamCall, true); + }); + + test('a genuine multi-line block comment is still stripped (no false-positive require inside it)', () => { + const text = [ + "'use strict';", + '/**', + " * Example: require('../gsd-core/bin/lib/capability-registry.cjs') is", + ' * mentioned here only as documentation prose.', + ' */', + "const fs = require('fs');", + '', + ].join('\n'); + const { compiledLibRequires } = scanFile(text); + assert.deepStrictEqual(compiledLibRequires, []); + }); +}); + +// ─── Case 3: on-disk fixture tree via scanRepo — proves the guard CAN fail ─ + +describe('scanRepo — on-disk fixture tree (proves the guard is not vacuous)', () => { + function writeFixtureTree(dir, { withSeam }) { + const hooksDir = path.join(dir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const badLines = [ + "'use strict';", + "const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');", + 'module.exports = { runtimes };', + '', + ]; + fs.writeFileSync(path.join(hooksDir, 'bad-hook.js'), badLines.join('\n')); + + const goodLines = withSeam + ? [ + "'use strict';", + "const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');", + 'ensureRuntimeBuild();', + "const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');", + 'module.exports = { runtimes };', + '', + ] + : [ + "'use strict';", + "const fs = require('fs');", + 'module.exports = { fs };', + '', + ]; + fs.writeFileSync(path.join(hooksDir, 'good-hook.js'), goodLines.join('\n')); + + // hooks/dist/ is the generated build-output copy — never scanned, even + // when it contains an identical un-sealed require. + const distDir = path.join(hooksDir, 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(path.join(distDir, 'bad-hook.js'), badLines.join('\n')); + } + + test('a fixture hook requiring a compiled module with NO seam is FLAGGED', (t) => { + const dir = createTempDir('gsd-lint-hooks-seam-'); + t.after(() => cleanup(dir)); + writeFixtureTree(dir, { withSeam: true }); + + const violations = scanRepo(dir); + const files = violations.map((v) => v.file); + assert.ok(files.includes('hooks/bad-hook.js'), `expected hooks/bad-hook.js flagged, got: ${JSON.stringify(files)}`); + assert.ok( + !files.includes('hooks/good-hook.js'), + `hooks/good-hook.js (seam-wired) must NOT be flagged, got: ${JSON.stringify(files)}`, + ); + const bad = violations.find((v) => v.file === 'hooks/bad-hook.js'); + assert.deepStrictEqual(bad.compiledLibRequires, ['../gsd-core/bin/lib/capability-registry.cjs']); + assert.ok(bad.missing.length > 0); + }); + + test('hooks/dist/ (generated build-output copy) is never scanned, even with the same violation', (t) => { + const dir = createTempDir('gsd-lint-hooks-seam-dist-'); + t.after(() => cleanup(dir)); + writeFixtureTree(dir, { withSeam: true }); + + const violations = scanRepo(dir); + const files = violations.map((v) => v.file); + assert.ok( + !files.some((f) => f.startsWith('hooks/dist/')), + `hooks/dist/ must be excluded from the scan, got: ${JSON.stringify(files)}`, + ); + }); + + test('a fixture tree with NO offending hooks reports zero violations (no false positive)', (t) => { + const dir = createTempDir('gsd-lint-hooks-seam-clean-'); + t.after(() => cleanup(dir)); + writeFixtureTree(dir, { withSeam: false }); + fs.unlinkSync(path.join(dir, 'hooks', 'bad-hook.js')); + + const violations = scanRepo(dir); + assert.deepStrictEqual(violations, []); + }); +});