* 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>
123 lines
6.2 KiB
JavaScript
123 lines
6.2 KiB
JavaScript
'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');
|
|
});
|
|
});
|