* 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>
223 lines
7.0 KiB
TypeScript
223 lines
7.0 KiB
TypeScript
/**
|
|
* Fallow binary resolution and report normalisation.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/fallow-runner.cjs
|
|
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
|
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
|
*
|
|
* 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';
|
|
import { resolveExecutableBinary } from './shell-command-projection.cjs';
|
|
|
|
export interface ResolveFallowOpts {
|
|
cwd: string;
|
|
envPath?: string;
|
|
}
|
|
|
|
export function resolveFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string | null {
|
|
return resolveExecutableBinary('fallow', {
|
|
prependPaths: [path.join(cwd, 'node_modules', '.bin')],
|
|
// #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,
|
|
});
|
|
}
|
|
|
|
export function requireFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }: ResolveFallowOpts): string {
|
|
const binary = resolveFallowBinary({ cwd, envPath });
|
|
if (binary) return binary;
|
|
throw new Error(
|
|
'Fallow is enabled but no binary was found. Please install fallow via `npm install -D fallow` or `cargo install fallow`.',
|
|
);
|
|
}
|
|
|
|
// --- Real fallow audit --format json schema (schema_version 3) interfaces ---
|
|
|
|
interface FallowUnusedExport {
|
|
path?: string;
|
|
export_name?: string;
|
|
is_type_only?: boolean;
|
|
line?: number | null;
|
|
col?: number | null;
|
|
span_start?: number | null;
|
|
is_re_export?: boolean;
|
|
actions?: unknown[];
|
|
introduced?: boolean;
|
|
}
|
|
|
|
interface FallowUnusedFile {
|
|
path?: string;
|
|
actions?: unknown[];
|
|
introduced?: boolean;
|
|
}
|
|
|
|
interface FallowCircularDependency {
|
|
files?: string[];
|
|
length?: number;
|
|
line?: number | null;
|
|
col?: number | null;
|
|
actions?: unknown[];
|
|
introduced?: boolean;
|
|
}
|
|
|
|
interface FallowCloneInstance {
|
|
file?: string;
|
|
start_line?: number | null;
|
|
end_line?: number | null;
|
|
start_col?: number | null;
|
|
end_col?: number | null;
|
|
fragment?: string;
|
|
}
|
|
|
|
interface FallowCloneGroup {
|
|
instances?: FallowCloneInstance[];
|
|
}
|
|
|
|
interface FallowDeadCode {
|
|
unused_exports?: FallowUnusedExport[];
|
|
unused_files?: FallowUnusedFile[];
|
|
circular_dependencies?: FallowCircularDependency[];
|
|
summary?: unknown;
|
|
schema_version?: number;
|
|
}
|
|
|
|
interface FallowDuplication {
|
|
clone_groups?: FallowCloneGroup[];
|
|
stats?: unknown;
|
|
}
|
|
|
|
interface FallowReport {
|
|
schema_version?: number;
|
|
version?: string;
|
|
command?: string;
|
|
verdict?: string;
|
|
changed_files_count?: number;
|
|
base_ref?: string;
|
|
head_sha?: string;
|
|
elapsed_ms?: number;
|
|
summary?: unknown;
|
|
attribution?: unknown;
|
|
dead_code?: FallowDeadCode;
|
|
duplication?: FallowDuplication;
|
|
complexity?: unknown;
|
|
}
|
|
|
|
export interface FallowFinding {
|
|
type: 'unused_export' | 'unused_file' | 'duplicate_block' | 'circular_dependency';
|
|
message: string;
|
|
file: string;
|
|
line: number | null;
|
|
related_file?: string;
|
|
}
|
|
|
|
export interface NormalizedFallowReport {
|
|
summary: {
|
|
unused_exports: number;
|
|
unused_files: number;
|
|
duplicates: number;
|
|
circular_dependencies: number;
|
|
total: number;
|
|
};
|
|
findings: FallowFinding[];
|
|
}
|
|
|
|
export function normalizeFallowReport(report: FallowReport | null | undefined): NormalizedFallowReport {
|
|
const deadCodeRaw = report?.dead_code;
|
|
const duplicationRaw = report?.duplication;
|
|
const unusedExports: FallowUnusedExport[] = (Array.isArray(deadCodeRaw?.unused_exports)
|
|
? (deadCodeRaw?.unused_exports ?? [])
|
|
: []).filter((x): x is FallowUnusedExport => x !== null && typeof x === 'object');
|
|
const unusedFiles: FallowUnusedFile[] = (Array.isArray(deadCodeRaw?.unused_files)
|
|
? (deadCodeRaw?.unused_files ?? [])
|
|
: []).filter((x): x is FallowUnusedFile => x !== null && typeof x === 'object');
|
|
const circularDeps: FallowCircularDependency[] = (Array.isArray(deadCodeRaw?.circular_dependencies)
|
|
? (deadCodeRaw?.circular_dependencies ?? [])
|
|
: []).filter((x): x is FallowCircularDependency => x !== null && typeof x === 'object');
|
|
const cloneGroups: FallowCloneGroup[] = (Array.isArray(duplicationRaw?.clone_groups)
|
|
? (duplicationRaw?.clone_groups ?? [])
|
|
: []).filter((x): x is FallowCloneGroup => x !== null && typeof x === 'object');
|
|
|
|
const findings: FallowFinding[] = [];
|
|
|
|
for (const item of unusedExports) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
findings.push({
|
|
type: 'unused_export',
|
|
message: `Unused export ${item.export_name ?? '<unknown>'}`,
|
|
file: item.path ?? '',
|
|
line: item.line ?? null,
|
|
});
|
|
}
|
|
|
|
for (const item of unusedFiles) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
findings.push({
|
|
type: 'unused_file',
|
|
message: `Unused file ${item.path ?? '<unknown>'}`,
|
|
file: item.path ?? '',
|
|
line: null,
|
|
});
|
|
}
|
|
|
|
for (const item of circularDeps) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
const files = Array.isArray(item.files) ? item.files : [];
|
|
findings.push({
|
|
type: 'circular_dependency',
|
|
message: `Circular dependency: ${files.join(' -> ')}`,
|
|
file: files.length > 0 ? files[0] : '',
|
|
line: item.line ?? null,
|
|
});
|
|
}
|
|
|
|
for (const group of cloneGroups) {
|
|
if (!group || typeof group !== 'object') continue;
|
|
const instances = Array.isArray(group.instances) ? group.instances : [];
|
|
findings.push({
|
|
type: 'duplicate_block',
|
|
message: `Duplicate block (${instances.length} instances)`,
|
|
file: instances[0]?.file ?? '',
|
|
line: instances[0]?.start_line ?? null,
|
|
related_file: instances[1]?.file ?? '',
|
|
});
|
|
}
|
|
|
|
return {
|
|
summary: {
|
|
unused_exports: unusedExports.length,
|
|
unused_files: unusedFiles.length,
|
|
duplicates: cloneGroups.length,
|
|
circular_dependencies: circularDeps.length,
|
|
total: findings.length,
|
|
},
|
|
findings,
|
|
};
|
|
}
|
|
|
|
export function normalizeFallowReportFile(filePath: string): NormalizedFallowReport {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
const parsed = JSON.parse(raw) as FallowReport;
|
|
return normalizeFallowReport(parsed);
|
|
} catch {
|
|
return normalizeFallowReport(null);
|
|
}
|
|
}
|