Files
msd-core/tests/check-update-config-dir.test.cjs
Tom Boucher bf2332e67c fix(#3582): route every hook's compiled-module require through the self-heal build seam (#3629)
* 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>
2026-08-18 14:11:23 -04:00

349 lines
14 KiB
JavaScript

/**
* Regression test for #1860: detectConfigDir in gsd-check-update.js should
* prioritize .claude over .config/opencode so that Claude Code sessions
* don't report false "update available" warnings when an older OpenCode
* install exists alongside a newer Claude Code install.
*
* All coverage here is BEHAVIORAL: it spawns the real hook (as a `node -e`
* child, with `child_process.spawn` stubbed) and observes the resolved
* config-dir paths it hands to its background worker via env vars. Nothing
* in this file reads hooks/gsd-check-update.js source — the hook has no
* exports (it runs entirely on require), so its only outward, in-process
* observable effect is the one spawn() call it makes to launch its worker.
* That spawn's env carries GSD_GLOBAL_VERSION_FILE / GSD_PROJECT_VERSION_FILE,
* which is deliberately borrowed as the observation seam here (Hyrum's Law:
* this is an implementation detail, not a contract) rather than a real
* subprocess launch, since the actual worker touches the network.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { cleanup } = require('./helpers.cjs');
const { runNode, OUTCOME } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const CHECK_UPDATE_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update.js');
// ─── Probe harness ──────────────────────────────────────────────────────────
//
// Builds a `node -e` wrapper (assembled via array .join('\n'), never a
// multi-line template literal — CONTRIBUTING.md's fixture-string convention)
// that stubs child_process.spawn BEFORE requiring the real hook, so the
// hook's actual detectConfigDir() logic runs untouched while the worker
// launch itself is captured instead of executed. Emits exactly one JSON line
// so the test parses structured data, never regex/substring-matches stdout.
function buildProbeSource(hookPath) {
return [
"'use strict';",
'let spawned = false;',
'let capturedEnv = null;',
"const cp = require('child_process');",
'cp.spawn = function stubSpawn(command, args, opts) {',
' spawned = true;',
' capturedEnv = (opts && opts.env) || null;',
' return { unref: function () {} };',
'};',
`require(${JSON.stringify(hookPath)});`,
'const result = {',
' spawned: spawned,',
' global: capturedEnv ? capturedEnv.GSD_GLOBAL_VERSION_FILE : null,',
' project: capturedEnv ? capturedEnv.GSD_PROJECT_VERSION_FILE : null,',
' cache: capturedEnv ? capturedEnv.GSD_CACHE_FILE : null,',
'};',
'process.stdout.write(JSON.stringify(result) + "\\n");',
].join('\n');
}
/**
* Run the probe against a fake HOME/cwd and return the parsed
* { spawned, global, project, cache } envelope.
*
* @param {object} opts
* @param {string} opts.homeDir - fake HOME/USERPROFILE for this run.
* @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 = {}, 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(hookPath)], {
cwd,
env: childEnv,
timeoutMs: PROBE_TIMEOUT_MS,
});
assert.equal(
result.outcome,
OUTCOME.EXITED,
`probe process did not exit cleanly (outcome=${result.outcome}); stderr:\n${result.stderr}`
);
assert.equal(
result.exitCode,
0,
`probe process exited non-zero; stderr:\n${result.stderr}`
);
const lastLine = result.stdout.trim().split('\n').filter(Boolean).pop();
let parsed;
try {
parsed = JSON.parse(lastLine);
} catch (cause) {
throw new Error(
`probe: could not parse probe stdout as JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
{ cause }
);
}
if (parsed.spawned !== true) {
throw new Error(
"probe: hooks/gsd-check-update.js no longer calls child_process.spawn() to launch " +
"its background worker. This harness's OBSERVATION POINT (reading detectConfigDir's " +
"resolved paths off the spawn() env) has moved and needs to be re-anchored on " +
'whatever now carries the resolved config-dir paths — this is NOT evidence that ' +
"detectConfigDir's precedence/search-order logic regressed."
);
}
return parsed;
}
function configDirOf(versionFile) {
assert.ok(
typeof versionFile === 'string' && versionFile.length > 0,
'expected the probe to report a version-file path'
);
return path.dirname(path.dirname(versionFile));
}
function assertConfigDir(actualVersionFile, expectedDir, message) {
const actual = configDirOf(actualVersionFile).replace(/\\/g, '/');
const expected = expectedDir.replace(/\\/g, '/');
assert.equal(actual, expected, message);
}
function writeVersionFile(configDir) {
const versionDir = path.join(configDir, 'gsd-core');
fs.mkdirSync(versionDir, { recursive: true });
fs.writeFileSync(path.join(versionDir, 'VERSION'), '1.0.0\n');
}
// ─── Fixtures ───────────────────────────────────────────────────────────────
describe('detectConfigDir runtime behavior (#1860)', () => {
let tmpHome;
let tmpProject;
beforeEach(() => {
// realpathSync'd immediately: process.cwd() inside the spawned child
// resolves symlinks (macOS resolves a temp dir through /private), while
// os.homedir()'s env-var passthrough does not. Resolving both bases once,
// up front, and using ONLY the resolved string everywhere downstream
// (as HOME/cwd for the spawn AND to build every expected path) makes
// resolving an already-resolved path a no-op on both sides, so the two
// mechanisms can never disagree — instead of patching the divergence
// back together at each assertion.
tmpHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-home-')));
tmpProject = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-project-')));
});
afterEach(() => {
cleanup(tmpHome);
cleanup(tmpProject);
});
test('#1860: returns .claude when both .claude and .config/opencode hold VERSION', () => {
writeVersionFile(path.join(tmpHome, '.config', 'opencode'));
writeVersionFile(path.join(tmpHome, '.claude'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'.claude must win over .config/opencode when both hold VERSION (#1860)'
);
});
test('falls back to .config/opencode when only it holds VERSION', () => {
writeVersionFile(path.join(tmpHome, '.config', 'opencode'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.config', 'opencode'),
'expected .config/opencode when it is the only dir with a VERSION file'
);
});
test('falls back to <home>/.claude when nothing holds VERSION and no env override', () => {
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'expected the bare .claude fallback tail when no candidate dir has a VERSION file'
);
});
test('CLAUDE_CONFIG_DIR with a valid VERSION short-circuits the search order', (t) => {
const envDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-envdir-')));
t.after(() => cleanup(envDir));
writeVersionFile(envDir);
writeVersionFile(path.join(tmpHome, '.claude'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject, envOverrides: { CLAUDE_CONFIG_DIR: envDir } });
assertConfigDir(
result.global,
envDir,
'CLAUDE_CONFIG_DIR must win outright when its own VERSION file exists'
);
});
test('CLAUDE_CONFIG_DIR without a VERSION file does not short-circuit the search', (t) => {
const envDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-envdir-')));
t.after(() => cleanup(envDir));
writeVersionFile(path.join(tmpHome, '.claude'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject, envOverrides: { CLAUDE_CONFIG_DIR: envDir } });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'CLAUDE_CONFIG_DIR must be ignored (falling through to the search array) when it has no VERSION file'
);
});
test('CLAUDE_CONFIG_DIR without a VERSION file anywhere falls back to the env dir itself', (t) => {
const envDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-envdir-')));
t.after(() => cleanup(envDir));
const result = probe({ homeDir: tmpHome, cwd: tmpProject, envOverrides: { CLAUDE_CONFIG_DIR: envDir } });
assertConfigDir(
result.global,
envDir,
'the `return envDir || path.join(baseDir, ".claude")` tail must return the env dir, ' +
'not the bare .claude fallback, when CLAUDE_CONFIG_DIR is set but nothing has a VERSION file'
);
});
test('CLAUDE_CONFIG_DIR set to an empty string is treated as unset', () => {
writeVersionFile(path.join(tmpHome, '.claude'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject, envOverrides: { CLAUDE_CONFIG_DIR: '' } });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'an empty-string CLAUDE_CONFIG_DIR is falsy and must not be treated as a real override'
);
});
// ─── Adjacent-pair ordering (behavioral replacement for the deleted static
// array-order grep) ─────────────────────────────────────────────────
const ADJACENT_PAIRS = [
['.claude', '.gemini'],
['.gemini', '.config/kilo'],
['.config/kilo', '.kilo'],
['.kilo', '.config/opencode'],
['.config/opencode', '.opencode'],
];
for (const [winner, loser] of ADJACENT_PAIRS) {
test(`search order: ${winner} wins over ${loser} (#1860 ordering)`, () => {
writeVersionFile(path.join(tmpHome, winner));
writeVersionFile(path.join(tmpHome, loser));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, winner),
`${winner} must be searched before ${loser}`
);
});
}
test('an empty .claude directory (no VERSION file) is not a match — the file is the predicate', () => {
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
writeVersionFile(path.join(tmpHome, '.config', 'opencode'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.config', 'opencode'),
'an existing .claude dir with no gsd-core/VERSION file must not satisfy the search — ' +
'fs.existsSync(VERSION) is the predicate, not directory existence'
);
});
test('global (home) and project (cwd) resolve independently, each against its own base', () => {
writeVersionFile(path.join(tmpHome, '.claude'));
writeVersionFile(path.join(tmpProject, '.config', 'opencode'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'global resolution must be independent of the project (cwd) fixture state'
);
assertConfigDir(
result.project,
path.join(tmpProject, '.config', 'opencode'),
'project resolution must be independent of the home (global) fixture state'
);
});
});
// ─── #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}`,
);
});
});