* test(#3412): failing-first suite for the pattern-construction seam Phase 1 of epic #3212 (ADR-3212 §1/§2/§7). Tests only — src/pattern.cts and eslint-rules/no-adhoc-regex-escape.cjs do not exist yet, so both suites fail with MODULE_NOT_FOUND, which is the intended RED. Locks the measured behavior rather than the assumed behavior: RegExp.escape hex-escapes the leading character of nearly every string ("abc" -> "\x61bc"), so the suite asserts match-equivalence against an inlined historical oracle (the implementation being deleted) rather than byte-equivalence of pattern text — 200 seeded fast-check runs plus a fixed corpus, 0 mismatches. Also locks the latent character-class range bug this phase fixes as a side effect: a hyphen-bearing value interpolated into [...] currently forms a real range and matches an unintended character; post-migration it must not. * chore(#3412): src/pattern.cts owns runtime-value regex construction Phase 1 of epic #3212 (ADR-3212 §1/§2/§6/§7). Adds the pattern seam delegating to the built-in RegExp.escape, deletes every hand-rolled copy, and raises the Node floor to the Active LTS line. The census was low, three times over. ADR-3212 counted 10 copies; a graph query found 12; the new lint rule — once live — found 27 more. The difference is that the census counted named helper FUNCTIONS while the rule counts the escape SHAPE, so inline .replace(<class>, '\$&') copies were never in scope. ADR §1's actual requirement is that no module outside the seam escapes a value for regex use, so all of them are, and CLAUDE.md's no-defer rule makes them this change's work. Fourth consecutive epic here whose copy count was low — the argument for ADR-3180 Amendment 3's "state N found by the guard" rule. Also corrected mid-implementation: the survey reported phase-id.cts's escapeRegex had 0 external importers. It had 8 production importers, making its removal a public-surface change to an ADR-2121-owned module and requiring an update to that ADR's locked-surface test. Blast radius revised Medium-High -> High. RegExp.escape is match-equivalent but NOT text-equivalent: it hex-escapes the leading char of nearly every string ("abc" -> "\x61bc"). Equivalence is proven by a seeded fast-check property test against the deleted implementation as oracle. It also fixes a latent bug: a hyphen-bearing value interpolated into a character class previously formed a real range and matched an unintended character. Node floor 22 -> 24 (RegExp.escape is Node 24+), across engines, .nvmrc, package-lock, 9 CI matrix entries, and 5 docs. The aggregate `required-tests` context is unchanged and no job was added or removed, so branch protection cannot be orphaned by the dropped lanes. Enforced by eslint-rules/no-adhoc-regex-escape.cjs (shape-matched, with structural provenance for reviewed pattern-fragment constants rather than a name heuristic) plus a whole-tree companion guard covering the directories ESLint's globs miss. * fix(#3412): close the _SOURCE guard evasion, correct two false claims Three findings from the orthogonal review pass, all fixed. 1. The ESLint rule's `_SOURCE` provenance fallback was pure identifier- name matching with no binding check, so `new RegExp(userInput_SOURCE)` — a function parameter — sailed past the guard. That is the same rename-evasion class issue #3410 documents, reopened by the very fallback meant to complement the structural check. Now bound to the identifier's actual binding kind: import, require-derived const, or module-scope const; parameters, `let`/`var`, and unresolvable bindings fail closed. Four RuleTester cases cover the evasion and prove the legitimate cross-module case still passes. 2. src/pattern.cts's own header carried the stale pre-correction counts (12 copies / 17 call sites) while CONTEXT.md and the design doc carried the corrected ones (~39 / ~44) — a self-contradiction inside the PR whose entire purpose is deleting divergent copies. Rewritten, preserving the durable lesson: a named-function census cannot see inline copies; only a shape-matching guard can. 3. The claim that all deleted copies threw TypeError on non-string was false. phase-id.cts's copy — the one with 8 external importers — did String(value).replace(...) and never threw. The seam's locked signature does not coerce, so this is a real, now-disclosed behavior change rather than the pure preservation the tests asserted. Audited all 32 invocations across the 8 importers and 6 in-file callers: every one is safe by construction (upstream truthy guard or a string-producing derivation), verified by runtime probe against the compiled modules rather than by TS compilation, which cannot see a runtime undefined. Corrected the false claim in both the test comment and the design doc, and added it to Known limits. * docs(#3412): add Changed changeset for the Node 24 floor The only user-visible break in this phase. The escape-behavior change is internal and match-equivalent, so it carries no user-facing note. * fix(#3412): resolve the seam's require graph in script fixtures and packaging Checkpoint 2 came back red with 90 failures on the node24 lane. Three distinct defects, all introduced by routing scripts/ through the new pattern seam, none reproducible by any local gate: 1. ~82 failures — tests/adr-index-gate.test.cjs and tests/removed-but-needed-lint.test.cjs copy a scripts/*.cjs into an mkdtemp fixture and spawn it there (necessary: those scripts resolve their scan root from __dirname/.., so running the real script would scan the real repo). Each harness hand-listed the dependencies to copy alongside. Adding require('../gsd-core/bin/lib/pattern.cjs') to gen-adr-index.cjs made both lists silently incomplete -> MODULE_NOT_FOUND, plus 17 downstream 'did not emit parseable JSON' failures from the same crash. Fixed as a class, not an instance: new tests/helpers/copy-script- fixture.cjs walks a script's transitive static relative-require graph and copies it, so dependencies are derived and never re-declared. It throws (naming the unbuilt artifact) instead of letting the child die with a bare MODULE_NOT_FOUND. Verified for all four seam-consuming scripts: gen-adr-index, lint-removed-but-needed, gen-loop-host- contract, sync-runtime-launcher. 2. 2 failures — scripts/ ships wholesale but eslint-rules/ does not, so the new scripts/lint-no-adhoc-regex-escape.cjs would be MODULE_NOT_FOUND in a published install (#2858 guard). Excluded from the tarball, matching the existing precedent for gen-emitted- baseline.cjs, which is excluded for the identical reason, and locked with a test modeled on that one. Confirmed against a real npm pack: 890 files, 0 from eslint-rules/, and gsd-core/bin/lib/pattern.cjs present (so the other four scripts' requires are legitimate). 3. 6 failures — tests/phase-id.test.cjs asserted the literal escaped source text ('0*29', 'PROJ-42'). RegExp.escape is match-equivalent to the retired hand-rolled escaper but NOT text-equivalent: it hex- escapes the leading character and all hyphens ('0*\x329', '\x50ROJ\x2d42'). Verified NOT a behavior change — 576 match decisions across all three real interpolation prefixes, zero divergence. Those tests now compile each source into the same heading regex src/roadmap.cts's searchPhaseInContent builds and assert what matches and what does not, including the 'i'-flag canonicalization the hex escape has to preserve. Re-pinning the new literals would have rebuilt the same brittleness one layer down. Adds a test for the property the escape exists for: a dot in '1.2' must not act as a wildcard. Also shares one definition of 'a require' between the packaging guard and the fixture copier, so the two cannot disagree about what they scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): refuse to copy a fixture dependency outside the fixture root copyScriptWithDeps resolved each relative require and joined the repo-relative result onto fixtureRoot. A require resolving OUTSIDE the repo yields a '../'-prefixed relative path, so path.join climbed out of the fixture and wrote into the surrounding temp dir (verified: repoRoot=/repo + depAbs=/etc/passwd wrote /tmp/etc/passwd). No script in the tree does this today, so this closes an available escape rather than an active one. Refuses via the existing unresolved- require path so the failure names the offending specifier. Covered by a negative proof that the guard fires and that nothing lands outside the fixture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3412): parse requires instead of pattern-matching them; restore the foreign-prefix contract Applies all findings from the second orthogonal review round, re-run because real code changed after round 1. HIGH (security) — extractRequires stripped BLOCK comments before LINE comments, so a '//' comment containing '/*' opened a phantom block comment, and a '//' inside a string literal truncated the line. Both hid real requires: 'const u="http://x"; require("./real.cjs")' returned [], and four real requires in gsd-core/bin/gsd-tools.cjs were invisible. Replaced with a real AST parse via espree. This is ADR-3212's own Decision 4 — tokenizer-first for stateful grammars — applied to the case it describes; comment/string/regex nesting is exactly such a grammar, which is why the regex version was wrong. The function was moved byte-identical out of the #2858 packaging guard, so the bug PRE-DATES this branch and has been a live blind spot there: a shipped script could have required an unshipped path undetected. Fixing it makes that guard strictly stronger than on next. espree is promoted from a transitive eslint dependency to an explicit devDependency rather than relying on hoisting. The script parse attempt sets ecmaFeatures.globalReturn because Node wraps CommonJS bodies in a function, making a top-level return legal — scripts/check-coverage-gate .cjs relies on it, and without the flag the guard throws on a file it is supposed to scan. Verified 0 unparseable across all 324 .cjs/.js under scripts/, bin/, and gsd-core/bin/, and 0 new violations against a real npm pack, so the exact extractor does not newly fail the guard. MEDIUM (security) — the repo-containment check guarded dependencies but not the entry path. One escapesContainment predicate now guards both. LOW (security) — containment was lexical while fs follows symlinks, and a directory symlink could mint a fresh dedupe key per level. realpath now resolves both repoRoot and each dependency before the decision, and the realpath-derived path is the dedupe key. Destination layout still uses the original repo-relative path, so copied trees are unchanged. MAJOR (standards) — the round-1 behavioral rewrite of phase-id tests lost the foreign-prefix contract: every assertion was satisfied by an impl returning [A-Z]+\x2d42, i.e. ANY project code — the exact #3599 bug class the exact-source prevents. The literal assertions it replaced were catching this. Now asserts the compiled regex REJECTS a different prefix with the same number. MAJOR (standards) — the test hand-duplicated production's heading regex with no parity guard (CLAUDE.md's 'Generative Fix Divergence'). Removed the parallel surface instead of policing it: src/roadmap.cts exports buildPhaseHeadingRegex, searchPhaseInContent calls it, the test imports it. Byte-identical .source and .flags verified for both escaped forms. MINOR — '..foo' no longer false-flagged as an escape; the inverted spurious-vs-missing doc claim corrected; the dead allow-test-rule header removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3412): backfill changeset pr number to 3416 * fix(#3412): make the escape guard's own regex linear, reword an injection-scan collision Two CI failures on PR #3416, both in code this branch added. CodeQL js/redos (high) — REPLACE_CALL_RE's outer alternation let a bracket run be consumed EITHER by the character-class branch OR one character at a time by the trailing catch-all, so a failing match explored both parses of every pair. Measured on the real regex: n=26 -> 204ms, n=28 -> 791ms, n=30 -> 3475ms, a clean 2^n. This script scans repo source, so a file with a long bracket run after '.replace(/' would hang CI outright — a guard against undisciplined pattern construction was itself the worst pattern in the diff. Fixed the way ADR-3212 already prescribes: the catch-all branch now excludes '[' and ']' so a bracket can only be consumed by the class branch (this is what makes it linear), and every quantifier is bounded (the locked bounded-quantifiers decision) as a second line of defense. Now 0ms at n=2000. Disclosed coverage tradeoff, recorded at the constant: a regex literal with a BARE unescaped ']' outside a class is no longer matched by this backstop. No census shape has that form, and the AST rule remains the primary detector. Verified the guard did not go blind doing it: a real census-shape violation is still reported, and an allow-adhoc-regex-escape suppression comment is still honored. Regression test drives the exported findViolations on a 2000-repetition adversarial input and asserts the RESULT. It makes no wall-clock assertion — elapsed-time tests are forbidden — so a regression surfaces as a harness timeout, which is the correct signal. Prompt injection scan — 'must not act as a regex wildcard' in a test comment matched the scanner's jailbreak pattern act\s+as\s+(a|an|if| my). Reworded to 'behave as'. Deliberately NOT allowlisted: silencing a whole test file over one phrase would blunt the scanner permanently, and the comment has nothing to do with injection. Neither failure was reachable from the remote runner — CodeQL and the injection scan are not in that matrix, so the sha it passed was green and still wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
963 lines
38 KiB
TypeScript
963 lines
38 KiB
TypeScript
/**
|
|
* Shell Command Projection Module
|
|
*
|
|
* Tracer-bullet seam for runtime-aware projection of serialized command text
|
|
* that GSD writes into runtime config or prints for copy/paste. This module
|
|
* does NOT execute commands; it only renders command text for external shells
|
|
* and runtimes.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/shell-command-projection.cjs
|
|
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
|
* from the prior hand-written .cjs; only types are added.
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
// Use non-destructured namespace import so test-time mock.method(childProcess, 'spawnSync')
|
|
// can intercept calls from this seam — destructured imports capture references
|
|
// at load time and become un-mockable.
|
|
import childProcess from 'node:child_process';
|
|
import { escapeRegex } from './pattern.cjs';
|
|
|
|
/**
|
|
* Convert a filesystem path to POSIX form (forward slashes) by translating the
|
|
* platform-native separator. Single seam for native→POSIX conversion.
|
|
*
|
|
* Prefer this over `p.replace(/\\/g, '/')`: the regex form hardcodes both
|
|
* separators and corrupts POSIX paths containing a literal backslash (a legal
|
|
* filename character). Splitting on `path.sep` only ever touches real
|
|
* separators — a no-op on POSIX, `\`→`/` on Windows.
|
|
*/
|
|
export function toPosixPath(p: string): string {
|
|
return p.split(path.sep).join(path.posix.sep);
|
|
}
|
|
|
|
/**
|
|
* Convert a filesystem path to the platform-native separator form. No-op on
|
|
* POSIX; `/`→`\` on Windows. Prefer this over a
|
|
* `process.platform === 'win32' ? p.replace(/\//g, '\\') : p` ternary.
|
|
*/
|
|
export function toNativePath(p: string): string {
|
|
return p.split(path.posix.sep).join(path.sep);
|
|
}
|
|
|
|
/**
|
|
* Normalize ALL backslashes to forward slashes, unconditionally and independent
|
|
* of the running OS. Use this when emitting a path into a POSIX/bash target
|
|
* (which may differ from the running platform — e.g. generating a Windows config
|
|
* on a Linux runner) or when parsing input whose separators are unpredictable.
|
|
*
|
|
* Contrast `toPosixPath`, which is running-OS-relative (splits on `path.sep`) and
|
|
* is for *this machine's* filesystem paths. Do NOT use `toPosixPath` for
|
|
* target-platform projection — on a Linux runner it would not convert a
|
|
* Windows-target path's backslashes.
|
|
*/
|
|
export function posixNormalize(p: string): string {
|
|
return p.replace(/\\/g, '/');
|
|
}
|
|
|
|
/**
|
|
* Return true when a managed hook command must be prefixed with PowerShell's
|
|
* call operator so a quoted executable token is invokable by the target
|
|
* runtime/shell combination.
|
|
*
|
|
* The `&`/no-`&` decision is keyed on the **effective hook-execution shell**
|
|
* (`opts.hookShell`), not on runtime alone — a single runtime (Claude Code)
|
|
* can host either Git Bash or PowerShell on Windows, and no single static
|
|
* command string is valid in both (#2236):
|
|
* - Git Bash: `"node.exe" "hook.js"` works; `& "node.exe" …` → syntax error.
|
|
* - PowerShell: `& "node.exe" "hook.js"` works; bare `"node.exe" …` →
|
|
* `Unexpected token`.
|
|
*
|
|
* Default is `false` (Git Bash form) for backward compatibility. Set
|
|
* `opts.hookShell = 'powershell'` to emit the PowerShell call-operator form.
|
|
*/
|
|
export function hookCommandNeedsPowerShellCallOperator(opts: { platform?: string; runtime?: string; hookShell?: string } = {}): boolean {
|
|
return opts.hookShell === 'powershell';
|
|
}
|
|
|
|
/**
|
|
* Project a fully-assembled hook command string for the target runtime.
|
|
*/
|
|
export function formatHookCommandForRuntime(command: string, opts: { platform?: string; runtime?: string; hookShell?: string } = {}): string {
|
|
return hookCommandNeedsPowerShellCallOperator(opts) ? `& ${command}` : command;
|
|
}
|
|
|
|
// #166/#580: Claude Code on Windows executes hook command strings inside Git
|
|
// Bash. A `.sh` hook wrapped with an explicit bash.exe path makes bash try to
|
|
// exec bash itself ("C:/.../bash.exe: cannot execute binary file"). Both install
|
|
// paths — global (buildHookCommand) and local (buildLocalShellHookCommand) — must
|
|
// drop the bash runner in this case and emit only the anchored script path.
|
|
// Centralized here so the two paths cannot silently drift apart again: the local
|
|
// path missed this guard and reintroduced the #166/#377 failure (#580).
|
|
export function shellHookOmitsBashRunner({ platform, runtime = 'generic', isShellHook = false }: { platform?: string; runtime?: string; isShellHook?: boolean } = {}): boolean {
|
|
const p = platform ?? process.platform;
|
|
return p === 'win32' && runtime === 'claude' && isShellHook;
|
|
}
|
|
|
|
// Builds the command string for a local-install managed `.sh` hook. Mirrors the
|
|
// global buildHookCommand path but uses the $CLAUDE_PROJECT_DIR-anchored prefix
|
|
// instead of an absolute configDir. On Claude/Windows the bash runner is dropped
|
|
// (see shellHookOmitsBashRunner) and the anchored script path is emitted alone —
|
|
// matching the global path. Elsewhere the resolved bash runner is required; a
|
|
// null runner yields null so callers skip registration instead of emitting a
|
|
// broken hook (#3393).
|
|
export function buildLocalShellHookCommand({ localPrefix, hookFile, bashRunner, runtime = 'generic', platform = process.platform }: {
|
|
localPrefix?: string | null;
|
|
hookFile?: string | null;
|
|
bashRunner?: string | null;
|
|
runtime?: string;
|
|
platform?: string;
|
|
}): string | null {
|
|
if (!localPrefix || !hookFile) return null;
|
|
const scriptPath = `${localPrefix}/hooks/${hookFile}`;
|
|
if (shellHookOmitsBashRunner({ platform, runtime, isShellHook: true })) {
|
|
return formatHookCommandForRuntime(scriptPath, { platform, runtime });
|
|
}
|
|
if (!bashRunner) return null;
|
|
return projectShellCommandText({
|
|
runnerToken: bashRunner,
|
|
argTokens: [scriptPath],
|
|
runtime,
|
|
platform,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Project a managed hook script path token for serialized shell commands.
|
|
* Windows managed hook commands normalize to forward slashes so the same path
|
|
* survives JSON/TOML/config surfaces consistently.
|
|
*/
|
|
export function formatManagedHookScriptToken(scriptPath: string, opts: { platform?: string } = {}): string | null {
|
|
const platform = opts.platform || process.platform;
|
|
if (platform !== 'win32') return null;
|
|
return JSON.stringify(posixNormalize(scriptPath));
|
|
}
|
|
|
|
export function projectLocalHookPrefix({ runtime: _runtime = 'claude', dirName, hookPathStyle }: { runtime?: string; dirName?: string | null; hookPathStyle?: string | null }): string | undefined | null {
|
|
if (!dirName) return dirName;
|
|
// Descriptor-driven (ADR-1239 / #2096): folded from a hardcoded
|
|
// `runtime === 'antigravity'` literal into the runtime's declared
|
|
// `hostBehaviors.hookPathStyle`. Runtimes that always run project hooks
|
|
// with the project dir as cwd (Antigravity today) declare 'raw' and get
|
|
// the bare dirName; every other runtime keeps the $CLAUDE_PROJECT_DIR-
|
|
// anchored prefix. `runtime` itself is now unused here but stays in the
|
|
// signature for call-site/back-compat parity (kept `_`-prefixed to
|
|
// satisfy no-unused-vars).
|
|
return (hookPathStyle === 'raw')
|
|
? dirName
|
|
: `"$CLAUDE_PROJECT_DIR"/${dirName}`;
|
|
}
|
|
|
|
export function projectPortableHookBaseDir({ configDir, homeDir }: { configDir?: string | null; homeDir?: string | null }): string {
|
|
const normalizedConfigDir = posixNormalize(String(configDir || ''));
|
|
const normalizedHome = posixNormalize(String(homeDir || ''));
|
|
if (!normalizedConfigDir || !normalizedHome) return normalizedConfigDir;
|
|
return normalizedConfigDir.startsWith(normalizedHome)
|
|
? '$HOME' + normalizedConfigDir.slice(normalizedHome.length)
|
|
: normalizedConfigDir;
|
|
}
|
|
|
|
export function projectShellCommandText({
|
|
runnerToken,
|
|
argTokens = [],
|
|
runtime = 'generic',
|
|
platform = process.platform,
|
|
hookShell,
|
|
}: {
|
|
runnerToken?: string | null;
|
|
argTokens?: (string | null | undefined)[];
|
|
runtime?: string;
|
|
platform?: string;
|
|
hookShell?: string;
|
|
}): string | null {
|
|
if (!runnerToken) return null;
|
|
const parts = [runnerToken, ...argTokens.filter(Boolean)] as string[];
|
|
return formatHookCommandForRuntime(parts.join(' '), { platform, runtime, hookShell });
|
|
}
|
|
|
|
export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform, hookShell }: {
|
|
absoluteRunner?: string | null;
|
|
scriptPath?: string | null;
|
|
runtime?: string;
|
|
platform?: string;
|
|
hookShell?: string;
|
|
}): string | null {
|
|
if (!absoluteRunner || !scriptPath) return null;
|
|
const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
|
|
return projectShellCommandText({
|
|
runnerToken: absoluteRunner,
|
|
argTokens: [JSON.stringify(normalizedScriptPath)],
|
|
runtime,
|
|
platform,
|
|
hookShell,
|
|
});
|
|
}
|
|
|
|
const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
|
|
'settings-json': new Set([
|
|
'gsd-check-update.js',
|
|
'gsd-config-reload.js',
|
|
'gsd-statusline.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-read-injection-scanner.js',
|
|
'gsd-update-banner.js',
|
|
'gsd-workflow-guard.js',
|
|
]),
|
|
'codex-toml': new Set([
|
|
'gsd-check-update.js',
|
|
]),
|
|
};
|
|
|
|
const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
|
|
'settings-json': new Set([
|
|
'gsd-check-update.js',
|
|
'gsd-config-reload.js',
|
|
'gsd-statusline.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-read-injection-scanner.js',
|
|
'gsd-update-banner.js',
|
|
'gsd-workflow-guard.js',
|
|
'gsd-session-state.sh',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-phase-boundary.sh',
|
|
]),
|
|
'codex-toml': new Set([
|
|
'gsd-check-update.js',
|
|
]),
|
|
'codex-hooks-json': new Set([
|
|
'gsd-check-update.js',
|
|
// #3426: Windows .cmd shim for Codex hook — must be treated as managed so
|
|
// reconcileCodexHooksJsonSessionStart can replace stale node-runner commands
|
|
// with the .cmd shim on reinstall (and vice-versa on cross-platform moves).
|
|
'gsd-check-update.cmd',
|
|
// #772: context-monitor is now registered for Codex SubagentStart/Stop/PostToolUse.
|
|
'gsd-context-monitor.js',
|
|
// #772: Windows .cmd shim for gsd-context-monitor — same #3426 pattern.
|
|
'gsd-context-monitor.cmd',
|
|
]),
|
|
};
|
|
|
|
const LEGACY_MANAGED_HOOK_ALIASES_BY_SURFACE: Record<string, Set<string>> = {
|
|
'codex-toml': new Set([
|
|
'gsd-update-check.js',
|
|
]),
|
|
'codex-hooks-json': new Set([
|
|
'gsd-update-check.js',
|
|
]),
|
|
};
|
|
|
|
function managedHookSurfaceSet(surface: string = 'settings-json'): Set<string> {
|
|
return MANAGED_HOOK_BASENAMES_BY_SURFACE[surface] || MANAGED_HOOK_BASENAMES_BY_SURFACE['settings-json'];
|
|
}
|
|
|
|
export function isManagedHookBasename(scriptPathOrBasename: string | null | undefined, opts: { surface?: string } = {}): boolean {
|
|
if (!scriptPathOrBasename) return false;
|
|
const surface = opts.surface || 'settings-json';
|
|
const basename = String(scriptPathOrBasename).split(/[\\/]/).pop() || '';
|
|
return managedHookSurfaceSet(surface).has(basename);
|
|
}
|
|
|
|
function managedHookCommandSurfaceSet(surface: string = 'settings-json', includeLegacyAliases: boolean = false): Set<string> {
|
|
const base = MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE[surface]
|
|
|| MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE['settings-json'];
|
|
if (!includeLegacyAliases) return base;
|
|
const aliases = LEGACY_MANAGED_HOOK_ALIASES_BY_SURFACE[surface];
|
|
if (!aliases || aliases.size === 0) return base;
|
|
return new Set([...base, ...aliases]);
|
|
}
|
|
|
|
export function isManagedHookCommand(commandText: unknown, opts: { surface?: string; includeLegacyAliases?: boolean; configDir?: string; args?: unknown[] } = {}): boolean {
|
|
if (typeof commandText !== 'string') return false;
|
|
const surface = opts.surface || 'settings-json';
|
|
const includeLegacyAliases = opts.includeLegacyAliases === true;
|
|
const managedBasenames = managedHookCommandSurfaceSet(surface, includeLegacyAliases);
|
|
if (!managedBasenames || managedBasenames.size === 0) return false;
|
|
|
|
// args-form check: the managed hook filename may appear in args[] rather than
|
|
// in command when a windowless launcher wraps the Node invocation. (#976)
|
|
// Only treat as managed when an arg basename matches the managed hook set —
|
|
// prevents false-positives for non-GSD entries that happen to share a path segment.
|
|
if (Array.isArray(opts.args) && opts.args.length > 0) {
|
|
for (const arg of opts.args) {
|
|
if (typeof arg !== 'string') continue;
|
|
const argBasename = posixNormalize(arg).split('/').pop() || '';
|
|
if (isManagedHookBasename(argBasename, { surface })) return true;
|
|
}
|
|
}
|
|
|
|
const normalizedCommand = posixNormalize(commandText);
|
|
|
|
if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
|
|
const normalizedHooksDir = `${posixNormalize(path.join(opts.configDir, 'hooks'))}/`;
|
|
if (!normalizedCommand.includes(normalizedHooksDir)) return false;
|
|
}
|
|
|
|
for (const basename of managedBasenames) {
|
|
const escapedBasename = escapeRegex(basename);
|
|
const pattern = new RegExp(`(^|[\\\\/\\s"'` + '`' + `])${escapedBasename}(?=$|[\\s"'` + '`' + `])`);
|
|
if (pattern.test(normalizedCommand)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detect a `"$VAR"/rest` anchored hook-script token — a path whose leading
|
|
* shell variable is already double-quoted with the remainder left bare (the
|
|
* shape `projectLocalHookPrefix` emits for local installs, e.g.
|
|
* `"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-x.js`). Such a token is ALREADY a
|
|
* valid, correctly-quoted shell argument and must never be re-quoted.
|
|
*/
|
|
const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
|
|
|
|
/**
|
|
* Projection helper for legacy settings.json hook rewrites.
|
|
*
|
|
* Non-Windows keeps the original script token shape when provided (single
|
|
* quote / bareword / quoted), while Windows normalizes to double-quoted
|
|
* forward-slash path tokens for stable cross-shell behavior.
|
|
*/
|
|
export function projectLegacySettingsHookCommand({
|
|
absoluteRunner,
|
|
scriptPath,
|
|
scriptToken,
|
|
runtime = 'generic',
|
|
platform = process.platform,
|
|
}: {
|
|
absoluteRunner?: string | null;
|
|
scriptPath?: string | null;
|
|
scriptToken?: string | null;
|
|
runtime?: string;
|
|
platform?: string;
|
|
}): string | null {
|
|
if (!absoluteRunner || !scriptPath) return null;
|
|
const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
|
|
// #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
|
|
// quoted prefix (local installs) is already a valid shell token — only the
|
|
// variable is quoted, the rest is bare. JSON.stringify-ing it on Windows
|
|
// yields `"\"$CLAUDE_PROJECT_DIR\"/..."` (escaped quotes inside an outer
|
|
// quote); node then receives an argument that *starts* with a `"`, treats it
|
|
// as relative, and dies with MODULE_NOT_FOUND. Emit anchored tokens verbatim;
|
|
// only bare absolute paths (which may contain spaces, e.g. "Program Files")
|
|
// need the JSON.stringify quoting. Scoped to win32: the non-Windows branch
|
|
// already preserves the caller's `scriptToken` (which is the bare anchored
|
|
// token for these inputs), so it never had the double-quote bug.
|
|
const commandScriptToken = platform === 'win32'
|
|
? (ANCHORED_HOOK_SCRIPT_TOKEN.test(normalizedScriptPath)
|
|
? normalizedScriptPath
|
|
: JSON.stringify(normalizedScriptPath))
|
|
: (scriptToken || JSON.stringify(normalizedScriptPath));
|
|
return projectShellCommandText({
|
|
runnerToken: absoluteRunner,
|
|
argTokens: [commandScriptToken],
|
|
runtime,
|
|
platform,
|
|
});
|
|
}
|
|
|
|
// Implements the TOML v1.0.0 basic-string escaping grammar (toml.md, "Basic
|
|
// strings" section, https://toml.io/en/v1.0.0#string): a basic string must
|
|
// escape the quotation mark, backslash, and control characters other than
|
|
// tab (U+0000-U+0008, U+000A-U+001F, U+007F). Compact escapes are used where
|
|
// TOML defines them (\b \t \n \f \r \" \\); every other character in the
|
|
// required ranges falls back to \uXXXX. See #3118 — an earlier version
|
|
// escaped only backslash and quote, so a raw newline/CR/NUL in a value
|
|
// produced an unparseable config.toml.
|
|
const TOML_COMPACT_ESCAPES: Record<string, string> = {
|
|
'\x08': '\\b',
|
|
'\x09': '\\t',
|
|
'\x0A': '\\n',
|
|
'\x0C': '\\f',
|
|
'\x0D': '\\r',
|
|
};
|
|
|
|
// U+0000-U+0008, U+000A-U+001F, U+007F — control characters other than tab
|
|
// (U+0009), which the grammar permits unescaped.
|
|
const TOML_MUST_ESCAPE_CONTROL_CHARS = /[\x00-\x08\x0A-\x1F\x7F]/g;
|
|
|
|
export function escapeTomlDoubleQuotedString(value: unknown): string {
|
|
return String(value)
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/"/g, '\\"')
|
|
.replace(TOML_MUST_ESCAPE_CONTROL_CHARS, (ch) => {
|
|
const compact = TOML_COMPACT_ESCAPES[ch];
|
|
if (compact) return compact;
|
|
return `\\u${ch.codePointAt(0)!.toString(16).padStart(4, '0')}`;
|
|
});
|
|
}
|
|
|
|
export function projectCodexHookTomlCommand({ absoluteRunner, scriptPath, platform = process.platform }: {
|
|
absoluteRunner?: string | null;
|
|
scriptPath?: string | null;
|
|
platform?: string;
|
|
}): string | null {
|
|
const command = projectManagedHookCommand({
|
|
absoluteRunner,
|
|
scriptPath,
|
|
runtime: 'codex',
|
|
platform,
|
|
});
|
|
return command === null ? null : escapeTomlDoubleQuotedString(command);
|
|
}
|
|
|
|
export function escapePowerShellSingleQuoted(value: unknown): string {
|
|
return String(value).replace(/'/g, "''");
|
|
}
|
|
|
|
export function escapePosixDoubleQuoted(value: unknown): string {
|
|
return String(value).replace(/[\\$"`]/g, '\\$&');
|
|
}
|
|
|
|
export function escapeSingleQuotedShellLiteral(value: unknown): string {
|
|
return String(value).replace(/'/g, "'\\''");
|
|
}
|
|
|
|
/**
|
|
* The `export PATH="<dir>:$PATH"` line every persistence lane appends, plus the escaped directory
|
|
* token it embeds. One builder because three lanes emit this line: a lane that re-escapes it
|
|
* locally is how #3118 shipped a `$(…)` into ~/.bashrc, where it ran on every new shell. The
|
|
* escaping is for the line's FINAL context — a double-quoted string in an rc file — not for
|
|
* whatever transport (an `echo`, a paste) it passes through on the way there.
|
|
*/
|
|
export function projectPathExportLine(targetDir: unknown): { escapedDir: string; line: string } {
|
|
const escapedDir = escapePosixDoubleQuoted(String(targetDir));
|
|
return { escapedDir, line: `export PATH="${escapedDir}:$PATH"` };
|
|
}
|
|
|
|
interface ShellAction {
|
|
label: string | null;
|
|
shell: string;
|
|
command: string;
|
|
}
|
|
|
|
/**
|
|
* Why a PATH suggestion produced no actions. An empty `shellActions` alone folds two different
|
|
* facts together — "no target directory was given" and "this target directory cannot be
|
|
* expressed as a shell command" — and a caller that cannot tell them apart prints a header with
|
|
* nothing under it (#3118).
|
|
*/
|
|
export const PATH_ACTION_REASON = Object.freeze({
|
|
NO_TARGET_DIR: 'no_target_dir',
|
|
WIN32_RESERVED_QUOTE: 'win32_reserved_quote',
|
|
});
|
|
|
|
export function renderShellActionLines(shellActions: ShellAction[] = []): string[] {
|
|
return shellActions.map((action) => {
|
|
if (!action || !action.command) return '';
|
|
return action.label ? `${action.label}: ${action.command}` : action.command;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
export function projectPathActionProjection({
|
|
mode = 'repair',
|
|
targetDir,
|
|
platform = process.platform,
|
|
}: {
|
|
mode?: string;
|
|
targetDir?: string | null;
|
|
platform?: string;
|
|
}): { shellActions: ShellAction[]; actionLines: string[]; reason?: string } {
|
|
if (!targetDir) return { shellActions: [], actionLines: [], reason: PATH_ACTION_REASON.NO_TARGET_DIR };
|
|
|
|
// #3118: `"` is reserved on Windows, so a path containing one cannot exist — and it would close
|
|
// cmd's quoted region in the `powershell -Command "…"` lane below, turning the rest into cmd
|
|
// input. There is no correct command to suggest for an impossible path: fail closed rather than
|
|
// emit one whose quoting can be broken.
|
|
if (platform === 'win32' && String(targetDir).includes('"')) return { shellActions: [], actionLines: [], reason: PATH_ACTION_REASON.WIN32_RESERVED_QUOTE };
|
|
|
|
const isWin32 = platform === 'win32';
|
|
|
|
let shellActions: ShellAction[];
|
|
if (isWin32) {
|
|
const psTargetDir = escapePowerShellSingleQuoted(targetDir);
|
|
const bashExportLine = escapeSingleQuotedShellLiteral(
|
|
projectPathExportLine(posixNormalize(String(targetDir))).line,
|
|
);
|
|
shellActions = [
|
|
{
|
|
label: 'PowerShell',
|
|
shell: 'powershell',
|
|
command: `[Environment]::SetEnvironmentVariable('PATH', '${psTargetDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`,
|
|
},
|
|
{
|
|
label: 'cmd.exe',
|
|
shell: 'cmd',
|
|
command: `powershell -Command "[Environment]::SetEnvironmentVariable('PATH', '${psTargetDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`,
|
|
},
|
|
{
|
|
label: 'Git Bash',
|
|
shell: 'bash',
|
|
command: `echo '${bashExportLine}' >> ~/.bashrc`,
|
|
},
|
|
];
|
|
} else if (mode === 'persist') {
|
|
const exportLine = escapeSingleQuotedShellLiteral(projectPathExportLine(targetDir).line);
|
|
const fishTargetDir = escapeSingleQuotedShellLiteral(String(targetDir));
|
|
shellActions = [
|
|
{
|
|
label: 'zsh',
|
|
shell: 'zsh',
|
|
command: `echo '${exportLine}' >> ~/.zshrc`,
|
|
},
|
|
{
|
|
label: 'bash',
|
|
shell: 'bash',
|
|
command: `echo '${exportLine}' >> ~/.bashrc`,
|
|
},
|
|
// #323: fish has no `export`/`$PATH`-list syntax. `fish_add_path` is the
|
|
// fish-native API (>= fish 3.2, 2021) that persists to the universal
|
|
// variable store and de-duplicates. The directory is single-quoted with
|
|
// the same POSIX literal escaping as the zsh/bash siblings — `'\''` is
|
|
// also a valid escaped single quote in fish between quote spans.
|
|
//
|
|
// #3118 review MINOR: a `targetDir` with a leading `-` (e.g. `-v`) is a
|
|
// legal directory name, but fish's argparse-based option scanning
|
|
// treats a leading-dash token as a flag REGARDLESS of quoting, so
|
|
// `fish_add_path '-v'` misparses it and prints "No paths to add, not
|
|
// setting anything." (exit 1) instead of adding the path. `--` is
|
|
// fish's standard end-of-options separator; verified empirically
|
|
// against a real fish 4.8.1 install that `fish_add_path -- '-v'`
|
|
// succeeds where the unseparated form fails.
|
|
{
|
|
label: 'fish',
|
|
shell: 'fish',
|
|
command: `fish_add_path -- '${fishTargetDir}'`,
|
|
},
|
|
];
|
|
} else {
|
|
shellActions = [
|
|
{
|
|
label: null,
|
|
shell: 'posix',
|
|
command: projectPathExportLine(targetDir).line,
|
|
},
|
|
];
|
|
}
|
|
|
|
return {
|
|
shellActions,
|
|
actionLines: renderShellActionLines(shellActions),
|
|
};
|
|
}
|
|
|
|
export function projectPersistentPathExportActions({ targetDir, platform = process.platform }: {
|
|
targetDir?: string | null;
|
|
platform?: string;
|
|
}): { shellActions: ShellAction[]; reason?: string } {
|
|
const projected = projectPathActionProjection({
|
|
mode: 'persist',
|
|
targetDir,
|
|
platform,
|
|
});
|
|
return projected.reason === undefined
|
|
? { shellActions: projected.shellActions }
|
|
: { shellActions: projected.shellActions, reason: projected.reason };
|
|
}
|
|
|
|
|
|
// ─── Subprocess dispatch ──────────────────────────────────────────────────────
|
|
|
|
export interface SpawnResultOutput {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
signal: NodeJS.Signals | null;
|
|
error: Error | null;
|
|
timedOut: boolean;
|
|
}
|
|
|
|
/**
|
|
* Returns true when a spawn/exec result indicates the subprocess was killed
|
|
* by a timeout, i.e. it never completed and reported a real answer. This is
|
|
* the single shared definition of "did this subprocess time out" — worktree
|
|
* safety (src/worktree-safety.cts) and worktree base-ref detection
|
|
* (src/worktree-base-ref.cts) both call this instead of maintaining their
|
|
* own copies (#3050 — "Generative Fix Divergence").
|
|
*
|
|
* Only `error.code === 'ETIMEDOUT'` is checked. Node.js guarantees this
|
|
* cross-platform when `spawnSync`'s `timeout` option fires. The `signal ===
|
|
* 'SIGTERM'` check some earlier code paired with it is platform-fragile —
|
|
* Windows does not necessarily report SIGTERM the same way — and pairing it
|
|
* in as a REQUIRED conjunct risks a false NEGATIVE (a timeout that silently
|
|
* fails to trip the guard) on that platform. There is no false-positive risk
|
|
* from dropping it: an externally-delivered SIGTERM (not a timeout) leaves
|
|
* `error` null, so `error.code === 'ETIMEDOUT'` alone still won't match it.
|
|
*/
|
|
export function isSpawnTimeout(result: { error?: unknown }): boolean {
|
|
return (result.error as NodeJS.ErrnoException | null | undefined)?.code === 'ETIMEDOUT';
|
|
}
|
|
|
|
function _spawnResult(result: { error?: NodeJS.ErrnoException | null; status?: number | null; stdout?: Buffer | string | null; stderr?: Buffer | string | null; signal?: NodeJS.Signals | null }, program: string): SpawnResultOutput {
|
|
if (result.error && result.error.code === 'ENOENT') {
|
|
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error, timedOut: false };
|
|
}
|
|
const signal = result.signal ?? null;
|
|
const error = result.error ?? null;
|
|
return {
|
|
exitCode: result.status ?? 1,
|
|
stdout: (result.stdout ?? '').toString().trim(),
|
|
stderr: (result.stderr ?? '').toString().trim(),
|
|
signal,
|
|
error,
|
|
// Reuse the single shared timeout predicate (isSpawnTimeout, below) rather
|
|
// than re-deriving it here — see that function's docstring for why only
|
|
// error.code === 'ETIMEDOUT' is checked (not signal === 'SIGTERM').
|
|
timedOut: isSpawnTimeout({ error }),
|
|
};
|
|
}
|
|
|
|
export function execGit(args: string[], opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}): SpawnResultOutput {
|
|
// Non-interactive defaults: a hung credential prompt or terminal-input
|
|
// probe must surface as a timeout, not block the tool forever. Callers
|
|
// can override via opts.env.
|
|
const env = {
|
|
...process.env,
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
GCM_INTERACTIVE: 'never',
|
|
...(opts.env || {}),
|
|
};
|
|
const result = childProcess.spawnSync('git', args, {
|
|
cwd: opts.cwd,
|
|
env,
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
timeout: opts.timeout ?? 10_000,
|
|
windowsHide: true,
|
|
});
|
|
return _spawnResult(result, 'git');
|
|
}
|
|
|
|
export function execNpm(args: string[], opts: { cwd?: string; timeout?: number } = {}): SpawnResultOutput {
|
|
const result = childProcess.spawnSync('npm', args, {
|
|
cwd: opts.cwd,
|
|
shell: process.platform === 'win32',
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: opts.timeout ?? 15_000,
|
|
windowsHide: true,
|
|
});
|
|
return _spawnResult(result, 'npm');
|
|
}
|
|
|
|
export function execTool(program: string, args: string[], opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}): SpawnResultOutput {
|
|
const result = childProcess.spawnSync(program, args, {
|
|
cwd: opts.cwd,
|
|
env: opts.env ? { ...process.env, ...opts.env } : undefined,
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
timeout: opts.timeout ?? 30_000,
|
|
windowsHide: true,
|
|
});
|
|
return _spawnResult(result, program);
|
|
}
|
|
|
|
/**
|
|
* Result shape for {@link dispatchGsdCommand}. Modeled on the existing
|
|
* `{exitCode,stdout,stderr,signal,error}` seam above, but flattened to the
|
|
* fields callers actually need (never leaks a raw Error/signal — see
|
|
* `timedOut`), per the "Unbounded Subprocesses" contract (CLAUDE.md):
|
|
* degrade to a structured result on timeout/ENOENT, never throw.
|
|
*/
|
|
export interface DispatchGsdCommandResult {
|
|
ok: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number | null;
|
|
timedOut: boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute path to gsd-tools.cjs relative to THIS module.
|
|
*
|
|
* This file compiles to gsd-core/bin/lib/shell-command-projection.cjs — a
|
|
* sibling of gsd-core/bin/gsd-tools.cjs — so the relative walk-up is stable
|
|
* regardless of install location (global/local/dev-repo layouts all ship
|
|
* gsd-core/bin/ as a unit).
|
|
*/
|
|
export function resolveGsdToolsPath(): string {
|
|
return path.resolve(__dirname, '..', 'gsd-tools.cjs');
|
|
}
|
|
|
|
/**
|
|
* Subprocess-shim dispatch to gsd-tools.cjs (ADR-1239 #2102 Stage 2).
|
|
*
|
|
* No fully-populated in-process command-routing hub exists anywhere in the
|
|
* tree — every `createHub()` caller (cjs-command-router-adapter.cts,
|
|
* phase-command-router.cts, command-routing-hub.cts's own tests) builds a
|
|
* single-family hub for its own narrow purpose. The ONLY dispatch path that
|
|
* covers the FULL family/subcommand surface is the gsd-tools.cjs CLI itself.
|
|
* This mirrors the SUBPROCESS-REUSE precedent already established for the
|
|
* OpenCode/Kilo hook bridge (see .opencode/plugins/gsd-core.js header:
|
|
* "Architecture: SUBPROCESS REUSE ... spawns existing hook scripts as child
|
|
* processes") — the same pattern, applied to command dispatch instead of
|
|
* hook dispatch.
|
|
*
|
|
* Output-flag choice (verified by direct invocation — see #2102 dispatch
|
|
* notes for the sample invocations): always pass `--raw` (undecorated,
|
|
* programmatically-consumable stdout on success) and `--json-errors` (a
|
|
* structured `{ok:false,reason,message}` JSON object on stderr, with a
|
|
* non-zero exit, instead of a free-text "Error: ..." line). Both are global
|
|
* flags accepted by every gsd-tools.cjs family/subcommand, so passing them
|
|
* unconditionally is safe for the full command surface.
|
|
*
|
|
* `family` maps 1:1 onto gsd-tools.cjs's first positional argv token;
|
|
* `subcommand` (when present) onto the second — e.g.
|
|
* `{family:'phase', subcommand:'add'}` → `gsd-tools.cjs phase add`. An empty
|
|
* `subcommand` is omitted entirely (some families, e.g. `config-path`, take
|
|
* no subcommand).
|
|
*
|
|
* NEVER throws. Degrades to `{ ok:false, ... }` on:
|
|
* - a missing/invalid "family" (validated locally, no subprocess spawned)
|
|
* - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
|
|
* - a wall-clock timeout (`timedOut:true`, via the shared `isSpawnTimeout`
|
|
* predicate defined above in this file — also used by worktree-safety.cts
|
|
* and worktree-base-ref.cts)
|
|
* - any other unanticipated throw from the underlying spawn (defensive
|
|
* try/catch — execTool itself is spawnSync-based and does not throw).
|
|
*/
|
|
export function dispatchGsdCommand({
|
|
family,
|
|
subcommand,
|
|
args = [],
|
|
cwd,
|
|
timeout = 30_000,
|
|
gsdToolsPath,
|
|
}: {
|
|
family?: string;
|
|
subcommand?: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
timeout?: number;
|
|
gsdToolsPath?: string;
|
|
} = {}): DispatchGsdCommandResult {
|
|
if (typeof family !== 'string' || family.length === 0) {
|
|
return {
|
|
ok: false,
|
|
stdout: '',
|
|
stderr: 'dispatchGsdCommand requires a non-empty string "family".',
|
|
code: null,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
|
|
const resolvedCwd = cwd || process.cwd();
|
|
const toolsPath = gsdToolsPath || resolveGsdToolsPath();
|
|
const argv = [
|
|
toolsPath,
|
|
family,
|
|
...(subcommand ? [subcommand] : []),
|
|
...(Array.isArray(args) ? args : []),
|
|
'--cwd', resolvedCwd,
|
|
'--raw',
|
|
'--json-errors',
|
|
];
|
|
|
|
let result: SpawnResultOutput;
|
|
try {
|
|
result = execTool(process.execPath, argv, { cwd: resolvedCwd, timeout });
|
|
} catch (e) {
|
|
// Defensive belt-and-suspenders: execTool is spawnSync-based and does not
|
|
// throw today, but a degraded result here keeps this seam's no-throw
|
|
// contract true even under an unanticipated future failure mode.
|
|
return {
|
|
ok: false,
|
|
stdout: '',
|
|
stderr: e instanceof Error ? e.message : String(e),
|
|
code: null,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
|
|
// Delegates to the single shared predicate defined above in this file
|
|
// (#3050 — "Generative Fix Divergence") instead of a local inline copy.
|
|
const timedOut = isSpawnTimeout(result);
|
|
|
|
return {
|
|
ok: result.exitCode === 0 && !timedOut,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
code: result.exitCode,
|
|
timedOut,
|
|
};
|
|
}
|
|
|
|
export function probeTty(opts: { platform?: string } = {}): string | null {
|
|
const platform = opts.platform ?? process.platform;
|
|
if (platform === 'win32') return null;
|
|
try {
|
|
const ttyPath = childProcess.execFileSync('tty', [], {
|
|
encoding: 'utf-8',
|
|
stdio: ['inherit', 'pipe', 'ignore'],
|
|
timeout: 5_000,
|
|
}).trim();
|
|
if (!ttyPath || ttyPath === 'not a tty') return null;
|
|
return ttyPath;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Platform file I/O ────────────────────────────────────────────────────────
|
|
|
|
function _normalizeMd(content: string): string {
|
|
if (!content || typeof content !== 'string') return content;
|
|
let text = content.replace(/\r\n/g, '\n');
|
|
const lines = text.split('\n');
|
|
const result: string[] = [];
|
|
const fenceRegex = /^```/;
|
|
const insideFence = new Array<boolean>(lines.length);
|
|
let fenceOpen = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (fenceRegex.test(lines[i].trimEnd())) {
|
|
if (fenceOpen) {
|
|
insideFence[i] = false;
|
|
fenceOpen = false;
|
|
} else {
|
|
insideFence[i] = false;
|
|
fenceOpen = true;
|
|
}
|
|
} else {
|
|
insideFence[i] = fenceOpen;
|
|
}
|
|
}
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const prev = i > 0 ? lines[i - 1] : '';
|
|
const prevTrimmed = prev.trimEnd();
|
|
const trimmed = line.trimEnd();
|
|
const isFenceLine = fenceRegex.test(trimmed);
|
|
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') result.push('');
|
|
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
|
|
if (i === 0 || !insideFence[i - 1]) result.push('');
|
|
}
|
|
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) && prevTrimmed !== '---') result.push('');
|
|
result.push(line);
|
|
if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '') result.push('');
|
|
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '') result.push('');
|
|
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
|
|
const next = lines[i + 1];
|
|
if (next !== undefined && next.trimEnd() !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) && !/^\s/.test(next)) result.push('');
|
|
}
|
|
}
|
|
text = result.join('\n');
|
|
text = text.replace(/\n{3,}/g, '\n\n');
|
|
text = text.replace(/\n*$/, '\n');
|
|
return text;
|
|
}
|
|
|
|
export function normalizeContent(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): { content: string; encoding: BufferEncoding } {
|
|
const encoding = opts.encoding ?? 'utf-8';
|
|
const isMd = path.extname(filePath).toLowerCase() === '.md';
|
|
let normalized: string;
|
|
if (isMd) {
|
|
normalized = _normalizeMd(content);
|
|
} else {
|
|
normalized = (content ?? '').replace(/\r\n/g, '\n').replace(/\n*$/, '\n');
|
|
}
|
|
return { content: normalized, encoding };
|
|
}
|
|
|
|
// Rename errnos that are transient on Windows: a concurrent reader (or an AV
|
|
// scanner / indexer) holding the target open makes renameSync fail briefly.
|
|
// Same idiom as capability-ledger.cts / capability-consent.cts.
|
|
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
const RENAME_MAX_ATTEMPTS = 3;
|
|
const RENAME_RETRY_BACKOFF_MS = 50;
|
|
|
|
/** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
|
|
let _renameSleepBuf: Int32Array | null = null;
|
|
function renameBackoff(): void {
|
|
if (_renameSleepBuf === null) _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
|
|
}
|
|
|
|
/**
|
|
* Atomic publish with bounded retry on transient Windows lock errnos.
|
|
* Returns null on success, or the final error if every attempt failed.
|
|
*/
|
|
function atomicRenameWithRetry(tmpPath: string, filePath: string): NodeJS.ErrnoException | null {
|
|
let renameErr: NodeJS.ErrnoException | null = null;
|
|
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
|
|
try {
|
|
fs.renameSync(tmpPath, filePath);
|
|
return null;
|
|
} catch (err) {
|
|
renameErr = err as NodeJS.ErrnoException;
|
|
if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
|
|
renameBackoff();
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return renameErr;
|
|
}
|
|
|
|
/**
|
|
* Drop-in replacement for `fs.renameSync(from, to)` that retries the transient
|
|
* Windows lock errnos (EPERM/EBUSY/EACCES — see DEFECT.WINDOWS-FS-OPS) a bounded
|
|
* number of times with a short backoff before rethrowing the final error.
|
|
*
|
|
* Idempotent on POSIX (the transient errnos do not occur), so callers retain
|
|
* identical semantics on macOS/Linux while gaining resilience on Windows where
|
|
* an antivirus scanner, indexer, or concurrent reader may briefly hold the
|
|
* target open. Enforced by local/require-fs-op-fallback (ADR-1703 Phase 6).
|
|
*/
|
|
export function retryRenameSync(fromPath: string, toPath: string): void {
|
|
const err = atomicRenameWithRetry(fromPath, toPath);
|
|
if (err !== null) throw err;
|
|
}
|
|
|
|
export function platformWriteSync(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): void {
|
|
const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const tmpPath = filePath + '.tmp.' + process.pid;
|
|
|
|
// Step 1: write the sibling tmp file. If THIS fails, nothing was published, so a
|
|
// direct fallback write cannot truncate a concurrent reader of an existing file.
|
|
try {
|
|
fs.writeFileSync(tmpPath, normalized, encoding);
|
|
} catch {
|
|
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
|
|
fs.writeFileSync(filePath, normalized, encoding);
|
|
return;
|
|
}
|
|
|
|
// Step 2: atomic publish, retrying transient Windows locks.
|
|
const renameErr = atomicRenameWithRetry(tmpPath, filePath);
|
|
if (renameErr === null) return;
|
|
|
|
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
|
|
if (RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
|
|
// A live reader still holds the target open after every retry. A non-atomic
|
|
// direct write here would truncate that reader (the exact corruption this seam
|
|
// exists to prevent), so surface the error instead of falling back.
|
|
throw renameErr;
|
|
}
|
|
// Atomic publish is genuinely impossible here (e.g. EXDEV cross-device move):
|
|
// fall back to a direct write to preserve write availability.
|
|
fs.writeFileSync(filePath, normalized, encoding);
|
|
}
|
|
|
|
export function platformReadSync(filePath: string, opts: { encoding?: BufferEncoding; required?: boolean } = {}): string | null {
|
|
const encoding = opts.encoding ?? 'utf-8';
|
|
try {
|
|
return fs.readFileSync(filePath, encoding);
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException;
|
|
if (e.code === 'ENOENT') {
|
|
if (opts.required) throw err;
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function platformEnsureDir(dirPath: string): void {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|