Files
msd-core/eslint-rules/no-private-binary-resolution.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

167 lines
7.2 KiB
JavaScript

'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, msd-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 .msd/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;