enhance(#3619): ratchet the platform seam with local/no-private-binary-resolution (epic #3411 Phase 3) (#3636)
* chore(#3619): ratchet the platform seam with local/no-private-binary-resolution
Epic #3411 Phase 3, the ratchet. Scope revised with maintainer approval and
recorded on the issue: the epic's literal ask was a rule rejecting a bare-name
spawn outside the seam. Surveyed at ac1b6d679, ~30 such sites exist and none is
a defect — git, gh and npm ship native .exe that CreateProcess resolves unaided,
and the rest are POSIX-only tools. ADR-1703 rules 2 and 3 forbid grandfathering
and escape hatches, so a literal rule would be unsuppressable and would force
rewriting 30 correct calls.
The epic's actual thesis was four private RESOLVERS, not four bare spawns. So
the rule flags re-implementing resolution: reading PATHEXT in any casing from
any object, and a hardcoded list carrying two or more of .exe/.cmd/.bat/.com —
precisely the shapes fallow-runner's candidateNames and gsd-tools' PATHEXT
string had before Phases 1 and 2 deleted them.
Three boundaries were arrived at rather than assumed:
two-or-more a single .endsWith('.cmd') is a classification, not a candidate
set; runtime-hooks-surface derives .cmd shim paths that way
boundary-aware a naive substring test flags .execute and .compacting, caught
on src/host-integration.cts before it could become a false
positive nobody could suppress
suffix-anchored the seam exemption matches src/shell-command-projection.cts
exactly; a substring match would also exempt the dispatch test
file. Case I9 pins it.
PATH scans are deliberately NOT flagged — membership checks (bin/install.js)
are indistinguishable from resolution scans, and an unsound rule in a
zero-escape-hatch architecture is worse than no rule.
To make the ratchet strict with no carve-out, resolveExecutableBinary gained
pathOverride: search THIS PATH, read everything else including PATHEXT from the
ambient environment. resolveFallowBinary now supplies its own search path
without hand-threading PATHEXT, which would itself have been a private read.
The three alternatives were all worse: exempting the file is grandfathering,
exempting the AST shape is a carve-out every future caller must replicate, and
dropping the pass-through would silently ignore a user's real PATHEXT — buying
a lint rule with a correctness regression.
eslint-rules/** is outside the rule's globs rather than exempted, because
portability-vocab.cjs owns the extension set. scripts/**/*.cjs got its own block
so that exclusion does not leave a hole in the ratchet.
Started green with nothing suppressed. Proven able to fail: a fixture with both
signals reports two errors.
Refs #3411
* fix(#3619): close the PATHEXT destructuring evasion and correct two overclaims
Adversarial review found a trivial evasion of the rule's primary signal: the
visitor only handled MemberExpression, so
const { PATHEXT } = process.env
const { PATHEXT: exts } = process.env
const { Pathext } = opts.env
were all unflagged. That is a common idiom, not an exotic bypass. An ObjectPattern
visitor now catches it in every form — renamed, any casing, any receiver, string
keys — while leaving a computed key alone, since it is not statically decidable.
I10-I13 pin the invalid forms and V9/V10 pin PATH and the computed key.
Two overclaims corrected, both mine:
Standards review proved the docs were factually wrong. Both the ADR amendment and
the CONTEXT.md entry asserted that tests/shell-command-projection-dispatch.test.cjs
is still linted by this rule. It is not — the rule's surface is src, gsd-core/bin,
scripts and hooks, and tests/** is deliberately outside it because test setup
legitimately assigns process.env.PATHEXT (fallow-runner's P3 does exactly that).
The suffix-vs-substring distinction is therefore proven by RuleTester case I9
feeding a synthetic filename, NOT by real coverage of that file. Both documents now
say so.
The rule's own docstring claimed the seam exemption matches the seam path
'exactly'. It is a suffix match, so a nested foo/src/shell-command-projection.cts
would also be exempt. Suffix matching is kept — it is how sibling rules resolve
paths and the nested case does not exist — but the docstring now states the
boundary rather than overstating the precision.
The evasion fix was verified by executing eslint against both destructuring forms
in scripts/, not by inspection. Probe: 31/31.
Refs #3411
* chore(#3619): backfill changeset pr number 3636
---------
Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/steady-wasps-jump.md
Normal file
5
.changeset/steady-wasps-jump.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 3636
|
||||
---
|
||||
**A lint rule now keeps Windows binary resolution in one place** — re-implementing PATH/PATHEXT lookup outside the platform seam is rejected at lint time, so the four divergent resolvers epic #3411 removed cannot quietly come back. No change to how GSD behaves at runtime. (#3619)
|
||||
File diff suppressed because one or more lines are too long
@@ -76,6 +76,39 @@ Replace all three with a single coherent mechanism: **AST-based ESLint rules in
|
||||
| `require-userprofile-with-home` | `DEFECT.WINDOWS-TEST-PORTABILITY` (G6) | tests |
|
||||
| `normalize-path-in-content` | `DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT` (`RULESET.CONTENT-PATH-NORMALIZATION`) | `src/**/*.cts` |
|
||||
| `require-fs-op-fallback` | `DEFECT.WINDOWS-FS-OPS` | `src/**/*.cts`, build/install |
|
||||
| `no-private-binary-resolution` | `DEFECT.WINDOWS-PRIVATE-BINARY-RESOLUTION` | `src/**/*.cts`, `gsd-core/bin/**`, `scripts/**`, `hooks/**` |
|
||||
|
||||
**Amendment (2026-08-18, epic #3411 Phase 3 / #3619).** `no-private-binary-resolution` is the
|
||||
first catalog entry added after the original seven, and it extends this architecture to a
|
||||
**production** Windows-runtime class rather than a test-portability one. It flags the two
|
||||
unambiguous signals of re-implementing Windows binary resolution outside the platform seam:
|
||||
reading `PATHEXT` (in any casing, from any object), and a hardcoded list containing two or more of
|
||||
`.exe`/`.cmd`/`.bat`/`.com`. Both are exactly the shapes the four resolvers that epic #3411 deleted
|
||||
actually had.
|
||||
|
||||
It deliberately does **not** flag a bare-name `spawn`, which is what #3411's own text asked for:
|
||||
~30 such call sites exist and none is a defect (`git`, `gh`, `npm` ship native `.exe` on Windows),
|
||||
so under rules 2 and 3 above a literal rule would be unsuppressable and would force rewriting
|
||||
correct code. It also does not flag a `PATH` scan, which is used for legitimate membership checks
|
||||
(`bin/install.js`) and cannot be soundly distinguished from a resolution scan. An unsound rule in a
|
||||
zero-escape-hatch architecture is worse than no rule.
|
||||
|
||||
Two scoping consequences worth recording, because both were arrived at rather than assumed:
|
||||
|
||||
- **`eslint-rules/**` is outside this rule's surface** — `lib/portability-vocab.cjs` owns the
|
||||
extension set per rule 4 and would otherwise flag itself. This is expressed by *not linting that
|
||||
tree*, never by an exemption, so rule 3 holds.
|
||||
- **The seam exemption is path-suffix-anchored**, matching `src/shell-command-projection.cts`
|
||||
or any path ending in `/src/shell-command-projection.cts` — not a substring match. The rule's
|
||||
own configured surface is `src/**/*.cts`, `gsd-core/bin/**/*.cjs`, `scripts/**/*.cjs`, and
|
||||
`hooks/**/*.js`; `tests/**` is deliberately outside that surface, because test setup
|
||||
legitimately assigns `process.env.PATHEXT` (`tests/fallow-runner.test.cjs`'s P3 case does this).
|
||||
So the suffix-vs-substring distinction is proven only by case I9 of the rule's `RuleTester`
|
||||
suite, which feeds the rule a synthetic filename directly — not by real-world linting of
|
||||
`tests/shell-command-projection-dispatch.test.cjs`, which this rule never scans.
|
||||
|
||||
The rule started **green** with nothing suppressed and nothing grandfathered — Phases 1 and 2 had
|
||||
already removed every private resolver, which is what made a strict ratchet possible at all.
|
||||
|
||||
**Taxonomy coverage.** This catalog addresses every `DEFECT.WINDOWS-*` class plus
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE` in `CONTEXT.md`, to the extent each is *statically*
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
* deeper nesting (e.g. `String(path.join(...)).toLowerCase().replace(/\\/g,'/')`)
|
||||
* is not covered — only the outermost call and one level of String() cast are
|
||||
* visible to the rule.
|
||||
*
|
||||
* countWindowsExecutableExtensions matches by spelling (case-insensitive)
|
||||
* against the fixed WINDOWS_EXECUTABLE_EXTENSIONS set below — it does not
|
||||
* parse the string as a PATHEXT-style delimited list, so it also matches a
|
||||
* bare substring occurrence (e.g. `.exe` inside `.exeFoo`). That is
|
||||
* deliberate: the rule this feeds (local/no-private-binary-resolution) only
|
||||
* needs "does this string carry two-or-more of these extensions", the same
|
||||
* shape both deleted private resolvers had, not a strict grammar.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -74,6 +82,60 @@ const PATH_RETURNING_FNS = [
|
||||
'toPosixPath',
|
||||
];
|
||||
|
||||
/**
|
||||
* Windows executable extensions (lowercase canonical). This is the fixed
|
||||
* candidate-extension set both deleted private resolvers (`fallow-runner`'s
|
||||
* `['fallow.exe','fallow.cmd','fallow.bat','fallow']` and `gsd-tools`'
|
||||
* `'.EXE;.CMD;.BAT;.COM'`) hardcoded; local/no-private-binary-resolution
|
||||
* flags a re-occurrence of two-or-more of these outside the seam.
|
||||
*/
|
||||
const WINDOWS_EXECUTABLE_EXTENSIONS = ['.exe', '.cmd', '.bat', '.com'];
|
||||
|
||||
/**
|
||||
* The Windows PATHEXT environment variable name. Windows environment variable
|
||||
* names are case-insensitive, so a read of ANY casing of this name (`Pathext`,
|
||||
* `pathext`, ...) is the signal local/no-private-binary-resolution matches on.
|
||||
*/
|
||||
const PATHEXT_VAR_NAME = 'PATHEXT';
|
||||
|
||||
/**
|
||||
* Boundary-aware match for each WINDOWS_EXECUTABLE_EXTENSIONS entry, in the
|
||||
* same order. A plain substring test misfires on ordinary prose/event-name
|
||||
* tokens that merely start with the same three letters — e.g. `.execute`
|
||||
* contains the substring `.exe`, and `.compacting` contains `.com`, neither
|
||||
* of which is a Windows executable extension (a real false positive this
|
||||
* caught in src/host-integration.cts's OPENCODE_EXTENSION_EVENTS list). The
|
||||
* negative lookahead requires the match NOT be immediately followed by
|
||||
* another letter, so `.exe` at the end of a token or before a delimiter
|
||||
* (`;`, `.`, `'`, whitespace, end-of-string) still matches, but `.exe` inside
|
||||
* `.execute` does not. Hand-written RegExp literals (not built from a
|
||||
* runtime string) so this itself is not an adhoc-regex-escape construction.
|
||||
*/
|
||||
const WINDOWS_EXECUTABLE_EXTENSION_PATTERNS = [
|
||||
/\.exe(?![a-zA-Z])/i,
|
||||
/\.cmd(?![a-zA-Z])/i,
|
||||
/\.bat(?![a-zA-Z])/i,
|
||||
/\.com(?![a-zA-Z])/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns how many DISTINCT entries of WINDOWS_EXECUTABLE_EXTENSIONS appear
|
||||
* (case-insensitively, boundary-aware) in `str`. Used by
|
||||
* local/no-private-binary-resolution to apply its two-or-more threshold —
|
||||
* see the "Known boundaries" note above for the matching semantics.
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {number}
|
||||
*/
|
||||
function countWindowsExecutableExtensions(str) {
|
||||
if (typeof str !== 'string' || str.length === 0) return 0;
|
||||
let count = 0;
|
||||
for (const pattern of WINDOWS_EXECUTABLE_EXTENSION_PATTERNS) {
|
||||
if (pattern.test(str)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `node` is a CallExpression whose callee matches one of the
|
||||
* PATH_RETURNING_FNS entries.
|
||||
@@ -337,6 +399,9 @@ function unwrapNonNormalizerMethodChain(node) {
|
||||
|
||||
module.exports = {
|
||||
PATH_RETURNING_FNS,
|
||||
WINDOWS_EXECUTABLE_EXTENSIONS,
|
||||
PATHEXT_VAR_NAME,
|
||||
countWindowsExecutableExtensions,
|
||||
isPathReturningCall,
|
||||
isPosixSlashStringLiteral,
|
||||
isPosixNormalizerCall,
|
||||
|
||||
166
eslint-rules/no-private-binary-resolution.cjs
Normal file
166
eslint-rules/no-private-binary-resolution.cjs
Normal file
@@ -0,0 +1,166 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const { PATHEXT_VAR_NAME, countWindowsExecutableExtensions } = require('./lib/portability-vocab.cjs');
|
||||
|
||||
/**
|
||||
* no-private-binary-resolution
|
||||
*
|
||||
* Epic #3411's actual thesis: four private Windows-binary-resolution
|
||||
* implementations existed (execNpm's shell:true, execTool's absence of any
|
||||
* handling, gsd-tools.cjs's private scan, and fallow-runner.cts's own
|
||||
* candidate array). All four are gone; this rule stops a fifth from
|
||||
* accreting by flagging the two unambiguous "I am re-implementing Windows
|
||||
* binary resolution" shapes outside the platform seam
|
||||
* (src/shell-command-projection.cts):
|
||||
*
|
||||
* 1. Reading PATHEXT from any object, any casing — Windows environment
|
||||
* variable names are case-insensitive, and nothing reads PATHEXT for a
|
||||
* reason other than locating an executable. This covers both member
|
||||
* access (`env.PATHEXT`, `env['Pathext']`) and destructuring
|
||||
* (`const { PATHEXT } = env`, `const { PATHEXT: exts } = env`), from
|
||||
* any source object.
|
||||
* 2. A hardcoded Windows executable-extension list (an ArrayExpression or
|
||||
* a single string literal) carrying two or more of .exe/.cmd/.bat/.com
|
||||
* — the exact shape both deleted implementations had. A SINGLE
|
||||
* extension is deliberately not flagged: that is a classification
|
||||
* (`p.endsWith('.cmd')`) or a shim-path derivation
|
||||
* (`scriptPath.replace(/\.js$/, '.cmd')`), not a candidate list, and
|
||||
* the tree has legitimate instances of both.
|
||||
*
|
||||
* The seam exemption is PATH-SUFFIX ANCHORED, not substring-matched: a file
|
||||
* is exempt only when its repo-relative path IS `src/shell-command-projection.cts`
|
||||
* or ENDS WITH `/src/shell-command-projection.cts` — so a file nested under
|
||||
* any parent directory at that suffix (e.g. `foo/src/shell-command-projection.cts`)
|
||||
* is exempt too, but a file that merely contains that string as a substring
|
||||
* elsewhere in its own path, or as a `.bak` / `.test.cts` variant of the
|
||||
* filename itself, is NOT exempt (case I9 pins this distinction) — a
|
||||
* substring exemption would be the obvious wrong implementation here.
|
||||
*
|
||||
* See .gsd/phase/chore-3619-no-bare-binary-spawn/40-design.md for the full
|
||||
* behavior table and rejected alternatives.
|
||||
*/
|
||||
|
||||
const SEAM_RELATIVE_PATH = 'src/shell-command-projection.cts';
|
||||
|
||||
/**
|
||||
* True when `filename` IS the seam file, matched by path SUFFIX after
|
||||
* normalizing separators to `/` — never by substring containment anywhere
|
||||
* else in the path.
|
||||
*
|
||||
* @param {string} filename
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSeamFile(filename) {
|
||||
if (typeof filename !== 'string' || filename.length === 0) return false;
|
||||
const normalized = filename.split(path.sep).join('/');
|
||||
return normalized === SEAM_RELATIVE_PATH || normalized.endsWith(`/${SEAM_RELATIVE_PATH}`);
|
||||
}
|
||||
|
||||
/** True when `node` is a string Literal whose value case-insensitively equals PATHEXT_VAR_NAME. */
|
||||
function isPathextStringLiteral(node) {
|
||||
return !!node && node.type === 'Literal' && typeof node.value === 'string'
|
||||
&& node.value.toLowerCase() === PATHEXT_VAR_NAME.toLowerCase();
|
||||
}
|
||||
|
||||
/** True when `node` is an Identifier whose name case-insensitively equals PATHEXT_VAR_NAME. */
|
||||
function isPathextIdentifier(node) {
|
||||
return !!node && node.type === 'Identifier'
|
||||
&& node.name.toLowerCase() === PATHEXT_VAR_NAME.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a MemberExpression's property resolves to PATHEXT, any casing:
|
||||
* - non-computed (dot access): property is always an Identifier — `env.PATHEXT`, `env.pathext`
|
||||
* - computed (bracket access): property may be a string Literal — `env['PATHEXT']` —
|
||||
* or an Identifier referencing a same-named local variable — `env[PATHEXT]`
|
||||
* (row 10 in the design doc: a variable *named* PATHEXT, accepted false-positive risk)
|
||||
*/
|
||||
function memberExpressionReadsPathext(node) {
|
||||
const property = node.property;
|
||||
if (!node.computed) return isPathextIdentifier(property);
|
||||
return isPathextStringLiteral(property) || isPathextIdentifier(property);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an ObjectPattern `Property`'s key resolves to PATHEXT, any casing —
|
||||
* regardless of what is being destructured (`process.env`, `env`, `opts.env`,
|
||||
* anything) and regardless of renaming (`const { PATHEXT: exts } = ...`):
|
||||
* - non-computed key: an Identifier (`{ PATHEXT }`, `{ Pathext: v }`) or a
|
||||
* string Literal (`{ 'PATHEXT': v }`)
|
||||
* - computed key (`{ [expr]: v }`): only a string Literal is statically
|
||||
* decidable (`{ ['PATHEXT']: v }`); a variable expression like `{ [key]: v }`
|
||||
* is NOT decidable and must not be reported (an Identifier that happens to
|
||||
* be *named* PATHEXT is still accepted, mirroring the MemberExpression
|
||||
* computed case above and its accepted false-positive risk).
|
||||
*/
|
||||
function objectPatternPropertyReadsPathext(property) {
|
||||
if (!property || property.type !== 'Property') return false;
|
||||
const key = property.key;
|
||||
return isPathextIdentifier(key) || isPathextStringLiteral(key);
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow re-implementing Windows binary resolution outside the platform seam',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
pathextRead:
|
||||
'Reading PATHEXT is Windows binary resolution — route through resolveExecutableBinary ' +
|
||||
'in src/shell-command-projection.cts instead. Four private resolvers is what epic #3411 removed.',
|
||||
extensionList:
|
||||
'A hardcoded Windows executable-extension list is a private resolver candidate set — ' +
|
||||
'use the seam\'s PATHEXT handling in src/shell-command-projection.cts instead.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = typeof context.filename === 'string' ? context.filename : context.getFilename();
|
||||
if (isSeamFile(filename)) return {};
|
||||
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
if (memberExpressionReadsPathext(node)) {
|
||||
context.report({ node, messageId: 'pathextRead' });
|
||||
}
|
||||
},
|
||||
|
||||
ObjectPattern(node) {
|
||||
for (const property of node.properties) {
|
||||
if (objectPatternPropertyReadsPathext(property)) {
|
||||
context.report({ node: property, messageId: 'pathextRead' });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
ArrayExpression(node) {
|
||||
const stringValues = node.elements
|
||||
.filter((el) => el && el.type === 'Literal' && typeof el.value === 'string')
|
||||
.map((el) => el.value);
|
||||
if (stringValues.length === 0) return;
|
||||
if (countWindowsExecutableExtensions(stringValues.join(' ')) >= 2) {
|
||||
context.report({ node, messageId: 'extensionList' });
|
||||
}
|
||||
},
|
||||
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string') return;
|
||||
// Elements of an ArrayExpression are handled by the ArrayExpression
|
||||
// visitor above (which combines the whole array's contents) — do not
|
||||
// double-report the same evidence from this node's own value alone.
|
||||
const parent = node.parent;
|
||||
if (parent && parent.type === 'ArrayExpression' && parent.elements.includes(node)) return;
|
||||
if (countWindowsExecutableExtensions(node.value) >= 2) {
|
||||
context.report({ node, messageId: 'extensionList' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
@@ -30,6 +30,7 @@ import noUnboundedSpawn from './eslint-rules/no-unbounded-spawn.cjs';
|
||||
import noDuplicateFoldMarker from './eslint-rules/no-duplicate-fold-marker.cjs';
|
||||
import requireSubprocessTimeout from './eslint-rules/require-subprocess-timeout.cjs';
|
||||
import noExternalRequireInBin from './eslint-rules/no-external-require-in-bin.cjs';
|
||||
import noPrivateBinaryResolution from './eslint-rules/no-private-binary-resolution.cjs';
|
||||
|
||||
const localPlugin = {
|
||||
rules: {
|
||||
@@ -54,6 +55,7 @@ const localPlugin = {
|
||||
'no-duplicate-fold-marker': noDuplicateFoldMarker,
|
||||
'require-subprocess-timeout': requireSubprocessTimeout,
|
||||
'no-external-require-in-bin': noExternalRequireInBin,
|
||||
'no-private-binary-resolution': noPrivateBinaryResolution,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -376,6 +378,11 @@ export default tseslint.config(
|
||||
// place a bad external import in an already-migrated module is still
|
||||
// visible to lint.
|
||||
'local/no-external-require-in-bin': 'error',
|
||||
// #3619 (epic #3411 Phase 3): flag a re-implemented Windows binary
|
||||
// resolver — a PATHEXT read or a hardcoded exe-extension list — outside
|
||||
// the platform seam (src/shell-command-projection.cts, exempt by path).
|
||||
// See .gsd/phase/chore-3619-no-bare-binary-spawn/40-design.md.
|
||||
'local/no-private-binary-resolution': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -487,6 +494,35 @@ export default tseslint.config(
|
||||
},
|
||||
rules: {
|
||||
'local/no-external-require-in-bin': 'error',
|
||||
// #3619 (epic #3411 Phase 3): see the src/**/*.cts block above for detail.
|
||||
'local/no-private-binary-resolution': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// ── scripts/**/*.cjs only — no-private-binary-resolution ───────────────────
|
||||
// A NARROWER block than the combined CommonJS glob above, on purpose:
|
||||
// eslint-rules/** is deliberately OUTSIDE this rule's surface, because
|
||||
// eslint-rules/lib/portability-vocab.cjs is the single source of truth for
|
||||
// the Windows executable-extension set (ADR-1703 rule 4) — its own
|
||||
// WINDOWS_EXECUTABLE_EXTENSIONS vocabulary array would trip the rule it
|
||||
// feeds. Registering on the shared `gsd-core/bin/**/*.cjs + scripts/**/*.cjs
|
||||
// + eslint-rules/**/*.cjs + ...` block would flag that file; this block
|
||||
// covers scripts/**/*.cjs only, so the rule still lints every other script
|
||||
// in the tree without the vocabulary file self-flagging.
|
||||
{
|
||||
files: ['scripts/**/*.cjs'],
|
||||
plugins: {
|
||||
local: localPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// #3619 (epic #3411 Phase 3): see the src/**/*.cts block above for detail.
|
||||
'local/no-private-binary-resolution': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -505,6 +541,8 @@ export default tseslint.config(
|
||||
'n/no-path-concat': 'error',
|
||||
// ADR-3212 Phase 1 (#3412): pattern-construction seam prohibition.
|
||||
'local/no-adhoc-regex-escape': 'error',
|
||||
// #3619 (epic #3411 Phase 3): see the src/**/*.cts block above for detail.
|
||||
'local/no-private-binary-resolution': 'error',
|
||||
// n/no-process-exit is deliberately OFF for hooks ONLY.
|
||||
//
|
||||
// A hook is a standalone process whose ENTIRE contract is its exit code: the
|
||||
|
||||
@@ -28,12 +28,14 @@ export interface ResolveFallowOpts {
|
||||
export function resolveFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string | null {
|
||||
return resolveExecutableBinary('fallow', {
|
||||
prependPaths: [path.join(cwd, 'node_modules', '.bin')],
|
||||
// PATHEXT is passed through (not spread from the rest of process.env) so
|
||||
// win32 resolution still works, while avoiding the spread-loses-the-
|
||||
// case-insensitive-proxy hazard the Windows lane caught in Phase 1. `?? ''`
|
||||
// is intentional: the seam falls back to its own DEFAULT_PATHEXT when this
|
||||
// is empty/absent, so an empty string here is a safe "let the seam decide".
|
||||
env: { PATH: envPath, PATHEXT: process.env['PATHEXT'] ?? '' },
|
||||
// #3619 (epic #3411 Phase 3): pathOverride carries envPath as the search
|
||||
// path while leaving `env` unset, so the seam falls back to its default
|
||||
// `env` (process.env) for everything else — PATHEXT included. Ambient
|
||||
// PATHEXT therefore still governs win32 resolution exactly as before, but
|
||||
// this file never reads it itself: that's what keeps this module clean
|
||||
// under local/no-private-binary-resolution, which forbids a PATHEXT read
|
||||
// outside the seam.
|
||||
pathOverride: envPath,
|
||||
requireExecutable: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -754,12 +754,26 @@ function _buildVerbatimCmdLine(target: string, args: string[]): string {
|
||||
* with plain `fs.writeFileSync` and never set an exec bit (the repo bans
|
||||
* `chmod` in tests), so every one of them would resolve to `null` on POSIX.
|
||||
*
|
||||
* **`opts.pathOverride`** — "search THIS PATH, but read everything else —
|
||||
* PATHEXT included — from the ambient environment." When set (`!== undefined`),
|
||||
* the PATH search segments come from splitting `pathOverride` on `path.delimiter`
|
||||
* instead of from `env.PATH`; `pathOverride: ''` means an explicit EMPTY search
|
||||
* path (zero segments), never a fallback to `env.PATH` — use `!== undefined`,
|
||||
* not truthiness, to tell "caller supplied a PATH string" apart from "caller
|
||||
* supplied nothing". `opts.prependPaths` still comes first. PATHEXT resolution
|
||||
* is UNAFFECTED by this option — it still reads from `env` (which defaults to
|
||||
* `process.env`) exactly as it does when `pathOverride` is omitted. This exists
|
||||
* so a caller that already has its own search path in hand (e.g.
|
||||
* `resolveFallowBinary`'s `envPath`) does not have to hand-thread PATHEXT
|
||||
* alongside it — a private PATHEXT read is the very shape
|
||||
* `local/no-private-binary-resolution` forbids outside this seam.
|
||||
*
|
||||
* @returns the resolved path, or `null` when nothing matched. Callers fall back to
|
||||
* the declared name on `null` so a genuine ENOENT still surfaces (#3086).
|
||||
*/
|
||||
export function resolveExecutableBinary(
|
||||
name: string | null | undefined,
|
||||
opts: { platform?: string; env?: NodeJS.ProcessEnv; prependPaths?: string[]; requireExecutable?: boolean } = {},
|
||||
opts: { platform?: string; env?: NodeJS.ProcessEnv; prependPaths?: string[]; requireExecutable?: boolean; pathOverride?: string } = {},
|
||||
): string | null {
|
||||
if (!name) return null;
|
||||
const requireExecutable = opts.requireExecutable ?? false;
|
||||
@@ -768,7 +782,8 @@ export function resolveExecutableBinary(
|
||||
return _isFile(name, requireExecutable, platform) ? name : null;
|
||||
}
|
||||
const env = opts.env ?? process.env;
|
||||
const pathSegments = String(_envGet(env, 'PATH') || '').split(path.delimiter).filter(Boolean);
|
||||
const rawPath = opts.pathOverride !== undefined ? opts.pathOverride : (_envGet(env, 'PATH') || '');
|
||||
const pathSegments = String(rawPath).split(path.delimiter).filter(Boolean);
|
||||
const segments = [...(opts.prependPaths ?? []), ...pathSegments];
|
||||
|
||||
if (platform !== 'win32') {
|
||||
|
||||
@@ -326,4 +326,43 @@ describe('resolveFallowBinary (#3618)', () => {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
// P3 (#3619, epic #3411 Phase 3): resolveFallowBinary switched from
|
||||
// `env: { PATH: envPath, PATHEXT: process.env['PATHEXT'] ?? '' }` to
|
||||
// `pathOverride: envPath` so this module never reads PATHEXT itself (the
|
||||
// shape local/no-private-binary-resolution forbids outside the seam). The
|
||||
// seam's `env` param is left unset, so it defaults to `process.env` inside
|
||||
// resolveExecutableBinary — a REAL ambient process.env.PATHEXT must still
|
||||
// govern win32 resolution exactly as it did before the switch. This is the
|
||||
// row the whole switch could have silently broken.
|
||||
//
|
||||
// PATHEXT is meaningless on POSIX (R21), so per F4's pattern above, BOTH
|
||||
// platform contracts are asserted explicitly rather than skipping either:
|
||||
// a `t.skip()`/bare `return` would make this a vacuous pass on that lane.
|
||||
test('P3: a real ambient process.env.PATHEXT still governs win32 resolution after the env -> pathOverride switch', () => {
|
||||
const cwd = createTempDir();
|
||||
const pathDir = createTempDir();
|
||||
const originalPathext = process.env.PATHEXT;
|
||||
try {
|
||||
process.env.PATHEXT = '.XYZ';
|
||||
const distinctiveFixture = path.join(pathDir, 'fallow.XYZ');
|
||||
fs.writeFileSync(distinctiveFixture, '');
|
||||
const resolved = resolveFallowBinary({ cwd, envPath: pathDir });
|
||||
if (process.platform === 'win32') {
|
||||
// win32: ambient process.env.PATHEXT ('.XYZ') is consulted, so the
|
||||
// distinctively-extensioned fixture resolves — proving the pass-through
|
||||
// was preserved by pathOverride, not silently dropped.
|
||||
assert.equal(resolved, distinctiveFixture);
|
||||
} else {
|
||||
// POSIX: PATHEXT plays no role in resolution at all (resolveExecutableBinary
|
||||
// matches the bare name 'fallow' exactly); a file named 'fallow.XYZ' is not
|
||||
// that name, so nothing is found.
|
||||
assert.equal(resolved, null);
|
||||
}
|
||||
} finally {
|
||||
if (originalPathext === undefined) delete process.env.PATHEXT; else process.env.PATHEXT = originalPathext;
|
||||
cleanup(cwd);
|
||||
cleanup(pathDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
341
tests/no-private-binary-resolution.rule.test.cjs
Normal file
341
tests/no-private-binary-resolution.rule.test.cjs
Normal file
@@ -0,0 +1,341 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-private-binary-resolution.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/no-private-binary-resolution ESLint rule.
|
||||
* Ids (V1-V8, I1-I9) map to .gsd/phase/chore-3619-no-bare-binary-spawn/50-test-matrix.md.
|
||||
*
|
||||
* RuleTester feeds fixtures to the rule directly and does not scan this test
|
||||
* file's own source, so the self-flagging problem that forced eslint.config.mjs
|
||||
* to carve the eslint-rules directory out of the scripts .cjs block does not
|
||||
* arise here (ADR-1703 rule 5 / 40-design.md).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/no-private-binary-resolution.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
const OUTSIDE_SEAM_FILE = 'src/some-other-module.cts';
|
||||
const SEAM_FILE = 'src/shell-command-projection.cts';
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-private-binary-resolution rule module', () => {
|
||||
test('exports meta and create', () => {
|
||||
assert.strictEqual(typeof rule.meta, 'object');
|
||||
assert.strictEqual(typeof rule.create, 'function');
|
||||
assert.strictEqual(rule.meta.type, 'problem');
|
||||
assert.ok(rule.meta.messages.pathextRead, 'pathextRead message must exist');
|
||||
assert.ok(rule.meta.messages.extensionList, 'extensionList message must exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VALID cases (V1-V8) ────────────────────────────────────────────────────
|
||||
|
||||
describe('no-private-binary-resolution: valid cases', () => {
|
||||
test('V1: process.env.PATHEXT in src/shell-command-projection.cts — the seam is exempt', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const ext = process.env.PATHEXT;`,
|
||||
filename: SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V2: ['.exe'] — one extension is classification, not a candidate list", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const exts = ['.exe'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V3: p.endsWith('.cmd') — one extension", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const isCmd = p.endsWith('.cmd');`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V4: scriptPath.replace(/\\.js$/, '.cmd') — shim-path derivation (runtime-hooks-surface.cts shape)", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const shimPath = scriptPath.replace(/\\.js$/, '.cmd');`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V5: pathEnv.split(path.delimiter) — PATH scans are deliberately not flagged', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const segments = pathEnv.split(path.delimiter);`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V6: process.env.PATH — only PATHEXT is the signal', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = process.env.PATH;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V7: ['.exe', '.txt'] — one exe extension plus an unrelated one, below the two-or-more threshold", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const exts = ['.exe', '.txt'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V8: '.execute'/'.compacting' are substrings only — the src/host-integration.cts:724 false-positive fix", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const OPENCODE_EXTENSION_EVENTS = ['.execute', '.compacting'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V9: const { PATH } = process.env; — PATH is not the signal', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const { PATH } = process.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V10: const { [key]: v } = process.env; — computed key is not statically decidable', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const { [key]: v } = process.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── INVALID cases (I1-I9) ──────────────────────────────────────────────────
|
||||
|
||||
describe('no-private-binary-resolution: invalid cases', () => {
|
||||
test('I1: process.env.PATHEXT outside the seam — 1 error', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const ext = process.env.PATHEXT;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I2: process.env['PATHEXT'] — bracket form", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const ext = process.env['PATHEXT'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I3: env['Pathext'] — Windows env names are case-insensitive", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const ext = env['Pathext'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I4: opts.env.pathext — lower case, non-process receiver', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const ext = opts.env.pathext;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I5: ['a.exe','a.cmd','a.bat','a'] — the deleted fallow-runner shape verbatim", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const candidates = ['a.exe', 'a.cmd', 'a.bat', 'a'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'extensionList' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I6: '.EXE;.CMD;.BAT;.COM' — the deleted gsd-tools shape verbatim", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const exts = '.EXE;.CMD;.BAT;.COM';`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'extensionList' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I7: ['.cmd', '.bat'] — exactly two, the threshold boundary from below", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const exts = ['.cmd', '.bat'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'extensionList' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I8: a file that trips BOTH signals — two errors, not one', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
const ext = process.env.PATHEXT;
|
||||
const candidates = ['a.exe', 'a.cmd'];
|
||||
`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I9: process.env.PATHEXT in a file whose path merely CONTAINS the seam name as a substring — still errors (path-anchored, not substring-matched)', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const ext = process.env.PATHEXT;`,
|
||||
filename: 'tests/shell-command-projection-dispatch.test.cjs',
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I10: const { PATHEXT } = process.env; — destructuring evades a MemberExpression-only check', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { PATHEXT } = process.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I11: const { PATHEXT: exts } = process.env; — renamed destructuring', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { PATHEXT: exts } = process.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I12: const { Pathext } = opts.env; — casing plus non-process receiver', () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { Pathext } = opts.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I13: const { 'PATHEXT': v } = env; — string-key destructuring", () => {
|
||||
ruleTester.run('no-private-binary-resolution', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { 'PATHEXT': v } = env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'pathextRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -635,6 +635,125 @@ describe('resolveExecutableBinary seam options (#3618)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveExecutableBinary pathOverride (#3619, epic #3411 Phase 3) ──────
|
||||
// "Search THIS PATH, but read everything else — PATHEXT included — from the
|
||||
// ambient environment." Added so resolveFallowBinary can hand the seam a
|
||||
// search path without also hand-threading PATHEXT (a private PATHEXT read
|
||||
// is exactly the shape local/no-private-binary-resolution forbids outside
|
||||
// this seam). See .gsd/phase/chore-3619-no-bare-binary-spawn/50-test-matrix.md (O1-O7).
|
||||
|
||||
describe('resolveExecutableBinary pathOverride (#3619)', () => {
|
||||
test('O1: pathOverride omitted — identical to today, env.PATH is used', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', { platform: 'linux', env: { PATH: dir } });
|
||||
assert.equal(resolved, path.join(dir, 'foo'));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('O2: pathOverride set, binary in the pathOverride dir, env.PATH points elsewhere — resolves via pathOverride, env.PATH ignored', () => {
|
||||
const dirA = createTempDir();
|
||||
const dirElsewhere = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dirA, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: dirElsewhere },
|
||||
pathOverride: dirA,
|
||||
});
|
||||
assert.equal(resolved, path.join(dirA, 'foo'));
|
||||
} finally {
|
||||
cleanup(dirA);
|
||||
cleanup(dirElsewhere);
|
||||
}
|
||||
});
|
||||
|
||||
test("O3: pathOverride: '' with a populated env.PATH — null; empty means empty, not a fallback to env.PATH", () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: dir },
|
||||
pathOverride: '',
|
||||
});
|
||||
assert.equal(resolved, null);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('O4: pathOverride + prependPaths — prepended dirs still come first', () => {
|
||||
const dirPrepend = createTempDir();
|
||||
const dirOverride = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dirPrepend, 'foo'), '');
|
||||
fs.writeFileSync(path.join(dirOverride, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
pathOverride: dirOverride,
|
||||
prependPaths: [dirPrepend],
|
||||
});
|
||||
assert.equal(resolved, path.join(dirPrepend, 'foo'));
|
||||
assert.notEqual(resolved, path.join(dirOverride, 'foo'));
|
||||
} finally {
|
||||
cleanup(dirPrepend);
|
||||
cleanup(dirOverride);
|
||||
}
|
||||
});
|
||||
|
||||
test('O5: pathOverride set, opts.env omitted, win32 — PATHEXT still comes from the ambient process.env, not DEFAULT_PATHEXT', () => {
|
||||
const dir = createTempDir();
|
||||
const originalPathext = process.env.PATHEXT;
|
||||
try {
|
||||
process.env.PATHEXT = '.XYZ';
|
||||
fs.writeFileSync(path.join(dir, 'foo.XYZ'), '');
|
||||
const resolved = resolveExecutableBinary('foo', { platform: 'win32', pathOverride: dir });
|
||||
assert.equal(resolved, path.join(dir, 'foo.XYZ'));
|
||||
} finally {
|
||||
if (originalPathext === undefined) delete process.env.PATHEXT; else process.env.PATHEXT = originalPathext;
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('O6: pathOverride AND env.PATH both set — pathOverride wins (assert WHICH path resolved, not merely that something resolved)', () => {
|
||||
const dirOverride = createTempDir();
|
||||
const dirEnvPath = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dirOverride, 'foo'), '');
|
||||
fs.writeFileSync(path.join(dirEnvPath, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: dirEnvPath },
|
||||
pathOverride: dirOverride,
|
||||
});
|
||||
assert.equal(resolved, path.join(dirOverride, 'foo'));
|
||||
assert.notEqual(resolved, path.join(dirEnvPath, 'foo'));
|
||||
} finally {
|
||||
cleanup(dirOverride);
|
||||
cleanup(dirEnvPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('O7: multi-segment pathOverride, binary in the second segment — first-match wins, in order', () => {
|
||||
const dir1 = createTempDir();
|
||||
const dir2 = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir2, 'foo'), '');
|
||||
const pathOverride = [dir1, dir2].join(path.delimiter);
|
||||
const resolved = resolveExecutableBinary('foo', { platform: 'linux', pathOverride });
|
||||
assert.equal(resolved, path.join(dir2, 'foo'));
|
||||
assert.notEqual(resolved, path.join(dir1, 'foo'));
|
||||
} finally {
|
||||
cleanup(dir1);
|
||||
cleanup(dir2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── projectSpawnInvocation (#3411) ─────────────────────────────────────────
|
||||
|
||||
// Mirrors the seam's own `_cmdQuoteToken` (a literal `"` is doubled) so
|
||||
|
||||
Reference in New Issue
Block a user