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.
266 lines
10 KiB
JavaScript
266 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Copy a repo script into a throwaway fixture tree ALONG WITH its transitive
|
|
* relative-require dependencies, preserving repo-relative layout.
|
|
*
|
|
* Several suites drive a `scripts/*.cjs` end-to-end by copying it into an
|
|
* mkdtemp fixture and spawning it there — necessary because those scripts
|
|
* resolve their scan root from `path.join(__dirname, '..')`, so running the
|
|
* REAL script would scan the real repo instead of the fixture (see
|
|
* tests/removed-but-needed-lint.test.cjs's copyScriptInto for the original
|
|
* statement of that constraint).
|
|
*
|
|
* Each such harness used to hand-list the script's dependencies
|
|
* (`fs.copyFileSync(... 'scripts/lib/cli-exit.cjs' ...)`). That made every new
|
|
* require in a covered script a silent, duplicated edit across N harnesses,
|
|
* and the failure mode was a MODULE_NOT_FOUND that only ever appeared in CI.
|
|
* #3412 collected the bill: adding one require of the new pattern seam to
|
|
* gen-adr-index.cjs broke 82 tests across two suites that had each hand-copied
|
|
* a now-incomplete dependency list.
|
|
*
|
|
* Walking the require graph instead means a script's dependencies are derived,
|
|
* never re-declared, so this class cannot recur.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const espree = require('espree');
|
|
|
|
/** Relative specifiers — the only kind that resolves inside the fixture tree. */
|
|
const RELATIVE_SPEC_RE = /^\.{1,2}[\\/]/;
|
|
|
|
/**
|
|
* Parse attempts tried in order by `extractRequires`. `globalReturn: true` is
|
|
* required on the `script` attempt because Node wraps every CommonJS module
|
|
* body in an implicit function, which makes a top-level `return` legal there
|
|
* even though it is not legal in a bare ECMAScript Program — `espree` without
|
|
* the flag rejects it with "'return' outside of function". A real shipped
|
|
* script relies on exactly this (`scripts/check-coverage-gate.cjs` has a
|
|
* top-level `return`), so dropping the flag silently breaks the #2858
|
|
* packaging guard that scans it. Do not remove this without re-verifying
|
|
* every shipped `scripts/**`, `bin/**`, and `msd-core/bin/**` .cjs/.js file
|
|
* still parses.
|
|
*/
|
|
const PARSE_ATTEMPTS = [
|
|
{ ecmaVersion: 'latest', sourceType: 'script', ecmaFeatures: { globalReturn: true } },
|
|
{ ecmaVersion: 'latest', sourceType: 'module' },
|
|
];
|
|
|
|
/**
|
|
* Extract every static `require('...')` string-literal call from a CJS or ESM
|
|
* source, via a real AST parse rather than pattern-matching. Commented-out,
|
|
* string-embedded, and template-literal occurrences are correctly ignored;
|
|
* `foo.require('x')` (a method call, not a bare identifier callee) is
|
|
* correctly ignored; dynamic `require(variable)` remains out of scope — it
|
|
* cannot be resolved statically, and in a script that ships it would itself
|
|
* be a red flag (see tests/packaging-shipped-scripts-require-only-shipped.
|
|
* test.cjs, which consumes this same extractor so the two guards cannot
|
|
* disagree about what "a require" is).
|
|
*
|
|
* Currently a spurious hit (a require-shaped call this function reports that
|
|
* the source does not actually reach) is not merely harmless: an unresolvable
|
|
* specifier makes `copyScriptWithDeps` throw. With a real parser this is
|
|
* largely moot — there is no pattern-matching left to produce a false
|
|
* positive from comment/string/regex content.
|
|
*
|
|
* @param {string} source
|
|
* @returns {string[]} specifiers, in source order, duplicates included
|
|
*/
|
|
function extractRequires(source) {
|
|
let ast = null;
|
|
for (const options of PARSE_ATTEMPTS) {
|
|
try {
|
|
ast = espree.parse(source, options);
|
|
break;
|
|
} catch {
|
|
/* try next */
|
|
}
|
|
}
|
|
if (ast === null) {
|
|
throw new Error('extractRequires: source did not parse as script or module');
|
|
}
|
|
const found = [];
|
|
(function walk(node) {
|
|
if (node === null || typeof node !== 'object') return;
|
|
if (Array.isArray(node)) {
|
|
node.forEach(walk);
|
|
return;
|
|
}
|
|
if (
|
|
node.type === 'CallExpression' &&
|
|
node.callee &&
|
|
node.callee.type === 'Identifier' &&
|
|
node.callee.name === 'require' &&
|
|
node.arguments.length === 1 &&
|
|
node.arguments[0].type === 'Literal' &&
|
|
typeof node.arguments[0].value === 'string'
|
|
) {
|
|
found.push(node.arguments[0].value);
|
|
}
|
|
for (const key of Object.keys(node)) walk(node[key]);
|
|
})(ast);
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Resolve a relative require specifier to a real file, applying Node's CJS
|
|
* extension/index candidates.
|
|
*
|
|
* @param {string} fromAbsFile absolute path of the requiring file
|
|
* @param {string} spec the relative specifier
|
|
* @returns {string|null} absolute path of the resolved file, or null
|
|
*/
|
|
function resolveRelativeRequire(fromAbsFile, spec) {
|
|
const base = path.resolve(path.dirname(fromAbsFile), spec);
|
|
const candidates = [
|
|
base,
|
|
`${base}.cjs`,
|
|
`${base}.js`,
|
|
`${base}.json`,
|
|
path.join(base, 'index.cjs'),
|
|
path.join(base, 'index.js'),
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* True when `rel` (a `path.relative(base, target)` result) climbs outside
|
|
* `base` — i.e. `target` is not contained within `base`. Guards against the
|
|
* `'..foo'.startsWith('..')` false positive (a real sibling directory named
|
|
* `..foo` is NOT an escape) by requiring either an exact `..` or a
|
|
* `..<sep>`-prefixed relative path, using the platform separator since
|
|
* `path.relative` returns platform-native separators.
|
|
*
|
|
* @param {string} rel
|
|
* @returns {boolean}
|
|
*/
|
|
function escapesContainment(rel) {
|
|
return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
}
|
|
|
|
/**
|
|
* Copy `scriptRel` (repo-relative, e.g. `scripts/gen-adr-index.cjs`) and every
|
|
* file reachable from it through static relative requires into `fixtureRoot`,
|
|
* at the same repo-relative paths. Bare specifiers (`node:fs`, npm packages)
|
|
* are left alone — they resolve from the real installation.
|
|
*
|
|
* Both the entry script and every discovered dependency are validated against
|
|
* the repo boundary before being copied (symlinks resolved via
|
|
* `fs.realpathSync` first, since the containment check is otherwise lexical
|
|
* while `copyFileSync`/`readFileSync` follow links — a repo-committed symlink
|
|
* pointing outside the repo must not smuggle its target's contents in). The
|
|
* ORIGINAL repo-relative path (not the realpath-derived one) is preserved for
|
|
* the destination location, so the copied layout is unchanged; the
|
|
* realpath-derived repo-relative path is used as the dedupe key so a
|
|
* directory-symlink cycle cannot mint a new key per level.
|
|
*
|
|
* Throws when a relative require does not resolve on disk, or when the entry
|
|
* or a dependency resolves outside the repo. Unresolved-require failures are
|
|
* nearly always an unbuilt artifact (`msd-core/bin/lib/*.cjs` requires
|
|
* `npm run build:lib`), and failing here names the cause instead of letting
|
|
* the spawned subprocess die with a bare MODULE_NOT_FOUND.
|
|
*
|
|
* @param {string} repoRoot absolute path to the repo root
|
|
* @param {string} fixtureRoot absolute path to the temp fixture root
|
|
* @param {string} scriptRel repo-relative path of the script to copy
|
|
* @returns {string} absolute path of the copied script inside `fixtureRoot`
|
|
*/
|
|
function copyScriptWithDeps(repoRoot, fixtureRoot, scriptRel) {
|
|
const toPosix = (p) => p.split(path.sep).join('/');
|
|
const entry = toPosix(scriptRel);
|
|
const copied = new Set();
|
|
const unresolved = [];
|
|
|
|
let realRepoRoot;
|
|
try {
|
|
realRepoRoot = fs.realpathSync(repoRoot);
|
|
} catch (err) {
|
|
throw new Error(`copyScriptWithDeps: could not resolve repoRoot ${repoRoot}: ${err.message}`);
|
|
}
|
|
|
|
/**
|
|
* @param {string} abs absolute path (not yet realpath-resolved)
|
|
* @param {string} label human-readable label for error messages
|
|
* @returns {{ dedupeKey: string } | null} null when unresolvable or escaping
|
|
*/
|
|
function checkContainment(abs, label) {
|
|
let real;
|
|
try {
|
|
real = fs.realpathSync(abs);
|
|
} catch (err) {
|
|
unresolved.push(`${label} could not be resolved on disk: ${err.message}`);
|
|
return null;
|
|
}
|
|
const dedupeKey = toPosix(path.relative(realRepoRoot, real));
|
|
if (escapesContainment(dedupeKey)) {
|
|
unresolved.push(`${label} resolves outside the repo (${real}) — refusing to copy outside the fixture`);
|
|
return null;
|
|
}
|
|
return { dedupeKey };
|
|
}
|
|
|
|
/** @param {string} rel repo-relative posix path (ORIGINAL, pre-realpath) */
|
|
function copyOne(rel) {
|
|
const abs = path.join(repoRoot, rel);
|
|
if (!fs.existsSync(abs)) {
|
|
if (!copied.has(rel)) {
|
|
copied.add(rel);
|
|
unresolved.push(`${rel} (does not exist in the repo)`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const containment = checkContainment(abs, rel);
|
|
if (!containment) return;
|
|
if (copied.has(containment.dedupeKey)) return;
|
|
copied.add(containment.dedupeKey);
|
|
|
|
const dest = path.join(fixtureRoot, rel);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.copyFileSync(abs, dest);
|
|
|
|
for (const spec of extractRequires(fs.readFileSync(abs, 'utf-8'))) {
|
|
if (!RELATIVE_SPEC_RE.test(spec)) continue;
|
|
const depAbs = resolveRelativeRequire(abs, spec);
|
|
if (!depAbs) {
|
|
unresolved.push(`require('${spec}') from ${rel}`);
|
|
continue;
|
|
}
|
|
const depRel = toPosix(path.relative(repoRoot, depAbs));
|
|
copyOne(depRel);
|
|
}
|
|
}
|
|
|
|
// F2: the entry path bypasses the sandbox guard the same way a dependency
|
|
// could — validate it against the same containment rule before copying.
|
|
const entryAbs = path.join(repoRoot, entry);
|
|
if (!fs.existsSync(entryAbs)) {
|
|
throw new Error(`copyScriptWithDeps: entry script does not exist in the repo: ${entry}`);
|
|
}
|
|
const entryContainment = checkContainment(entryAbs, `entry script ${entry}`);
|
|
if (!entryContainment) {
|
|
throw new Error(
|
|
`copyScriptWithDeps: entry script ${entry} resolves outside the repo — refusing to copy outside the fixture`,
|
|
);
|
|
}
|
|
|
|
copyOne(entry);
|
|
|
|
if (unresolved.length > 0) {
|
|
throw new Error(
|
|
`copyScriptWithDeps(${entry}) could not resolve ${unresolved.length} ` +
|
|
`relative require(s); the fixture would fail with MODULE_NOT_FOUND:\n` +
|
|
unresolved.map((u) => ` - ${u}`).join('\n') +
|
|
`\nIf these are compiled artifacts under msd-core/bin/lib/, run \`npm run build:lib\`.`,
|
|
);
|
|
}
|
|
|
|
return path.join(fixtureRoot, scriptRel);
|
|
}
|
|
|
|
module.exports = { extractRequires, resolveRelativeRequire, copyScriptWithDeps };
|