* chore(#3618): fold fallow-runner onto the canonical binary resolver Epic #3411 Phase 2. src/fallow-runner.cts was the fourth divergent implementation of Windows binary resolution the epic enumerated — candidateNames, isExecutableFile, findInPath, findInNodeModules, 40 lines. All four are deleted; resolveFallowBinary is one seam call. Two OPT-IN options were added to resolveExecutableBinary to make the fold behavior-preserving, both defaulting off so Phase 1's callers are byte-identical: prependPaths dirs searched before env.PATH, in order, through the identical per-directory candidate logic. This expresses node_modules/.bin-first precedence without env surgery — the rejected alternative re-introduced the spread-loses-the-proxy hazard the Windows lane caught in Phase 1, at every future call site instead of once. requireExecutable POSIX-only accessSync(X_OK); a no-op on win32 where mode bits do not mean execute. Opt-in rather than default because unconditional X_OK breaks #3445's suite, which stages candidates with plain writeFileSync and never sets an exec bit — the repo bans chmod in tests — so every one would resolve to null on POSIX. Deliberate behavior change on Windows: fallow's prior candidate list ended in a BARE fallow. The seam never tries a bare name there, so an extensionless file beside fallow.cmd is no longer resolved. That is the fix, not a regression — the extensionless file is npm's POSIX sh shim, which CreateProcess cannot run (#3275). Rows 7 and 8 of the design record it. Defect found while working, fixed inline: the resolution order was documented BACKWARDS as PATH-then-.bin in structural-pre-pass.md, docs/INVENTORY.md and four INVENTORY translations. The code has always been .bin first, and .bin first is correct — a project-local tool should beat a global one. The archived changeset is left alone as a historical record. fallow-runner had no test file at all. tests/fallow-runner.test.cjs is new (F1-F15) and the seam options are pinned by S1-S12 folded into the existing dispatch suite. RED proven by execution: with both source files stashed and build:lib re-run, 7 of 27 probe cases failed. Refs #3411 * chore(#3618): backfill changeset pr number 3633 * fix(#3618): assert both platform contracts in F4 instead of a POSIX-only premise Windows CI on #3633 failed F4. The test monkeypatched accessSync to throw and asserted resolveFallowBinary returned null — but that premise, that the X_OK check is consulted at all, is POSIX-only by design. requireExecutable is a deliberate no-op on win32 because Windows mode bits do not mean execute, so the staged fixture correctly resolved there. 40-design.md's negative-space section already states this carve-out verbatim. The test contradicted the design it was written from: fixtures were made platform-adaptive in the previous commit, and this assertion was left platform-blind. F4 now asserts BOTH contracts — null on POSIX, resolves on win32 — rather than skipping either. A t.skip on one lane would have been green and would have left the win32 carve-out unpinned by fallow's own entry point. Audited every other row for the same class. F1-F3, F5, F6, F11-F15 hold on both platforms; F7-F10 and S1-S12 inject platform explicitly and are unaffected. F4 was the only row with a single-platform premise. The local probe runs on one platform and structurally cannot catch this, which is why it was green — that limitation is now stated at the top of the probe so a green probe is not mistaken for platform coverage. The win32 branch was proven by injecting platform:'win32' with accessSync throwing and asserting it still resolves. Refs #3411 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/eager-pandas-glide.md
Normal file
5
.changeset/eager-pandas-glide.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3633
|
||||
---
|
||||
**Fallow binary resolution now shares the platform seam** — resolving the fallow binary uses the same PATH/PATHEXT logic as every other spawn, so on Windows a `fallow.cmd` shim resolves correctly and an extensionless npm shim is no longer picked up in its place. `node_modules/.bin` is still searched before `PATH`, and the POSIX executable-bit check is unchanged. (#3618)
|
||||
File diff suppressed because one or more lines are too long
@@ -499,7 +499,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `eval.cjs` | Deterministic eval scoring (compiled from `src/eval.cts`, gitignored) — `computeEvalScore` (coverage*0.6 + infra*0.4, bands 80/60/40) + `cmdEvalScore` CLI domain guard; moves the gsd-eval-auditor's weighted arithmetic out of the prompt into code (#10 / #1579) |
|
||||
| `estimate-cli.cjs` | I/O seam over `phase-estimation.cjs` — the `estimate-check` and `estimate-calibration` query verbs; reads the `workflow.smart_zone_tokens` budget and `.planning/estimation-calibration.json`, both degrading to defaults rather than failing planning (#2630) |
|
||||
| `observability/event.cjs` | DispatchEvent shape factory for every Hub dispatch — traceId/parentTraceId/command/result/timestamp record consumed by DispatchLogger (#177, ADR-0174 P1.3/P1.4) |
|
||||
| `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`PATH` then `node_modules/.bin`), actionable missing-binary errors, and structural findings normalization |
|
||||
| `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`node_modules/.bin` then `PATH`), actionable missing-binary errors, and structural findings normalization |
|
||||
| `federated-config.cjs` | Defensive merge of capability-declared config slices into the loadConfig return value — ADR-857 phase 3b; exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig })` → `{ values, validKeys, warnings }`; live for migrated Capability keys that are atomically removed from the central config schema |
|
||||
| `frontmatter.cjs` | YAML frontmatter CRUD operations |
|
||||
| `gap-checker.cjs` | Post-planning gap analysis (#2493): unified REQUIREMENTS.md + CONTEXT.md decisions vs PLAN.md coverage report (`gsd-tools gap-analysis`) |
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
| `decisions.cjs` | CONTEXT.md の `<decisions>` ブロックを解析。数値(D-42)と英数字(D-INFRA-01)の ID を受け付け。`{id, text, category, tags, trackable}` を返す |
|
||||
| `docs.cjs` | docs-update ワークフロー初期化、Markdown スキャン、モノリポ検出 |
|
||||
| `drift.cjs` | 実行後のコードベース構造ドリフト検出器(#2003): ファイル変更を new-dir/barrel/migration/route カテゴリに分類し、`last_mapped_commit` フロントマターをラウンドトリップ |
|
||||
| `fallow-runner.cjs` | `/gsd-code-review` 向けのファロー監査アダプター: バイナリ解決(`PATH` 次に `node_modules/.bin`)、アクション可能なバイナリ欠落エラー、構造的な調査結果の正規化 |
|
||||
| `fallow-runner.cjs` | `/gsd-code-review` 向けのファロー監査アダプター: バイナリ解決(`node_modules/.bin` 次に `PATH`)、アクション可能なバイナリ欠落エラー、構造的な調査結果の正規化 |
|
||||
| `frontmatter.cjs` | YAML フロントマター CRUD 操作 |
|
||||
| `gap-checker.cjs` | 計画後のギャップ分析(#2493): REQUIREMENTS.md + CONTEXT.md 決定事項 vs PLAN.md カバレッジレポート(`gsd-tools gap-analysis`)の統合 |
|
||||
| `graphify.cjs` | `/gsd-graphify` 向けのナレッジグラフビルド/クエリ/ステータス/差分 |
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
| `decisions.cjs` | CONTEXT.md `<decisions>` 블록 파싱; 숫자형(D-42) 및 영숫자형(D-INFRA-01) ID 허용; `{id, text, category, tags, trackable}` 반환 |
|
||||
| `docs.cjs` | 문서 업데이트 워크플로우 초기화, 마크다운 스캔, 모노레포 감지 |
|
||||
| `drift.cjs` | 실행 후 코드베이스 구조 드리프트 감지기(#2003): 파일 변경을 new-dir/barrel/migration/route 카테고리로 분류하고 `last_mapped_commit` 프론트매터를 왕복 처리 |
|
||||
| `fallow-runner.cjs` | `/gsd-code-review`를 위한 Fallow 감사 어댑터: 바이너리 해석(`PATH` 이후 `node_modules/.bin`), 실행 가능한 누락 바이너리 오류, 구조적 결과 정규화 |
|
||||
| `fallow-runner.cjs` | `/gsd-code-review`를 위한 Fallow 감사 어댑터: 바이너리 해석(`node_modules/.bin` 이후 `PATH`), 실행 가능한 누락 바이너리 오류, 구조적 결과 정규화 |
|
||||
| `frontmatter.cjs` | YAML 프론트매터 CRUD 작업 |
|
||||
| `gap-checker.cjs` | 계획 후 공백 분석(#2493): REQUIREMENTS.md + CONTEXT.md 결정 대 PLAN.md 커버리지 보고서(`gsd-tools gap-analysis`) |
|
||||
| `graphify.cjs` | `/gsd-graphify`를 위한 지식 그래프 빌드/쿼리/상태/비교 |
|
||||
|
||||
@@ -395,7 +395,7 @@ Listagem completa: `gsd-core/bin/lib/*.cjs`.
|
||||
| `decisions.cjs` | Analisa blocos `<decisions>` do CONTEXT.md; aceita IDs numéricos (D-42) e alfanuméricos (D-INFRA-01); retorna `{id, text, category, tags, trackable}` |
|
||||
| `docs.cjs` | Inicialização do workflow docs-update, varredura de Markdown, detecção de monorepo |
|
||||
| `drift.cjs` | Detector de drift estrutural pós-execução da base de código (#2003): classifica alterações de arquivo em categorias new-dir/barrel/migration/route e faz round-trip do frontmatter `last_mapped_commit` |
|
||||
| `fallow-runner.cjs` | Adaptador de auditoria fallow para `/gsd-code-review`: resolução binária (`PATH` depois `node_modules/.bin`), erros acionáveis de binário ausente e normalização de descobertas estruturais |
|
||||
| `fallow-runner.cjs` | Adaptador de auditoria fallow para `/gsd-code-review`: resolução binária (`node_modules/.bin` depois `PATH`), erros acionáveis de binário ausente e normalização de descobertas estruturais |
|
||||
| `frontmatter.cjs` | Operações CRUD de frontmatter YAML |
|
||||
| `gap-checker.cjs` | Análise de lacunas pós-planejamento (#2493): relatório unificado de cobertura de decisões do REQUIREMENTS.md + CONTEXT.md vs PLAN.md (`gsd-tools gap-analysis`) |
|
||||
| `graphify.cjs` | Build/consulta/status/diff do grafo de conhecimento para `/gsd-graphify` |
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
| `decisions.cjs` | 解析 CONTEXT.md `<decisions>` 块;接受数字(D-42)和字母数字(D-INFRA-01)ID;返回 `{id, text, category, tags, trackable}` |
|
||||
| `docs.cjs` | 文档更新工作流初始化、Markdown 扫描、单体仓库检测 |
|
||||
| `drift.cjs` | 执行后代码库结构漂移检测器(#2003):将文件更改分类为新目录/桶/迁移/路由类别,并循环处理 `last_mapped_commit` frontmatter |
|
||||
| `fallow-runner.cjs` | `/gsd-code-review` 的 fallow 审计适配器:二进制解析(`PATH` 然后 `node_modules/.bin`)、可操作的缺少二进制错误和结构性发现规范化 |
|
||||
| `fallow-runner.cjs` | `/gsd-code-review` 的 fallow 审计适配器:二进制解析(`node_modules/.bin` 然后 `PATH`)、可操作的缺少二进制错误和结构性发现规范化 |
|
||||
| `frontmatter.cjs` | YAML frontmatter 增删改查操作 |
|
||||
| `gap-checker.cjs` | 规划后间隙分析(#2493):REQUIREMENTS.md + CONTEXT.md 决策 vs PLAN.md 覆盖率报告(`gsd-tools gap-analysis`) |
|
||||
| `graphify.cjs` | `/gsd-graphify` 的知识图谱构建/查询/状态/差异 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
When `FALLOW_ENABLED=true`:
|
||||
|
||||
1) Resolve binary via PATH first, then `node_modules/.bin/fallow`.
|
||||
1) Resolve binary via `node_modules/.bin/fallow` first, then PATH.
|
||||
```bash
|
||||
FALLOW_BIN=$(FALLOW_CWD="$(pwd)" node -e "
|
||||
const { resolveFallowBinary } = require('./gsd-core/bin/lib/fallow-runner.cjs');
|
||||
|
||||
@@ -7,51 +7,18 @@
|
||||
*
|
||||
* Parses the real fallow `audit --format json` schema (schema_version 3
|
||||
* envelope, nested dead_code/duplication sections). See fallow 2.70.0+.
|
||||
*
|
||||
* #3411 Phase 2 (#3618): binary resolution no longer lives here — it delegates
|
||||
* to the platform seam's `resolveExecutableBinary` (shell-command-projection.cts).
|
||||
* This file's prior private resolver deliberately included an extensionless
|
||||
* `fallow` as a win32 candidate; the seam does not, and that is a fix, not a
|
||||
* regression — an extensionless file sitting beside `fallow.cmd` is npm's POSIX
|
||||
* `sh` shim, which `CreateProcess` cannot run (#3275).
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function candidateNames(): string[] {
|
||||
return process.platform === 'win32'
|
||||
? ['fallow.exe', 'fallow.cmd', 'fallow.bat', 'fallow']
|
||||
: ['fallow'];
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) return false;
|
||||
if (process.platform === 'win32') return true;
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findInPath(envPath: string | undefined): string | null {
|
||||
if (!envPath) return null;
|
||||
const names = candidateNames();
|
||||
const segments = envPath.split(path.delimiter).filter(Boolean);
|
||||
for (const segment of segments) {
|
||||
for (const name of names) {
|
||||
const candidate = path.join(segment, name);
|
||||
if (isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findInNodeModules(cwd: string): string | null {
|
||||
const names = candidateNames();
|
||||
const binDir = path.join(cwd, 'node_modules', '.bin');
|
||||
for (const name of names) {
|
||||
const candidate = path.join(binDir, name);
|
||||
if (isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
import { resolveExecutableBinary } from './shell-command-projection.cjs';
|
||||
|
||||
export interface ResolveFallowOpts {
|
||||
cwd: string;
|
||||
@@ -59,7 +26,16 @@ export interface ResolveFallowOpts {
|
||||
}
|
||||
|
||||
export function resolveFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string | null {
|
||||
return findInNodeModules(cwd) || findInPath(envPath) || 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'] ?? '' },
|
||||
requireExecutable: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function requireFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string {
|
||||
|
||||
@@ -653,11 +653,14 @@ const DEFAULT_PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
||||
/** Windows extensions that must be mediated through cmd.exe rather than spawned. */
|
||||
const CMD_MEDIATED_EXT = /\.(cmd|bat)$/i;
|
||||
|
||||
function _isFile(candidate: string): boolean {
|
||||
function _isFile(candidate: string, requireExecutable = false, platform: string = process.platform): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
if (!fs.statSync(candidate).isFile()) return false;
|
||||
if (requireExecutable && platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
// Missing, unreadable (EACCES), or a broken link — all mean "not this one".
|
||||
// Missing, unreadable (EACCES), a broken link, or (when requireExecutable
|
||||
// is set) not executable — all mean "not this one".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -733,25 +736,45 @@ function _buildVerbatimCmdLine(target: string, args: string[]): string {
|
||||
* Path-like names (any `/` or `\`) bypass the PATH scan: the name is already an
|
||||
* address, so it passes through when it names an existing file.
|
||||
*
|
||||
* **`opts.prependPaths`** — directories searched BEFORE `env.PATH`, in array
|
||||
* order (e.g. a project-local `node_modules/.bin`). Defaults to `[]`, so
|
||||
* Phase 1's callers (`execTool`, and `gsd-tools.cjs`'s `resolveSpawnBinary` /
|
||||
* `deps.spawn` / `hasBinary`), which set neither new option, are byte-identical
|
||||
* to today. The existing per-directory candidate logic (win32 as-is-then-append-
|
||||
* PATHEXT; POSIX bare name) applies to prepended directories exactly as it does
|
||||
* to `PATH` segments — there is no special-casing.
|
||||
*
|
||||
* **`opts.requireExecutable`** — when `true` and the platform is not `win32`,
|
||||
* a candidate must additionally pass `fs.accessSync(candidate, fs.constants.X_OK)`
|
||||
* to count as a match. On `win32` this is a no-op (mode bits do not mean
|
||||
* execute on Windows — the same carve-out `fallow-runner`'s prior private
|
||||
* resolver already had). Defaults to `false`, so `accessSync` is never called
|
||||
* unless a caller opts in. It is opt-in rather than the default because making
|
||||
* `X_OK` unconditional would break #3445's suite: those tests stage candidates
|
||||
* 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.
|
||||
*
|
||||
* @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 } = {},
|
||||
opts: { platform?: string; env?: NodeJS.ProcessEnv; prependPaths?: string[]; requireExecutable?: boolean } = {},
|
||||
): string | null {
|
||||
if (!name) return null;
|
||||
const requireExecutable = opts.requireExecutable ?? false;
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (name.includes('/') || name.includes('\\')) {
|
||||
return _isFile(name) ? name : null;
|
||||
return _isFile(name, requireExecutable, platform) ? name : null;
|
||||
}
|
||||
const env = opts.env ?? process.env;
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const segments = String(_envGet(env, 'PATH') || '').split(path.delimiter).filter(Boolean);
|
||||
const pathSegments = String(_envGet(env, 'PATH') || '').split(path.delimiter).filter(Boolean);
|
||||
const segments = [...(opts.prependPaths ?? []), ...pathSegments];
|
||||
|
||||
if (platform !== 'win32') {
|
||||
for (const dir of segments) {
|
||||
const candidate = path.join(dir, name);
|
||||
if (_isFile(candidate)) return candidate;
|
||||
if (_isFile(candidate, requireExecutable, platform)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -766,11 +789,11 @@ export function resolveExecutableBinary(
|
||||
for (const dir of segments) {
|
||||
if (carriesKnownExt) {
|
||||
const asIs = path.join(dir, name);
|
||||
if (_isFile(asIs)) return asIs;
|
||||
if (_isFile(asIs, requireExecutable, platform)) return asIs;
|
||||
}
|
||||
for (const ext of exts) {
|
||||
const candidate = path.join(dir, name + ext);
|
||||
if (_isFile(candidate)) return candidate;
|
||||
if (_isFile(candidate, requireExecutable, platform)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
329
tests/fallow-runner.test.cjs
Normal file
329
tests/fallow-runner.test.cjs
Normal file
@@ -0,0 +1,329 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const {
|
||||
resolveFallowBinary,
|
||||
requireFallowBinary,
|
||||
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'fallow-runner.cjs'));
|
||||
const {
|
||||
resolveExecutableBinary,
|
||||
} = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// `resolveFallowBinary` resolves through the seam using the REAL process.platform —
|
||||
// it has no platform injection. So the fixture, not the assertion, is what varies:
|
||||
// on win32 the seam only accepts a PATHEXT-listed extension (never a bare name, by
|
||||
// design — the #3275 npm sh-shim trap), so the staged file must carry one there.
|
||||
// This keeps every row genuinely executing on all three CI lanes instead of
|
||||
// silently no-opping on one.
|
||||
//
|
||||
// The extension's CASE must come from the same source the seam itself falls
|
||||
// back to (fallow-runner.cjs passes `process.env.PATHEXT ?? ''`, and the seam
|
||||
// falls back to its own DEFAULT_PATHEXT `.EXE;.CMD;.BAT;.COM` when that's
|
||||
// empty) — resolveExecutableBinary returns `name + <PATHEXT entry AS-IS>`,
|
||||
// never the on-disk file's own casing, so a hardcoded 'fallow.cmd' would
|
||||
// silently mismatch a real Windows PATHEXT of '.CMD' with a strict
|
||||
// assert.equal (case-insensitive filesystem, case-SENSITIVE string compare).
|
||||
const WIN32_CMD_EXT = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
||||
.split(';')
|
||||
.find((ext) => ext.toLowerCase() === '.cmd') ?? '.CMD';
|
||||
const FALLOW_FIXTURE = process.platform === 'win32' ? `fallow${WIN32_CMD_EXT}` : 'fallow';
|
||||
|
||||
// ─── fallow-runner (#3618 Phase 2) ──────────────────────────────────────────
|
||||
// Fold of the prior private resolver onto the seam's resolveExecutableBinary.
|
||||
// See .gsd/phase/chore-3618-fallow-binary-resolution/50-test-matrix.md (F1-F15).
|
||||
//
|
||||
// Executability (X_OK) cannot be forced via chmod — root Docker bypasses mode
|
||||
// bits, which would silently zero out coverage. Every case that needs a POSIX
|
||||
// "is executable" or "is not executable" outcome monkeypatches fs.accessSync
|
||||
// instead, restored in a finally.
|
||||
//
|
||||
// F7-F10 (the win32 rows) cannot inject a platform through
|
||||
// resolveFallowBinary's public signature, so they are asserted directly
|
||||
// against resolveExecutableBinary with platform:'win32' plus the same
|
||||
// prependPaths/requireExecutable shape resolveFallowBinary passes — a
|
||||
// projection of fallow's call, not fallow's own entry point (approach (a)
|
||||
// from the phase brief).
|
||||
function resolveFallowBinaryWin32({ cwd, envPath = '' }) {
|
||||
return resolveExecutableBinary('fallow', {
|
||||
platform: 'win32',
|
||||
prependPaths: [path.join(cwd, 'node_modules', '.bin')],
|
||||
env: { PATH: envPath, PATHEXT: process.env['PATHEXT'] ?? '' },
|
||||
requireExecutable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveFallowBinary (#3618)', () => {
|
||||
test('F1: POSIX, executable fallow in <cwd>/node_modules/.bin only — the .bin path', () => {
|
||||
const cwd = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
const binDir = path.join(cwd, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const fallowPath = path.join(binDir, FALLOW_FIXTURE);
|
||||
fs.writeFileSync(fallowPath, '');
|
||||
fs.accessSync = () => {};
|
||||
const resolved = resolveFallowBinary({ cwd, envPath: '' });
|
||||
assert.equal(resolved, fallowPath);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('F2: POSIX, fallow on envPath only — the PATH copy', () => {
|
||||
const cwd = createTempDir();
|
||||
const pathDir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
const fallowPath = path.join(pathDir, FALLOW_FIXTURE);
|
||||
fs.writeFileSync(fallowPath, '');
|
||||
fs.accessSync = () => {};
|
||||
const resolved = resolveFallowBinary({ cwd, envPath: pathDir });
|
||||
assert.equal(resolved, fallowPath);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(cwd);
|
||||
cleanup(pathDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('F3: POSIX, executable fallow in BOTH .bin and PATH — .bin wins (documented-backwards precedence)', () => {
|
||||
const cwd = createTempDir();
|
||||
const pathDir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
const binDir = path.join(cwd, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const binFallow = path.join(binDir, FALLOW_FIXTURE);
|
||||
const pathFallow = path.join(pathDir, FALLOW_FIXTURE);
|
||||
fs.writeFileSync(binFallow, '');
|
||||
fs.writeFileSync(pathFallow, '');
|
||||
fs.accessSync = () => {};
|
||||
const resolved = resolveFallowBinary({ cwd, envPath: pathDir });
|
||||
assert.equal(resolved, binFallow);
|
||||
assert.notEqual(resolved, pathFallow);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(cwd);
|
||||
cleanup(pathDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('F4: accessSync failure yields null on POSIX; on win32 requireExecutable is a documented no-op', () => {
|
||||
const cwd = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
const binDir = path.join(cwd, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const fallowPath = path.join(binDir, FALLOW_FIXTURE);
|
||||
fs.writeFileSync(fallowPath, '');
|
||||
fs.accessSync = () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); };
|
||||
const resolved = resolveFallowBinary({ cwd, envPath: '' });
|
||||
if (process.platform === 'win32') {
|
||||
// requireExecutable is a documented no-op on win32 (mode bits don't
|
||||
// mean execute there), so accessSync throwing must NOT block resolution.
|
||||
assert.equal(resolved, fallowPath);
|
||||
} else {
|
||||
// POSIX: the X_OK check is consulted, so a throwing accessSync must null out.
|
||||
assert.equal(resolved, null);
|
||||
}
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('F5: absent everywhere — null', () => {
|
||||
const cwd = createTempDir();
|
||||
try {
|
||||
const resolved = resolveFallowBinary({ cwd, envPath: '' });
|
||||
assert.equal(resolved, null);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('F6: envPath omitted (default from process.env.PATH), binary in .bin — resolves from .bin', () => {
|
||||
const cwd = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
const binDir = path.join(cwd, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const fallowPath = path.join(binDir, FALLOW_FIXTURE);
|
||||
fs.writeFileSync(fallowPath, '');
|
||||
fs.accessSync = () => {};
|
||||
process.env.PATH = '';
|
||||
// Call shape production uses (structural-pre-pass.md): no envPath.
|
||||
const resolved = resolveFallowBinary({ cwd });
|
||||
assert.equal(resolved, fallowPath);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
process.env.PATH = originalPath;
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('F7: win32, fallow.CMD in .bin — resolves .bin/fallow.CMD', () => {
|
||||
const cwd = createTempDir();
|
||||
try {
|
||||
const binDir = path.join(cwd, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const fallowCmd = path.join(binDir, 'fallow.CMD');
|
||||
fs.writeFileSync(fallowCmd, '');
|
||||
const resolved = resolveFallowBinaryWin32({ cwd, envPath: '' });
|
||||
assert.equal(resolved, fallowCmd);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('F8: win32, fallow.EXE and fallow.CMD both on PATH — .EXE wins (PATHEXT default order)', () => {
|
||||
const cwd = createTempDir();
|
||||
const pathDir = createTempDir();
|
||||
try {
|
||||
const exePath = path.join(pathDir, 'fallow.EXE');
|
||||
const cmdPath = path.join(pathDir, 'fallow.CMD');
|
||||
fs.writeFileSync(exePath, '');
|
||||
fs.writeFileSync(cmdPath, '');
|
||||
const resolved = resolveFallowBinaryWin32({ cwd, envPath: pathDir });
|
||||
assert.equal(resolved, exePath);
|
||||
assert.notEqual(resolved, cmdPath);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
cleanup(pathDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('F9: win32, extensionless fallow beside fallow.CMD — resolves fallow.CMD, never the shim (#3275 trap)', () => {
|
||||
const cwd = createTempDir();
|
||||
const pathDir = createTempDir();
|
||||
try {
|
||||
const bareFallow = path.join(pathDir, 'fallow');
|
||||
const cmdFallow = path.join(pathDir, 'fallow.CMD');
|
||||
fs.writeFileSync(bareFallow, '');
|
||||
fs.writeFileSync(cmdFallow, '');
|
||||
const resolved = resolveFallowBinaryWin32({ cwd, envPath: pathDir });
|
||||
assert.equal(resolved, cmdFallow);
|
||||
assert.notEqual(resolved, bareFallow);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
cleanup(pathDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('F10: win32, only an extensionless fallow — null (intentional change)', () => {
|
||||
const cwd = createTempDir();
|
||||
const pathDir = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(pathDir, 'fallow'), '');
|
||||
const resolved = resolveFallowBinaryWin32({ cwd, envPath: pathDir });
|
||||
assert.equal(resolved, null);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
cleanup(pathDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('F11: requireFallowBinary with a binary present — returns the path, does not throw', () => {
|
||||
const cwd = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
const binDir = path.join(cwd, 'node_modules', '.bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const fallowPath = path.join(binDir, FALLOW_FIXTURE);
|
||||
fs.writeFileSync(fallowPath, '');
|
||||
fs.accessSync = () => {};
|
||||
const resolved = requireFallowBinary({ cwd, envPath: '' });
|
||||
assert.equal(resolved, fallowPath);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('F12: requireFallowBinary with nothing found — throws the byte-identical install-instructions message', () => {
|
||||
const cwd = createTempDir();
|
||||
try {
|
||||
let caught = null;
|
||||
try {
|
||||
requireFallowBinary({ cwd, envPath: '' });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
assert.ok(caught instanceof Error, 'requireFallowBinary must throw when nothing is found');
|
||||
assert.equal(
|
||||
caught.message,
|
||||
'Fallow is enabled but no binary was found. Please install fallow via `npm install -D fallow` or `cargo install fallow`.',
|
||||
);
|
||||
} finally {
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
|
||||
test('F13: <cwd> has no node_modules at all — no throw; falls through to PATH', () => {
|
||||
const cwd = createTempDir();
|
||||
const pathDir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
const fallowPath = path.join(pathDir, FALLOW_FIXTURE);
|
||||
fs.writeFileSync(fallowPath, '');
|
||||
fs.accessSync = () => {};
|
||||
process.env.PATH = pathDir;
|
||||
// Call shape production uses (structural-pre-pass.md): no envPath.
|
||||
assert.doesNotThrow(() => {
|
||||
const resolved = resolveFallowBinary({ cwd });
|
||||
assert.equal(resolved, fallowPath);
|
||||
});
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
process.env.PATH = originalPath;
|
||||
cleanup(cwd);
|
||||
cleanup(pathDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('F14: <cwd>/node_modules/.bin exists but is a file, not a directory — no throw; falls through', () => {
|
||||
const cwd = createTempDir();
|
||||
const pathDir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
const nodeModulesDir = path.join(cwd, 'node_modules');
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nodeModulesDir, '.bin'), ''); // .bin is a FILE, not a dir
|
||||
const fallowPath = path.join(pathDir, FALLOW_FIXTURE);
|
||||
fs.writeFileSync(fallowPath, '');
|
||||
fs.accessSync = () => {};
|
||||
assert.doesNotThrow(() => {
|
||||
const resolved = resolveFallowBinary({ cwd, envPath: pathDir });
|
||||
assert.equal(resolved, fallowPath);
|
||||
});
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(cwd);
|
||||
cleanup(pathDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('F15: a directory named fallow in .bin — not resolved (isFile() is the predicate)', () => {
|
||||
const cwd = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
const binDir = path.join(cwd, 'node_modules', '.bin');
|
||||
fs.mkdirSync(path.join(binDir, FALLOW_FIXTURE), { recursive: true }); // fallow is a DIRECTORY
|
||||
fs.accessSync = () => {};
|
||||
const resolved = resolveFallowBinary({ cwd, envPath: '' });
|
||||
assert.equal(resolved, null);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(cwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -413,6 +413,228 @@ describe('resolveExecutableBinary (#3411)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveExecutableBinary seam options (#3618) ───────────────────────────
|
||||
// prependPaths / requireExecutable — both opt-in, both default off, so
|
||||
// Phase 1 callers (R1-R25 above, P1-P16 below) stay byte-identical. See
|
||||
// .gsd/phase/chore-3618-fallow-binary-resolution/50-test-matrix.md (S1-S12).
|
||||
|
||||
describe('resolveExecutableBinary seam options (#3618)', () => {
|
||||
test('S1: prependPaths with binary only in dirA resolves in dirA', () => {
|
||||
const dirA = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dirA, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: '' },
|
||||
prependPaths: [dirA],
|
||||
});
|
||||
assert.equal(resolved, path.join(dirA, 'foo'));
|
||||
} finally {
|
||||
cleanup(dirA);
|
||||
}
|
||||
});
|
||||
|
||||
test('S2: binary in both prependPaths dir and env.PATH — the prependPaths copy wins (precedence)', () => {
|
||||
const dirA = createTempDir();
|
||||
const dirB = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dirA, 'foo'), '');
|
||||
fs.writeFileSync(path.join(dirB, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: dirB },
|
||||
prependPaths: [dirA],
|
||||
});
|
||||
assert.equal(resolved, path.join(dirA, 'foo'));
|
||||
assert.notEqual(resolved, path.join(dirB, 'foo'));
|
||||
} finally {
|
||||
cleanup(dirA);
|
||||
cleanup(dirB);
|
||||
}
|
||||
});
|
||||
|
||||
test('S3: prependPaths: [] is identical to omitting it', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'foo'), '');
|
||||
const withEmpty = resolveExecutableBinary('foo', { platform: 'linux', env: { PATH: dir }, prependPaths: [] });
|
||||
const withOmitted = resolveExecutableBinary('foo', { platform: 'linux', env: { PATH: dir } });
|
||||
assert.equal(withEmpty, path.join(dir, 'foo'));
|
||||
assert.equal(withEmpty, withOmitted);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('S4: prependPaths given, binary only on env.PATH — falls through to PATH', () => {
|
||||
const dirA = createTempDir();
|
||||
const dirB = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dirB, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: dirB },
|
||||
prependPaths: [dirA],
|
||||
});
|
||||
assert.equal(resolved, path.join(dirB, 'foo'));
|
||||
} finally {
|
||||
cleanup(dirA);
|
||||
cleanup(dirB);
|
||||
}
|
||||
});
|
||||
|
||||
test('S5: prependPaths with two dirs, binary in the second — first-match wins, in array order', () => {
|
||||
const dir1 = createTempDir();
|
||||
const dir2 = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir2, 'foo'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: '' },
|
||||
prependPaths: [dir1, dir2],
|
||||
});
|
||||
assert.equal(resolved, path.join(dir2, 'foo'));
|
||||
// dir1 is first in the array but has no match, so dir2 wins — confirms
|
||||
// the search follows array order, not the reverse.
|
||||
assert.notEqual(resolved, path.join(dir1, 'foo'));
|
||||
} finally {
|
||||
cleanup(dir1);
|
||||
cleanup(dir2);
|
||||
}
|
||||
});
|
||||
|
||||
test('S6: win32 + prependPaths — PATHEXT applies inside the prepended dir too', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'win32',
|
||||
env: { PATH: '', PATHEXT: '.EXE;.CMD' },
|
||||
prependPaths: [dir],
|
||||
});
|
||||
assert.equal(resolved, path.join(dir, 'foo.CMD'));
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('S7: requireExecutable true, POSIX, file exists and is executable — resolves', () => {
|
||||
const dir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'foo'), '');
|
||||
// Executability is forced deterministically via accessSync monkeypatch
|
||||
// rather than chmod — root Docker bypasses mode bits (see file header).
|
||||
fs.accessSync = () => {};
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: dir },
|
||||
requireExecutable: true,
|
||||
});
|
||||
assert.equal(resolved, path.join(dir, 'foo'));
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('S8: requireExecutable true, POSIX, accessSync throws EACCES — null', () => {
|
||||
const dir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'foo'), '');
|
||||
fs.accessSync = () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); };
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'linux',
|
||||
env: { PATH: dir },
|
||||
requireExecutable: true,
|
||||
});
|
||||
assert.equal(resolved, null);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('S9: requireExecutable omitted — resolves anyway and accessSync is never consulted', () => {
|
||||
const dir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
let called = false;
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'foo'), '');
|
||||
fs.accessSync = () => {
|
||||
called = true;
|
||||
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||
};
|
||||
const resolved = resolveExecutableBinary('foo', { platform: 'linux', env: { PATH: dir } });
|
||||
assert.equal(resolved, path.join(dir, 'foo'));
|
||||
assert.equal(called, false, 'accessSync must not be consulted when requireExecutable is not set');
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('S10: requireExecutable true + win32 is a no-op — resolves without consulting accessSync', () => {
|
||||
const dir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
let called = false;
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
|
||||
fs.accessSync = () => {
|
||||
called = true;
|
||||
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||
};
|
||||
const resolved = resolveExecutableBinary('foo', {
|
||||
platform: 'win32',
|
||||
env: { PATH: dir, PATHEXT: '.CMD' },
|
||||
requireExecutable: true,
|
||||
});
|
||||
assert.equal(resolved, path.join(dir, 'foo.CMD'));
|
||||
assert.equal(called, false, 'accessSync must never be consulted on win32');
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('S11: requireExecutable true + path-like name — executability still enforced on the direct path', () => {
|
||||
const dir = createTempDir();
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
const staged = path.join(dir, 'foo');
|
||||
fs.writeFileSync(staged, '');
|
||||
fs.accessSync = () => { throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); };
|
||||
const resolved = resolveExecutableBinary(staged, {
|
||||
platform: 'linux',
|
||||
env: { PATH: '' },
|
||||
requireExecutable: true,
|
||||
});
|
||||
assert.equal(resolved, null);
|
||||
} finally {
|
||||
fs.accessSync = originalAccessSync;
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('S12: neither seam option set behaves exactly as before (win32 and posix)', () => {
|
||||
const dirWin = createTempDir();
|
||||
const dirPosix = createTempDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dirWin, 'foo.CMD'), '');
|
||||
const win = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH: dirWin, PATHEXT: '.CMD' } });
|
||||
assert.equal(win, path.join(dirWin, 'foo.CMD'));
|
||||
|
||||
fs.writeFileSync(path.join(dirPosix, 'foo'), '');
|
||||
const posix = resolveExecutableBinary('foo', { platform: 'linux', env: { PATH: dirPosix } });
|
||||
assert.equal(posix, path.join(dirPosix, 'foo'));
|
||||
} finally {
|
||||
cleanup(dirWin);
|
||||
cleanup(dirPosix);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── projectSpawnInvocation (#3411) ─────────────────────────────────────────
|
||||
|
||||
// Mirrors the seam's own `_cmdQuoteToken` (a literal `"` is doubled) so
|
||||
|
||||
Reference in New Issue
Block a user