* test(3582): failing-first cold-tree coverage and the seam drift lint On a plugin-channel install the compiled gsd-core/bin/lib/*.cjs are legitimately absent (ADR-457 build-at-publish; the npm package builds before publishing, a raw tree materialization never does). gsd-tools.cjs calls ensureRuntimeBuild() before requiring ./lib; no hook does, so the isolation guard's Cannot-find-module lands in its fail-closed catch and is misreported as an unreadable dispatch-isolation configuration, blocking every executor dispatch. These tests fail on that: cold-tree runs of the isolation guard, statusline, cursor guard and update worker, plus the seam's actionable build error surfacing instead of the generic misreport. Also adds the drift lint the acceptance criteria require, with a fixture proving it CAN fail — a guard never shown to fail is worthless. It is red here by design: it flags today's unfixed hooks, which is exactly the defect. * fix(3582): route every hook's compiled-module require through the self-heal seam RED proven at 5b174b0d: 11 failures — the cold-tree runs for the isolation guard, cursor guard and update worker, the fail-closed-with-actionable-message assertion, and the lint's own real-tree check. The compiled runtime library is produced by build:lib and gitignored (ADR-457, build-at-publish). The npm package builds before publishing; a plugin-marketplace or git-clone install materializes the raw tree and never does, so on that channel those modules are legitimately absent. The self-heal seam added by #2002 exists to heal exactly this, and the CLI entrypoint already calls it — no hook did. The isolation guard's Cannot-find-module therefore landed in its fail-closed catch and was reported as 'could not read or resolve dispatch-isolation configuration', so an ARTIFACT ABSENCE was misdiagnosed as an unreadable project config and every executor dispatch was blocked. All SEVEN affected files now call the seam before their first compiled require. The issue named four; a scan found six; implementing it surfaced a seventh — the shared isolation sentinel helper, used by BOTH guards, which requires two compiled modules itself and would have defeated the guards' own fix on a genuinely cold tree. Same defect class, so fixed here rather than left as a known-broken remainder. Failure posture is deliberately split by hook kind: - Gates (agent isolation guard, cursor subagent start) surface the seam's actionable build error distinctly instead of swallowing it into the generic text, and stay fail-closed — a genuinely unreadable project config still DENIES exactly as before. - Cosmetic and detached hooks (statusline, update worker, update check, update banner) DEGRADE rather than crash: the statusline draws on every render and the worker is a detached process, so a build failure there must not take down the prompt. The npm path is untouched: the seam's already-built fast path returns immediately, so prebuilt installs pay nothing and behave bit-for-bit as before. Adds a drift lint, wired into the CI lint chain, so the invariant is enforced rather than remembered — without it the next hook to add a compiled require reintroduces the class silently. It is proven able to fail: a fixture hook requiring a compiled module without the seam is flagged, and one that uses the seam is not. Verified directly — on the unfixed tree it named all seven offenders; with the fix it passes. While writing the lint's comment stripper, a naive whole-text block-comment regex ate its own fixture, because this repo's comments legitimately spell the compiled-lib glob whose star-slash reads as a comment opener. Rewritten as a line-based scanner with a regression test pinning that case. * fix(3582): test the three untested seam call sites and assert typed reason codes Two independent reviews converged on the same major gap: the fix wired the seam into seven files but only four had cold-tree tests. The adversarial pass put it plainly — deleting the shared isolation-sentinel helper's seam call would not have failed any test in the diff. That file was my own addition beyond the issue's four, so it shipped untested; that is now closed. - Shared isolation-sentinel helper: its seam call is only reached when .planning is NOT directly under cwd, and every existing cold-tree fixture puts it there, so the early return always fired first. Now covered, and proven load-bearing by mutation: with the call removed the spy records zero seam invocations and the test fails. - update-check hook and update-banner hook: cold-tree tests added asserting the DEGRADED VERDICT — the fallback cache filename, and silent suppression when the package name degrades to null — rather than merely 'did not throw'. The banner hook previously had no test file at all. Standards violation fixed: two tests asserted on free-form prose via assert.match against a JSON reason string, which CONTRIBUTING bans by name — its own BAD example is exactly that. The ESLint rule only covers readFileSync/spawnSync text, so tooling did not catch it. Both isolation guards now emit a machine-readable reason_code from a frozen enum, following the repo's existing REASON convention, and the tests assert that instead. The human-readable message is unchanged for operators; only the assertion target moved. The duplicated degrade boilerplate across the three cosmetic hooks was deliberately NOT extracted, and the reason is recorded at each site: both viable shapes — a path-parameterized helper, or a ceremony-only wrapper — defeat the drift lint's per-file literal co-occurrence check, so extracting would require the lint to special-case its own helper. Triplication is the lesser evil while the lint stays a co-occurrence scan. The lint's header now states what it does and does not catch (literal quoted requires only; hooks/ scan root), so a future reader does not over-trust a guard that a concatenated path or a require inside a non-hooks helper would evade. * chore(3582): regenerate the committed install-tree fixtures Adding a new shipped hook helper changed the install tree, and those fixtures are committed-and-derived (regen:derived / gen:install-tree), so 12 'install tree — <runtime>' tests failed on 541a1913. Regenerated rather than hand-edited. The delta across all 15 runtime fixtures is exactly two lines — the new helper under both its hooks/ and gsd-hooks/ install paths — and nothing else, so the regeneration pulled in no unrelated drift. This is the bookkeeping ripple a new file under hooks/ carries; it was not visible from lint:ci, which passed both before and after. * chore(3582): backfill changeset PR number (#3629) --------- Co-authored-by: sim <sim@local>
161 lines
8.1 KiB
JavaScript
161 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// gsd-hook-version: {{GSD_VERSION}}
|
|
// Background worker spawned by gsd-check-update.js (SessionStart hook).
|
|
// Checks for GSD updates and stale hooks, writes result to cache file.
|
|
// Receives paths via environment variables set by the parent hook.
|
|
//
|
|
// Using a separate file (rather than node -e '<inline code>') avoids the
|
|
// template-literal regex-escaping problem: regex source is plain JS here.
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// #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)
|
|
// so it is present in hooks/dist/ and ships to the installed runtime hooks/ dir.
|
|
// If it is missing (e.g., installed from an older dist), catch and degrade gracefully
|
|
// so the worker always proceeds to compute and write the result cache record.
|
|
let MANAGED_HOOKS = [];
|
|
try {
|
|
({ MANAGED_HOOKS } = require('./managed-hooks-registry.cjs'));
|
|
} catch (e) {
|
|
// Module not found in installed runtime — stale-hook detection degrades to
|
|
// no-op (empty list means no hooks are checked for staleness). The worker
|
|
// still runs and writes package_name / installed / latest / update_available.
|
|
}
|
|
|
|
const cacheFile = process.env.GSD_CACHE_FILE;
|
|
const projectVersionFile = process.env.GSD_PROJECT_VERSION_FILE;
|
|
const globalVersionFile = process.env.GSD_GLOBAL_VERSION_FILE;
|
|
|
|
// Check project directory first (local install), then global
|
|
let installed = '0.0.0';
|
|
let configDir = '';
|
|
try {
|
|
if (fs.existsSync(projectVersionFile)) {
|
|
installed = fs.readFileSync(projectVersionFile, 'utf8').trim();
|
|
configDir = path.dirname(path.dirname(projectVersionFile));
|
|
} else if (fs.existsSync(globalVersionFile)) {
|
|
installed = fs.readFileSync(globalVersionFile, 'utf8').trim();
|
|
configDir = path.dirname(path.dirname(globalVersionFile));
|
|
}
|
|
} catch (e) {}
|
|
|
|
// Check for stale hooks — compare hook version headers against installed VERSION
|
|
// Since #3023 the bundle directory name is resolved from __dirname (this
|
|
// worker is staged INSIDE the bundle), not assumed to be configDir/hooks —
|
|
// the directory name is runtime-descriptor-driven (e.g. `gsd-hooks/` for pi).
|
|
// Only check hooks that GSD currently ships — orphaned files from removed features
|
|
// (e.g., gsd-intel-*.js) must be ignored to avoid permanent stale warnings (#1750)
|
|
// MANAGED_HOOKS is imported from ./managed-hooks-registry.cjs above.
|
|
|
|
const staleHooks = [];
|
|
if (configDir) {
|
|
// #3023: the bundle's directory name is runtime-descriptor-driven (pi stages
|
|
// it as `gsd-hooks/`), so deriving it as `<configDir>/hooks` silently scanned
|
|
// nothing there. This worker is staged INSIDE the bundle, so __dirname is the
|
|
// bundle directory by construction — name-agnostic and one fewer assumption.
|
|
const hooksDir = __dirname;
|
|
try {
|
|
if (fs.existsSync(hooksDir)) {
|
|
const hookFiles = fs.readdirSync(hooksDir).filter(f => MANAGED_HOOKS.includes(f));
|
|
for (const hookFile of hookFiles) {
|
|
try {
|
|
const content = fs.readFileSync(path.join(hooksDir, hookFile), 'utf8');
|
|
// Match both JS (//) and bash (#) comment styles
|
|
const versionMatch = content.match(/(?:\/\/|#) gsd-hook-version:\s*(.+)/);
|
|
if (versionMatch) {
|
|
const hookVersion = versionMatch[1].trim();
|
|
if (isSemverNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
|
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
|
}
|
|
} else {
|
|
// No version header at all — definitely stale (pre-version-tracking)
|
|
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Single adapter for the registry lookup (#498). checkLatestVersion() routes
|
|
// through the shell-projection seam, which already owns the Windows shell-flag
|
|
// policy, the timeout, and semver validation. A non-ok result leaves latest
|
|
// null, exactly as the previous inline try/catch did.
|
|
let latest = null;
|
|
try {
|
|
const lv = checkLatestVersion();
|
|
if (lv && lv.ok) latest = lv.version;
|
|
} catch (e) {}
|
|
|
|
const result = {
|
|
update_available: latest && isSemverNewer(latest, installed),
|
|
installed,
|
|
latest: latest || 'unknown',
|
|
checked: Math.floor(Date.now() / 1000),
|
|
stale_hooks: staleHooks.length > 0 ? staleHooks : undefined,
|
|
package_name: PACKAGE_NAME,
|
|
};
|
|
|
|
if (cacheFile) {
|
|
try { fs.writeFileSync(cacheFile, JSON.stringify(result)); } catch (e) {}
|
|
}
|