* chore(#2896): convert CONTEXT.md prose defect registry into enforced gates Squashes the prior 4-commit sequence and fixes defects found while resuming this branch: 5 orphaned/corrupted DEFECT fragment lines left by an earlier botched edit, 17 "Source of truth: Memtrace `find_symbol`" placeholders that had destroyed real file-path citations, and 3 DEFECT.GENERATIVE-* entries merged into one RULESET.GENERATIVE-FIX predicate (policy, not an unenforced defect) to satisfy the zero DEFECT.<NAME>.<field>= acceptance criterion. Six mechanizable defects get real gates: DEFECT.UNBOUNDED-SUBPROCESS (eslint-rules/require-subprocess-timeout.cjs), DEFECT.CANARY-VERSION-LEAK (scripts/lint-canary-version-leak.cjs + version-gate.yml), DEFECT.CHANGESET-PR-FIELD-DRIFT (findPrFieldDrift in changeset/lint.cjs), DEFECT.FRONTMATTER-SCALAR-BROAD-GREP, DEFECT.REMOVED-BUT-NEEDED, and DEFECT.DEFAULT-FLIP-DOCUMENTATION (new lint scripts, wired into lint:ci). Already-enforced and unenforceable prose entries are deleted; the gate is the record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#2896): route the new lint tests' subprocess calls through the bounded process-seam helper The 4 new test files for this PR's lint checks called cp.spawnSync/ execFileSync directly with no timeout, tripping this repo's own existing local/no-unbounded-spawn ESLint rule. Route every one through runNode/gitOrThrow (tests/helpers/process-seam.cjs, tests/helpers/git-fixture.cjs) instead, matching the pattern already used elsewhere in the suite (e.g. tests/changeset-lint.test.cjs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: register claude-orchestration.cjs and regenerate stale generated indexes Pre-existing drift on next, unrelated to this PR's own change, surfaced by running lint:ci as part of verifying #2896: two cli_modules (claude-orchestration.cjs, write-set.cjs) landed without a manifest regen, and CONTEXT.md's own edits in this PR staled its two generated indexes. Adds the missing docs/INVENTORY.md row for claude-orchestration.cjs (write-set.cjs already had one — only its manifest entry was stale) and regenerates docs/INVENTORY-MANIFEST.json, docs/CONTEXT-INDEX.json, and examples/dynamic-context-management/CONTEXT-INDEX.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): default-flip-documentation lint's local fallback base was main, not next Found in review: every other base-ref fallback in this repo (see scripts/changeset/lint.cjs's DEFAULT_BASE, #2988) defaults to `next`, the integration branch every PR actually targets — `main` is the release branch. This script's local fallback (used only when GITHUB_BASE_REF is unset, i.e. never in CI, but potentially on a local or direct invocation) diffed against the wrong ref. No test exercised the unset-env-var path, so it shipped unnoticed; every e2e test sets GITHUB_BASE_REF explicitly and is unaffected by this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): stale eslint comment, overclaiming CONTEXT.md wording, and an incompletely-regenerated manifest Found by the isolated Standards code-review pass: - eslint.config.mjs's require-subprocess-timeout comment said "'warn' for now... flip to 'error' once migrated" while the rule already shipped as 'error' with all 8 sites migrated in the same commit — described a state that never existed. - The CONTEXT.md pointer block claimed the rule's bounded call sites "never throw", but roadmap-upgrade.cts's pre-mutation clean-tree check correctly still throws on failure (it gates a destructive real-run migration; degrading to "assume clean" would risk clobbering uncommitted work) — softened the claim to describe both shapes accurately instead of overclaiming one. - docs/INVENTORY-MANIFEST.json's claude-orchestration.cjs/write-set.cjs entries from the prior "fix: register claude-orchestration.cjs..." commit didn't actually land — re-running the generator now includes them; lint:generated-sync is green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * chore(#2896): backfill changeset pr field with the real PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#2896): normalize buildCorpus file paths to POSIX in lint-removed-but-needed Windows CI caught it: path.relative(root, abs) returns backslash- separated paths on Windows, but findSurvivingReferences's package-lock special case does file.startsWith('.github/workflows') — a forward- slash literal. On Windows the check silently never matched, so tests/removed-but-needed-lint.test.cjs's real-defect-shape fixture got exit 0 instead of the expected exit 1. Normalize at the production source (RULESET.CONTENT-PATH-NORMALIZATION) rather than the test side. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
962 lines
38 KiB
TypeScript
962 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';
|
|
|
|
/**
|
|
* 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 = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
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 });
|
|
}
|