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.
167 lines
7.2 KiB
JavaScript
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;
|