* chore(#3059): close the ESLint glob-coverage escape and guard it 62 tracked source files matched no `files:` glob in eslint.config.mjs, so ESLint skipped them entirely while `eslint .` still exited 0 — including all 26 files under hooks/, the enforcement machinery itself. Covers 56 of them (eslint-rules/, hooks/, bin/lib/, pi/, examples/, vscode/, the plugin shims, root *.mjs) and allowlists the 6 deliberate must-not-compile brand-typing fixtures with a recorded reason each. hooks/** is covered with n/no-process-exit deliberately off: a hook's whole contract is its exit code, several exits are load-bearing stdin-timeout guards where nothing else terminates the process, and ADR-0012/0174 scope the no-process-exit convention to the Command Routing Hub. bin/lib/ui-safety-gate.cjs is dual-mode, so it keeps the rule live and takes two targeted disables in its require.main===module tail instead. Adds scripts/lint-eslint-glob-coverage.cjs + a node:test drift guard so the class cannot regrow: allowlist entries require a non-empty reason, the list ratchets down only (a stale entry fails), and a tracked-count floor means a broken `git ls-files` fails rather than reporting clean. Closes #3059 * chore(#3059): apply review findings — correct the changeset count, add parser properties Isolated adversarial review, confirmed by rebuilding a byte-for-byte replica of the pre-change eslint.config.mjs: the changeset claimed 44 previously- unlinted files. The real figure is 56 (56 covered + 6 allowlisted = 62). That was user-facing CHANGELOG text and was wrong; corrected, along with three consequential figures in the design record. CLAUDE.md requires a fast-check property test for parsers and budget limits, and listTrackedSourceFiles is a parser. The standards review called this "satisfied in spirit"; it is not. Adds three properties driving the real exported parser through an injected execFile: extension totality/soundness including a trailing terminator, backslash-normalization totality, and CRLF/LF equivalence — the invariant the repo's recurring CRLF defect class breaks. Also de-duplicates the anchor rows onto one shared resolver, kept deliberately independent of the guard's own resolveFileCoverage so an anchor still fails if that resolution regresses, and records in the guard's header why the bin/install.js family is NOT allowlisted: it resolves to 2 rules under ADR-1703, so an entry would trip the allowlist_stale ratchet. * fix(#3059): make the coverage guard's git call container-safe The remote runner reported the guard degrading to `git_failed` on both Node lanes: fatal: detected dubious ownership in repository at '/work' The runner executes in a container where the repo is owned by a different UID, so git refuses to operate on it. The guard's degraded-verdict path worked exactly as designed — it reported the failure instead of throwing or falsely reporting clean — but a guard that cannot run in CI is not a gate. `git ls-files` is now invoked as `git -c safe.directory=* ls-files`. `-c` scopes the override to the single invocation and mutates no config file, and the wildcard is appropriate because this command only enumerates tracked paths in the repository it is already executing inside. Adds a regression test that captures the argv through the injected execFile seam and asserts `-c safe.directory=*` precedes `ls-files`, so the container case is pinned behaviorally rather than by reading the script's source. * chore(#3059): backfill changeset PR number pr:0 placeholder replaced with the real number now that #3277 exists. --------- Co-authored-by: sim <sim@local>
118 lines
5.5 KiB
JavaScript
118 lines
5.5 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');
|
|
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');
|
|
// 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) {}
|
|
}
|