diff --git a/.changeset/a-created-require-is-a-module-load.md b/.changeset/a-created-require-is-a-module-load.md new file mode 100644 index 0000000000..9275618fef --- /dev/null +++ b/.changeset/a-created-require-is-a-module-load.md @@ -0,0 +1,30 @@ +--- +"nextly": patch +"create-nextly-app": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/blocks-react": patch +"@nextlyhq/plugin-mcp": patch +"@nextlyhq/ui": patch +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-seo": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/eslint-config": patch +"@nextlyhq/eslint-plugin": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/builder": patch +"@nextlyhq/module-specifiers": patch +--- + +`@nextlyhq/module-specifiers` reads a require function that `createRequire` returned, whether bound to a name or called where it is made, `require.resolve` and `import.meta.resolve`, and labels every reference that reaches runtime with the resolver that finds it and whether it runs the module or only finds it, so a caller can follow a relative specifier to the file Node would load and skip a file Node never runs. diff --git a/package.json b/package.json index b5aa5ba0cd..15334dc29c 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "@eslint/js": "^9.34.0", "@manypkg/get-packages": "^1.1.3", "@nextlyhq/eslint-plugin": "workspace:*", + "@nextlyhq/module-specifiers": "workspace:*", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/packages/module-specifiers/README.md b/packages/module-specifiers/README.md index ef239b8155..da1457c3b8 100644 --- a/packages/module-specifiers/README.md +++ b/packages/module-specifiers/README.md @@ -71,3 +71,26 @@ the green it always did. The corpus that catches that belongs beside the reader. Note this is a different control from "the input set is non-empty". A guard can read every file it was given and still be unable to fail on any input. Only a known offender it must REJECT catches that. + +## Which resolver finds a module, and whether it runs + +A reference that survives to runtime also says which of Node's resolvers finds +it. `"esm"` covers an import declaration, a re-export, `import()` and +`import.meta.resolve()`, which take the path as written. `"cjs"` covers +`require()`, `module.require()`, `import x = require()`, a function +`createRequire` returned, whether bound to a name or called where it is made, +and `.resolve()` on either kind of require, which also try the extensions, the +directory's `package.json` `main` and the index files CommonJS adds. A caller +following a relative specifier to its file needs the difference: `./lib` is +`lib.js` to a require and nothing at all to an import. + +It also says whether the module runs. `loads` is `false` for `require.resolve()` +and `import.meta.resolve()`, which return where a module is without running it. +A package that is not installed still makes them throw, but nothing the found +file imports is needed, so a caller walking what a program runs follows only the +references with `loads: true`. + +A created require is recognised only when `createRequire` itself comes from +`module` or `node:module`. A helper of that name from anywhere else returns +whatever that helper returns, and reading its calls as module loads would report +a dependency nobody has. diff --git a/packages/module-specifiers/src/index.test.ts b/packages/module-specifiers/src/index.test.ts index 0448b36638..ff1698230a 100644 --- a/packages/module-specifiers/src/index.test.ts +++ b/packages/module-specifiers/src/index.test.ts @@ -164,13 +164,13 @@ describe("whether a reference survives to runtime", () => { it("reports a plain import as reaching runtime", () => { expect(refs(`import a from "pkg";`)).toEqual([ - { specifier: "pkg", typeOnly: false }, + { specifier: "pkg", typeOnly: false, resolution: "esm", loads: true }, ]); }); it("reports a bare side-effect import as reaching runtime", () => { expect(refs(`import "pkg";`)).toEqual([ - { specifier: "pkg", typeOnly: false }, + { specifier: "pkg", typeOnly: false, resolution: "esm", loads: true }, ]); }); @@ -191,25 +191,25 @@ describe("whether a reference survives to runtime", () => { // governing the whole clause erases a real runtime edge, which is the direction that answers // "clean" for a bundle guard. expect(refs(`import { a, type B } from "pkg";`)).toEqual([ - { specifier: "pkg", typeOnly: false }, + { specifier: "pkg", typeOnly: false, resolution: "esm", loads: true }, ]); }); it("reports a dynamic import as reaching runtime", () => { expect(refs(`const f = () => import("pkg");`)).toEqual([ - { specifier: "pkg", typeOnly: false }, + { specifier: "pkg", typeOnly: false, resolution: "esm", loads: true }, ]); }); it("reports require as reaching runtime", () => { expect(refs(`const a = require("pkg");`)).toEqual([ - { specifier: "pkg", typeOnly: false }, + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: true }, ]); }); it("reports an import-equals as reaching runtime", () => { expect(refs(`import a = require("pkg");`)).toEqual([ - { specifier: "pkg", typeOnly: false }, + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: true }, ]); }); @@ -237,7 +237,12 @@ describe("whether a reference survives to runtime", () => { it("keeps an unreadable target unreadable, and at runtime", () => { // An unresolvable target has to stay a violation for both consumers. expect(refs(`const a = require(name);`)).toEqual([ - { specifier: UNRESOLVABLE_SPECIFIER, typeOnly: false }, + { + specifier: UNRESOLVABLE_SPECIFIER, + typeOnly: false, + resolution: "cjs", + loads: true, + }, ]); }); @@ -271,7 +276,9 @@ describe("module.require", () => { ).toEqual(["pkg"]); expect( moduleSpecifierRefs(`const a = module.require("pkg");`, "m.ts") - ).toEqual([{ specifier: "pkg", typeOnly: false }]); + ).toEqual([ + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: true }, + ]); }); it("reads the bracket spelling the same way", () => { @@ -344,3 +351,167 @@ describe("module.require through wrappers and shadows", () => { expect(importedSpecifiers(source, "m.ts")).toEqual(["pkg"]); }); }); + +/** + * Which of Node's resolvers finds a reference that reaches runtime. A caller following a + * relative specifier needs it: `./lib` is `lib.js` to a require and nothing to an import. + */ +describe("the resolver that finds a runtime reference", () => { + it.each([ + [`import a from "pkg";`, "esm"], + [`export { a } from "pkg";`, "esm"], + [`import "pkg";`, "esm"], + [`const f = () => import("pkg");`, "esm"], + [`const a = require("pkg");`, "cjs"], + [`const a = module.require("pkg");`, "cjs"], + [`import a = require("pkg");`, "cjs"], + [`const a = (require)("pkg");`, "cjs"], + ])("finds %s with the %s resolver", (text, resolution) => { + expect(moduleSpecifierRefs(text, "module.ts")).toEqual([ + { specifier: "pkg", typeOnly: false, resolution, loads: true }, + ]); + }); +}); + +/** + * `const load = createRequire(import.meta.url)` is how an ES module reaches CommonJS, and + * `load("pkg")` then loads a package exactly as `require` does. A reader that knows only the + * name `require` reports such a file as loading nothing. + */ +describe("a require function createRequire returned", () => { + it.each([ + `import { createRequire } from "node:module";\nconst load = createRequire(import.meta.url);\nload("pkg");`, + `import { createRequire as make } from "module";\nconst load = make(import.meta.url);\nload("pkg");`, + `import * as nodeModule from "node:module";\nconst load = nodeModule.createRequire(import.meta.url);\nload("pkg");`, + `import nodeModule from "node:module";\nconst load = nodeModule["createRequire"](import.meta.url);\nload("pkg");`, + ])("reads its call as a CommonJS load: %s", text => { + expect( + moduleSpecifierRefs(text, "module.mjs").filter( + ref => ref.specifier === "pkg" + ) + ).toEqual([ + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: true }, + ]); + }); + + it("reads a createRequire destructured from require", () => { + const text = `const { createRequire } = require("node:module");\nconst load = createRequire(__filename);\nload("pkg");`; + + expect(moduleSpecifierRefs(text, "module.cjs")).toEqual([ + { + specifier: "node:module", + typeOnly: false, + resolution: "cjs", + loads: true, + }, + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: true }, + ]); + }); + + it("keeps an unreadable target unreadable", () => { + const text = `import { createRequire } from "node:module";\nconst load = createRequire(import.meta.url);\nload(name);`; + + expect(importedSpecifiers(text, "module.mjs")).toEqual([ + "node:module", + UNRESOLVABLE_SPECIFIER, + ]); + }); + + it("does not read a helper of the same name from anywhere else", () => { + // 🔴 The negative control. A `createRequire` from a local module returns whatever that helper + // returns, and reading its calls as module loads reports a dependency nobody has. + const text = `import { createRequire } from "./helpers.mjs";\nconst load = createRequire(import.meta.url);\nload("pkg");`; + + expect(importedSpecifiers(text, "module.mjs")).toEqual(["./helpers.mjs"]); + }); +}); + +/** + * `createRequire(import.meta.url)("pkg")` never binds the require function to a name, and this + * repository loads drizzle-kit exactly that way. A reader that knows only named require functions + * reports such a file as loading nothing. + */ +describe("a created require called where it is made", () => { + it.each([ + `import { createRequire } from "node:module";\nexport const load = () => createRequire(import.meta.url)("pkg");`, + `import * as nodeModule from "node:module";\nnodeModule.createRequire(import.meta.url)("pkg");`, + `import { createRequire } from "node:module";\n(createRequire(import.meta.url))("pkg");`, + ])("reads its call as a CommonJS load: %s", text => { + expect( + moduleSpecifierRefs(text, "module.mjs").filter( + ref => ref.specifier === "pkg" + ) + ).toEqual([ + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: true }, + ]); + }); + + it("reads resolve on it as finding the module without running it", () => { + const text = `import { createRequire } from "node:module";\nconst where = createRequire(import.meta.url).resolve("pkg");`; + + expect( + moduleSpecifierRefs(text, "module.mjs").filter( + ref => ref.specifier === "pkg" + ) + ).toEqual([ + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: false }, + ]); + }); + + it("does not read a helper of the same name from anywhere else", () => { + // 🔴 The negative control, as for a created require bound to a name. + const text = `import { createRequire } from "./helpers.mjs";\ncreateRequire(import.meta.url)("pkg");`; + + expect(importedSpecifiers(text, "module.mjs")).toEqual(["./helpers.mjs"]); + }); +}); + +/** + * Resolving a package that is not installed fails exactly as loading it does, but the file a + * resolve finds never runs. + */ +describe("calls that find a module without loading it", () => { + it.each([ + [`const p = require.resolve("pkg");`, "cjs"], + [`const p = require["resolve"]("pkg");`, "cjs"], + [`const p = import.meta.resolve("pkg");`, "esm"], + ])("reads %s with the %s resolver", (text, resolution) => { + expect(moduleSpecifierRefs(text, "module.mjs")).toEqual([ + { specifier: "pkg", typeOnly: false, resolution, loads: false }, + ]); + }); + + it("reads resolve on a created require", () => { + const text = `import { createRequire } from "node:module";\nconst load = createRequire(import.meta.url);\nconst p = load.resolve("pkg");`; + + expect(moduleSpecifierRefs(text, "module.mjs")).toEqual([ + { + specifier: "node:module", + typeOnly: false, + resolution: "esm", + loads: true, + }, + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: false }, + ]); + }); + + it("tells a resolve of a module from a load of the same module", () => { + const text = `const where = require.resolve("pkg");\nconst loaded = require("pkg");`; + + expect(moduleSpecifierRefs(text, "module.cjs")).toEqual([ + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: false }, + { specifier: "pkg", typeOnly: false, resolution: "cjs", loads: true }, + ]); + }); + + it("does not read resolve on any other object", () => { + // 🔴 The negative control. `Promise.resolve` and a router's `resolve` belong to somebody else's + // object, and reading every `.resolve` as a module resolve reports ordinary code. + expect( + importedSpecifiers( + `const a = Promise.resolve("pkg");\nconst b = router.resolve("pkg");`, + "module.mjs" + ) + ).toEqual([]); + }); +}); diff --git a/packages/module-specifiers/src/index.ts b/packages/module-specifiers/src/index.ts index eda62a8154..234ef1109a 100644 --- a/packages/module-specifiers/src/index.ts +++ b/packages/module-specifiers/src/index.ts @@ -55,6 +55,16 @@ export const UNRESOLVABLE_SPECIFIER = ""; * `require` identifier, or `module.require` — the documented CommonJS method, * which resolves exactly as the free function does. `loader.require("x")` is a * method on some other object and is not a module resolve. + * - A function `createRequire` returned: `const load = createRequire(import.meta.url)` + * and then `load("pkg")`, or `createRequire(import.meta.url)("pkg")` called where + * it is made. That is how an ES module reaches CommonJS, and it loads exactly as + * `require` does. Counted only when `createRequire` comes from `module` or + * `node:module`, since a helper of that name from anywhere else returns whatever + * that helper returns. + * - `require.resolve("pkg")`, the same on a created require, and + * `import.meta.resolve("pkg")`. They find a module without running it: a package + * that is not there fails exactly as loading it would, but the file found never + * runs, so each is reported with `loads: false`. * - `import x = require("pkg")`, the documented CommonJS-interop spelling, which * is neither of the above. * - `typeof import("pkg")` in type position, which the parser gives as an @@ -181,27 +191,299 @@ function readsModuleRequire(callee: ts.Expression, shadowed: boolean): boolean { return ts.isStringLiteralLike(key) && key.text === "require"; } +/** The specifiers `createRequire` is imported from. */ +const NODE_MODULE_SPECIFIERS: ReadonlySet = new Set([ + "module", + "node:module", +]); + +/** The property a call reads, `a.b` or `a["b"]`, or null for any other callee. */ +function accessedName(callee: ts.Expression): string | null { + if (ts.isPropertyAccessExpression(callee)) return callee.name.text; + if ( + ts.isElementAccessExpression(callee) && + ts.isStringLiteralLike(callee.argumentExpression) + ) { + return callee.argumentExpression.text; + } + return null; +} + +/** The object an access reads its property from, seen through wrappers. */ +function accessReceiver(callee: ts.Expression): ts.Expression | null { + return ts.isPropertyAccessExpression(callee) || + ts.isElementAccessExpression(callee) + ? unwrapReceiver(callee.expression) + : null; +} + +/** `require("module")` or `require("node:module")`, seen through wrappers. */ +function requiresNodeModule(expression: ts.Expression): boolean { + const call = unwrapReceiver(expression); + if (!ts.isCallExpression(call) || !ts.isIdentifier(call.expression)) { + return false; + } + const [target] = call.arguments; + return ( + call.expression.text === "require" && + target !== undefined && + ts.isStringLiteralLike(target) && + NODE_MODULE_SPECIFIERS.has(target.text) + ); +} + +/** The local names bound to Node's `createRequire`, and to the `module` builtin carrying it. */ +interface CreateRequireBindings { + readonly factories: Set; + readonly namespaces: Set; +} + +/** Record `local` as a `createRequire` when the name it binds is `createRequire`. */ +function recordFactory( + bindings: CreateRequireBindings, + bound: ts.Node, + local: ts.Node +): void { + const named = ts.isIdentifier(bound) || ts.isStringLiteral(bound); + if (named && bound.text === "createRequire" && ts.isIdentifier(local)) { + bindings.factories.add(local.text); + } +} + +/** The bindings an `import ... from "node:module"` declaration makes. */ +function recordImportedBindings( + node: ts.ImportDeclaration, + bindings: CreateRequireBindings +): void { + const clause = node.importClause; + if (clause?.name) bindings.namespaces.add(clause.name.text); + const named = clause?.namedBindings; + if (named === undefined) return; + if (ts.isNamespaceImport(named)) { + bindings.namespaces.add(named.name.text); + return; + } + for (const element of named.elements) { + recordFactory(bindings, element.propertyName ?? element.name, element.name); + } +} + +/** The bindings `const ... = require("node:module")` makes. */ +function recordRequiredBindings( + node: ts.VariableDeclaration, + bindings: CreateRequireBindings +): void { + if (ts.isIdentifier(node.name)) { + bindings.namespaces.add(node.name.text); + return; + } + if (!ts.isObjectBindingPattern(node.name)) return; + for (const element of node.name.elements) { + recordFactory(bindings, element.propertyName ?? element.name, element.name); + } +} + +/** + * Where a file binds Node's `createRequire`, or the `module` builtin that carries it. + * + * Only a binding from `module` or `node:module` counts. A helper of the same name + * imported from anywhere else returns whatever that helper returns, and reading its + * calls as module loads would report a dependency nobody has. + */ +function createRequireBindings(source: ts.SourceFile): CreateRequireBindings { + const bindings: CreateRequireBindings = { + factories: new Set(), + namespaces: new Set(), + }; + const visit = (node: ts.Node): void => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteralLike(node.moduleSpecifier) && + NODE_MODULE_SPECIFIERS.has(node.moduleSpecifier.text) + ) { + recordImportedBindings(node, bindings); + } else if ( + ts.isVariableDeclaration(node) && + node.initializer !== undefined && + requiresNodeModule(node.initializer) + ) { + recordRequiredBindings(node, bindings); + } + ts.forEachChild(node, visit); + }; + visit(source); + return bindings; +} + +/** Whether an expression calls Node's `createRequire`, however the file bound it. */ +function callsCreateRequire( + expression: ts.Expression, + bindings: CreateRequireBindings +): boolean { + const call = unwrapReceiver(expression); + if (!ts.isCallExpression(call)) return false; + const callee = unwrapReceiver(call.expression); + if (ts.isIdentifier(callee)) return bindings.factories.has(callee.text); + const receiver = accessReceiver(callee); + return ( + accessedName(callee) === "createRequire" && + receiver !== null && + ts.isIdentifier(receiver) && + bindings.namespaces.has(receiver.text) + ); +} + +/** + * The names in a file holding a require function Node's `createRequire` returned. + * + * File-granular, like {@link declaresOwnModule}: once a declaration binds a created + * require to a name, that name reads as a require everywhere in the file. Resolving + * each use to its own declaration needs a checker, which this reader works without. + */ +function createdRequireNames( + source: ts.SourceFile, + bindings: CreateRequireBindings +): ReadonlySet { + const names = new Set(); + if (bindings.factories.size === 0 && bindings.namespaces.size === 0) { + return names; + } + const visit = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer !== undefined && + callsCreateRequire(node.initializer, bindings) + ) { + names.add(node.name.text); + } + ts.forEachChild(node, visit); + }; + visit(source); + return names; +} + +/** The require functions a file can call: `require` itself, and what `createRequire` gave it. */ +interface RequireFunctions { + readonly bindings: CreateRequireBindings; + /** The names a created require was bound to. */ + readonly names: ReadonlySet; +} + +/** + * Whether an expression is a require function: `require` itself, a name a created require was + * bound to, or a `createRequire(...)` call used where it is made. + * + * The one answer for both ways a require function is used, called and `.resolve`d, so the two + * cannot come to disagree about what counts. + */ +function isRequireFunction( + expression: ts.Expression, + requires: RequireFunctions +): boolean { + const target = unwrapReceiver(expression); + if (ts.isIdentifier(target)) { + return target.text === "require" || requires.names.has(target.text); + } + return callsCreateRequire(target, requires.bindings); +} + +/** What a call naming a module does with it. */ +interface ModuleCall { + readonly resolution: ModuleResolution; + /** False for a call that only finds the module. */ + readonly loads: boolean; +} + +/** + * Which resolver a call hands its argument to and whether it runs what that finds, or null when + * the call names no module. + * + * `import()` loads through the ES module resolver, and `import.meta.resolve()` only finds through + * it. `require()`, `module.require()` and a created require load through CommonJS's, and + * `.resolve()` on `require` or on a created require only finds through it. A `.resolve` on any + * other object is that object's own method. + */ +function moduleCall( + callee: ts.Expression, + shadowsModule: boolean, + requires: RequireFunctions +): ModuleCall | null { + if (callee.kind === ts.SyntaxKind.ImportKeyword) { + return { resolution: "esm", loads: true }; + } + if ( + readsModuleRequire(callee, shadowsModule) || + isRequireFunction(callee, requires) + ) { + return { resolution: "cjs", loads: true }; + } + const receiver = accessReceiver(callee); + if (accessedName(callee) !== "resolve" || receiver === null) return null; + if (ts.isMetaProperty(receiver)) { + return receiver.keywordToken === ts.SyntaxKind.ImportKeyword + ? { resolution: "esm", loads: false } + : null; + } + return isRequireFunction(receiver, requires) + ? { resolution: "cjs", loads: false } + : null; +} + export function importedSpecifiers(text: string, fileName: string): string[] { return moduleSpecifierRefs(text, fileName).map(ref => ref.specifier); } -/** One module a source file names, and whether it survives to runtime. */ -export interface ModuleSpecifierRef { - /** The specifier as written, or {@link UNRESOLVABLE_SPECIFIER}. */ - readonly specifier: string; - /** - * Whether this reference is erased before anything runs. - * - * True for `import type`, `export type`, `typeof import()`, a JSDoc - * `@import` and a triple-slash type reference. False for everything that - * survives into the emitted module: a plain import, a bare side-effect - * import, `import(...)`, `require(...)` and `import x = require(...)`. - * - * 🔴 A mixed clause such as `import { a, type B } from "pkg"` is NOT type-only. - * The module is still loaded for `a`, and reading the inline `type` keyword as - * governing the whole clause would erase a real runtime edge. - */ - readonly typeOnly: boolean; +/** Which of Node's resolvers finds a module. */ +export type ModuleResolution = "esm" | "cjs"; + +/** One module a source file names, whether it survives to runtime, and what finds it if so. */ +export type ModuleSpecifierRef = + | { + /** The specifier as written, or {@link UNRESOLVABLE_SPECIFIER}. */ + readonly specifier: string; + /** + * Erased before anything runs: `import type`, `export type`, + * `typeof import()`, a JSDoc `@import` and a triple-slash type reference. + */ + readonly typeOnly: true; + } + | { + /** The specifier as written, or {@link UNRESOLVABLE_SPECIFIER}. */ + readonly specifier: string; + /** + * Survives into the emitted module: a plain import, a bare side-effect + * import, `import(...)`, `require(...)`, `import x = require(...)`, a + * created require, and the resolve calls. + * + * 🔴 A mixed clause such as `import { a, type B } from "pkg"` is NOT type-only. + * The module is still loaded for `a`, and reading the inline `type` keyword as + * governing the whole clause would erase a real runtime edge. + */ + readonly typeOnly: false; + /** + * Which resolver finds it. `"esm"` takes the path as written; `"cjs"` also + * tries the extensions and index files CommonJS adds. A caller following a + * relative specifier needs this to find the file Node would load. + */ + readonly resolution: ModuleResolution; + /** + * Whether Node runs the module, or only finds it. `require.resolve()` and + * `import.meta.resolve()` return where a module is: a package that is not + * installed still throws, but the file found never runs, so nothing it + * imports is needed. + */ + readonly loads: boolean; + }; + +/** The reference an import or export declaration makes, which the ES module resolver finds and loads. */ +function declarationRef( + specifier: string, + typeOnly: boolean +): ModuleSpecifierRef { + return typeOnly + ? { specifier, typeOnly: true } + : { specifier, typeOnly: false, resolution: "esm", loads: true }; } /** @@ -230,6 +512,11 @@ export function moduleSpecifierRefs( ); const found: ModuleSpecifierRef[] = []; const shadowsModule = declaresOwnModule(source); + const bindings = createRequireBindings(source); + const requires: RequireFunctions = { + bindings, + names: createdRequireNames(source, bindings), + }; const seen = new Set(); const visit = (node: ts.Node): void => { @@ -248,12 +535,14 @@ export function moduleSpecifierRefs( node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ) { - found.push({ - specifier: node.moduleSpecifier.text, - typeOnly: ts.isImportDeclaration(node) - ? Boolean(node.importClause?.isTypeOnly) - : node.isTypeOnly, - }); + found.push( + declarationRef( + node.moduleSpecifier.text, + ts.isImportDeclaration(node) + ? Boolean(node.importClause?.isTypeOnly) + : node.isTypeOnly + ) + ); } else if (ts.isJSDocImportTag(node)) { const target = node.moduleSpecifier; found.push({ @@ -282,14 +571,12 @@ export function moduleSpecifierRefs( ? target.text : UNRESOLVABLE_SPECIFIER, typeOnly: false, + resolution: "cjs", + loads: true, }); } else if (ts.isCallExpression(node)) { - const callee = node.expression; - const resolvesAModule = - callee.kind === ts.SyntaxKind.ImportKeyword || - (ts.isIdentifier(callee) && callee.text === "require") || - readsModuleRequire(callee, shadowsModule); - if (resolvesAModule) { + const call = moduleCall(node.expression, shadowsModule, requires); + if (call !== null) { const target = node.arguments[0]; found.push({ specifier: @@ -297,6 +584,7 @@ export function moduleSpecifierRefs( ? target.text : UNRESOLVABLE_SPECIFIER, typeOnly: false, + ...call, }); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07d3389ac7..70786ce07b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@nextlyhq/eslint-plugin': specifier: workspace:* version: link:packages/eslint-plugin + '@nextlyhq/module-specifiers': + specifier: workspace:* + version: link:packages/module-specifiers '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 diff --git a/scripts/no-install-jobs.mjs b/scripts/no-install-jobs.mjs new file mode 100644 index 0000000000..f628f24c35 --- /dev/null +++ b/scripts/no-install-jobs.mjs @@ -0,0 +1,850 @@ +/** + * The programs a workflow starts before it has installed any dependencies, and whether Node alone + * can load each one. + * + * Until a job's install has run, it has Node and a checkout and nothing more, so every module a + * program it starts imports — all the way down that program's import graph — must be a Node + * builtin. Some jobs never install at all: the CI gate, which has to report even when the install + * every other job depends on is what failed, and the scheduled repository-metadata check. + * + * A scheduled job never runs on a pull request, and a pull request's CI installs dependencies + * before anything else, so an npm import added three files away from such a program passes every + * check on its pull request and fails afterwards on `main`, attributed to whatever commit is at the + * tip when the job next runs. + * + * ## What is read, and how + * + * Workflows and the local actions they use are read with a YAML parser, so a folded, quoted, + * flow-style or continued `run:` is the same string here that it is to GitHub. Steps are taken in + * the order they run: a composite action's steps in place of the step that uses it, a JavaScript + * action's `pre` as the job starts, and its `main` and `post` where the step using it is. + * + * A job leaves the dependency-free state at its first step that installs the repository's + * dependencies and does nothing else, unconditionally, in the repository root. Everything before + * that step is read, and nothing after it. + * + * A step's script is read only under a shell whose grammar `shell-commands.mjs` implements: bash, + * sh, zsh or dash, named by the step or by a job's or the workflow's default, or given by a runner + * whose labels say it runs Linux or macOS. A Windows runner's default is PowerShell, whose + * assignments, quoting and call operator are another language, so a script under it is refused + * rather than misread. + * + * Each script read is split into commands by `shell-commands.mjs`. A `node` command's arguments + * are read with Node's own grammar, so an option's value is not taken for the script, and a module + * an option preloads is walked like the script. A shell script in the repository, named by a + * literal path, is read in turn. + * + * ## What is refused + * + * This guards `main` against a failure nothing before the merge can see, so whatever it cannot + * settle it refuses, naming the step and the reason, rather than passing over it: a `node` command + * with an argument that is not literal, an option it does not know, inline code or no script file; + * the word `node` anywhere else; a package manager running scripts or installed binaries; a + * directory change before a `node` command; `NODE_OPTIONS`; a script under a shell whose grammar + * this does not parse, including a runner's default that `runs-on` does not name; a program named + * only at run time, unless the caller maps it to the repository file it is a copy of; an action it + * cannot read; and a working directory that is not a fixed path inside the repository. + * + * Outside it: the code a remote action brings with it, which is that action's to load. + * + * Imports are read by `@nextlyhq/module-specifiers`, the repository's one reader for what a source + * file loads, so this sees every form the layering guards see. A relative specifier is followed only + * where that reader says the module runs, since `require.resolve` and `import.meta.resolve` find a + * file without running it, and it is followed the way the resolver that reader names finds it: + * exactly as written for an ES module, and through Node's documented CommonJS search for a require. + * + * @module no-install-jobs + */ + +import { isBuiltin } from "node:module"; +import { posix } from "node:path"; + +import { UNRESOLVABLE_SPECIFIER, moduleSpecifierRefs } from "@nextlyhq/module-specifiers"; +import { load } from "js-yaml"; + +import { shellCommands } from "./shell-commands.mjs"; + +/** + * @typedef {object} Repository + * @property {(path: string) => string | null} readFile a file's contents, or null when it is absent + * @property {Record} [copies] programs a workflow stages at run time from one of + * the repository's own files, keyed by the word that runs them, valued by that file's path + */ + +/** + * @typedef {object} Start + * @property {string} where the job and step, for a message a person can act on + * @property {string} entry the repository path of the script Node starts + * @property {({path: string} | {package: string})[]} preloads what Node loads before the script + */ + +/** + * @typedef {object} Refusal + * @property {string} where + * @property {string} reason + */ + +/** + * @typedef {{kind: "install", where: string} + * | {kind: "script", where: string, script: string, cwd: string} + * | ({kind: "start"} & Start) + * | ({kind: "refusal"} & Refusal)} JobEvent + */ + +/** A package manager, and the subcommands with which it installs a project's dependencies. */ +const INSTALLER = /^(?:pnpm|npm|yarn)$/; +const INSTALL = /^(?:install|ci|i)$/; + +/** Install options whose value is the next word. */ +const INSTALL_VALUES = new Set(["--filter", "-F"]); + +/** + * Install options that still leave every dependency of the installed packages in place. + * + * A list of what is known to install rather than of what is known not to: `--lockfile-only`, + * `--package-lock-only`, `--dry-run`, `--prod` and another project's directory each leave + * dependencies absent, and so may whatever option appears next. An option not listed here keeps + * the job in the state this check reads. + */ +const INSTALL_OPTIONS = new Set([ + "--frozen-lockfile", "--prefer-frozen-lockfile", "--prefer-offline", "--ignore-scripts", + "--strict-peer-dependencies", "--no-audit", "--no-fund", +]); + +const NODE_OPTIONS_REASON = + "sets NODE_OPTIONS, which changes what every node command loads, and this reader does not follow it"; + +/** + * Each job's steps as the events this check reads, in the order they run. + * + * @param {string} text a workflow file's contents + * @param {Repository} repo + * @returns {Map} job id to its events + */ +export function jobSequences(text, repo) { + const workflow = load(text) ?? {}; + const sequences = new Map(); + for (const [id, job] of Object.entries(workflow.jobs ?? {})) { + const sequence = { before: [], steps: [] }; + const defaults = + job.defaults?.run?.["working-directory"] ?? workflow.defaults?.run?.["working-directory"]; + const scope = { + where: id, + defaults, + defaultShell: job.defaults?.run?.shell ?? workflow.defaults?.run?.shell, + runnerShell: runnerDefaultShell(job["runs-on"]), + env: [workflow.env, job.env], + actions: [], + conditional: false, + repo, + sequence, + }; + readSteps(job.steps ?? [], scope); + sequences.set(id, [...sequence.before, ...sequence.steps]); + } + return sequences; +} + +function readSteps(steps, scope) { + steps.forEach((step, index) => { + const label = step.name ? `step ${index + 1} (${step.name})` : `step ${index + 1}`; + readStep(step, { ...scope, where: `${scope.where} › ${label}` }); + }); +} + +function readStep(step, scope) { + if (typeof step.run === "string") readRunStep(step, scope); + else if (typeof step.uses === "string" && step.uses.startsWith("./")) { + // A condition on the step using an action binds every step inside it, an install included. + const conditional = + scope.conditional || step.if !== undefined || Boolean(step["continue-on-error"]); + // The step's own environment reaches every step inside the action, NODE_OPTIONS included. + readLocalAction(step.uses, { ...scope, conditional, env: [...scope.env, step.env] }); + } +} + +function refusal(where, reason) { + return { kind: "refusal", where, reason }; +} + +function readRunStep(step, scope) { + const { steps } = scope.sequence; + const cwd = workingDirectory(step, scope); + // A composite action's run step names its own shell; a job's default reaches only the job's. + const shell = step.shell ?? (scope.actions.length > 0 ? undefined : scope.defaultShell); + if (!scope.conditional && isInstall(step, cwd)) { + steps.push({ kind: "install", where: scope.where }); + } else if ("refusal" in cwd) { + steps.push(refusal(scope.where, cwd.refusal)); + } else if ([...scope.env, step.env].some(env => env != null && Object.hasOwn(env, "NODE_OPTIONS"))) { + steps.push(refusal(scope.where, NODE_OPTIONS_REASON)); + } else if (shellRunsNode(shell)) { + steps.push(refusal(scope.where, "runs its script as inline Node code; move it into a file")); + } else if (!POSIX_SHELLS.has(scriptShell(shell, scope))) { + steps.push(refusal(scope.where, unparsedShellReason(shell, scope))); + } else { + const script = withKnownPaths(step.run, scope); + steps.push({ kind: "script", where: scope.where, script, cwd: cwd.path }); + } +} + +/** + * The program that runs a step's script: the shell it names, or its runner's default. + * + * @param {unknown} shell what the step, or the job's or the workflow's default, names as its shell + * @returns {string | null} the program's name, or null when nothing says which shell runs it + */ +function scriptShell(shell, scope) { + if (shell !== undefined) { + const [program = ""] = String(shell).trim().split(/\s+/); + return program.replace(/^.*[\\/]/, "").replace(/\.exe$/i, "").toLowerCase(); + } + // A composite action's run step has to name its shell, so no runner's default reaches one. + return scope.actions.length > 0 ? null : scope.runnerShell; +} + +/** Why a step's script is not read under the shell that runs it. */ +function unparsedShellReason(shell, scope) { + const name = scriptShell(shell, scope); + if (name !== null) { + return `runs its script under ${name}, whose grammar this reader does not parse; give the step \`shell: bash\``; + } + if (scope.actions.length > 0) { + return "is a composite action step that names no shell, which GitHub requires of every one"; + } + return ( + "names no shell, and its job's `runs-on` does not say which system's default shell runs it; " + + "give the step `shell: bash`" + ); +} + +/** + * The shell a runner gives a step that names none: bash on Linux and macOS, PowerShell on Windows. + * + * Told from labels that begin with a system's name: an `ubuntu-` or `macos-` image, or a self-hosted + * `linux` or `macOS` label, gives bash, and a `windows-` image or `windows` label gives PowerShell. A + * runner chosen by an expression, or labelled with no system or with two, leaves the default + * unknown, and a script under an unknown shell is refused rather than read as bash. + * + * @param {unknown} runsOn a job's `runs-on`: a label, a list of them, or a group with `labels` + * @returns {"bash" | "pwsh" | null} + */ +function runnerDefaultShell(runsOn) { + const labels = [Array.isArray(runsOn) || typeof runsOn !== "object" ? runsOn : runsOn?.labels].flat(); + const shells = new Set(); + for (const label of labels) { + if (typeof label !== "string") return null; + if (/^windows(?:-|$)/i.test(label)) shells.add("pwsh"); + else if (/^(?:ubuntu|macos)(?:-|$)|^linux$/i.test(label)) shells.add("bash"); + } + return shells.size === 1 ? [...shells][0] : null; +} + +/** + * Whether a step installs the repository's dependencies, unconditionally, and does nothing else. + * + * Narrow on purpose. A condition, a tolerated failure, another directory, a package named on the + * command line, an option not known to leave a full install and a second command each describe a + * step after which the dependencies may still be absent, so each keeps the job in the state this + * check reads rather than releasing it. + */ +function isInstall(step, cwd) { + if (step.if !== undefined || step["continue-on-error"]) return false; + if (cwd.path !== ".") return false; + const commands = shellCommands(step.run); + return commands.length === 1 && installsHere(commands[0].words); +} + +function installsHere(words) { + if (!words.every(word => word.literal)) return false; + const [tool = "", subcommand = "", ...options] = words.map(word => word.text); + if (!INSTALLER.test(tool) || !INSTALL.test(subcommand)) return false; + for (let i = 0; i < options.length; i += 1) { + if (INSTALL_VALUES.has(options[i])) i += 1; + else if (!INSTALL_OPTIONS.has(options[i])) return false; + } + return true; +} + +/** + * The repository path a step's script runs in. + * + * GitHub documents a composite action step's own `working-directory`, but not whether a job's + * default reaches one, so a composite step naming none under a job that sets one is refused rather + * than resolved either way. + * + * @returns {{path: string} | {refusal: string}} + */ +function workingDirectory(step, scope) { + const own = step["working-directory"]; + const inAction = scope.actions.length > 0; + if (own === undefined && inAction && scope.defaults !== undefined) { + return { + refusal: + "is a composite action step without a working-directory of its own, under a job that " + + "sets one, and which of the two it runs in is undocumented", + }; + } + return repositoryPath(own ?? (inAction ? undefined : scope.defaults) ?? ".", ".", "directory"); +} + +/** + * A path inside the repository, from one written relative to `base`, or why it is not one. + * + * @returns {{path: string} | {refusal: string}} + */ +function repositoryPath(value, base, what) { + if (typeof value !== "string" || value.includes("$")) { + return { refusal: `names a ${what} that is not in the text: ${value}` }; + } + const path = posix.normalize(posix.join(base, value)); + if (posix.isAbsolute(value) || path === ".." || path.startsWith("../")) { + return { refusal: `names a ${what} outside the repository: ${value}` }; + } + return { path }; +} + +/** The two runner paths a script can name whose value this reader knows. */ +function withKnownPaths(script, scope) { + const action = scope.actions.at(-1); + const workspace = /\$\{\{\s*github\.workspace\s*\}\}|\$\{GITHUB_WORKSPACE\}|\$GITHUB_WORKSPACE\b/g; + const actionPath = /\$\{\{\s*github\.action_path\s*\}\}|\$\{GITHUB_ACTION_PATH\}|\$GITHUB_ACTION_PATH\b/g; + const text = script.replace(workspace, "."); + return action === undefined ? text : text.replace(actionPath, action); +} + +/** + * A step using an action from this repository, read in its place. + * + * A composite action's steps are read as though the job had written them, so an install inside one + * ends the dependency-free state and a script started inside one is checked. A JavaScript action's + * `pre` runs as the job starts, so it is placed there. Its `post` runs as the job ends, but only for + * an action whose step ran and even when a later install failed, so it is read where that step is. + */ +function readLocalAction(uses, scope) { + const dir = posix.normalize(uses).replace(/\/$/, ""); + const { steps } = scope.sequence; + if (scope.actions.includes(dir)) { + steps.push(refusal(scope.where, `uses ./${dir} inside itself`)); + return; + } + const manifest = readManifest(dir, scope.repo); + if (manifest === null) { + steps.push(refusal(scope.where, `uses ./${dir}, whose action.yml cannot be read`)); + return; + } + const runs = manifest.runs ?? {}; + const inner = { ...scope, where: `${scope.where} › ./${dir}`, actions: [...scope.actions, dir] }; + if (runs.using === "composite") readSteps(runs.steps ?? [], inner); + else if (/^node\d+$/.test(String(runs.using))) readJavaScriptAction(dir, runs, inner); +} + +function readManifest(dir, repo) { + for (const name of ["action.yml", "action.yaml"]) { + const text = repo.readFile(posix.join(dir, name)); + if (text === null) continue; + try { + return load(text) ?? null; + } catch { + // A manifest that does not parse is refused by the caller, as one that cannot be read is. + return null; + } + } + return null; +} + +function readJavaScriptAction(dir, runs, scope) { + const { before, steps } = scope.sequence; + const preloads = scope.env.some(env => env != null && Object.hasOwn(env, "NODE_OPTIONS")); + for (const [key, events] of [["pre", before], ["main", steps], ["post", steps]]) { + if (typeof runs[key] !== "string") continue; + const where = `${scope.where} (${key})`; + const entry = repositoryPath(runs[key], dir, "script"); + if ("refusal" in entry) events.push(refusal(where, entry.refusal)); + else if (preloads) events.push(refusal(where, NODE_OPTIONS_REASON)); + else events.push({ kind: "start", where, entry: entry.path, preloads: [] }); + } +} + +/** Words that come before the program a command runs: reserved words and the wrappers that exec it. */ +const PREFIXES = new Set([ + "if", "then", "elif", "else", "while", "until", "do", "!", "{", + "time", "exec", "nohup", "command", "builtin", "env", "sudo", "nice", +]); + +/** An assignment word, `NAME=value`, whatever its value. */ +const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; + +/** Node's program name, alone or at the end of a path, with or without Windows' `.exe`. */ +const NODE_PROGRAM = /^(?:.*[\\/])?(?:node|nodejs)(?:\.exe)?$/i; + +/** Node's name as a word inside some other text. */ +const NODE_NAMED = /(?:^|[^\w$.-])(?:node|nodejs)(?![\w.-])/; + +/** Windows' spelling of Node's name inside other text, which the boundary above excludes. */ +const NODE_EXE_NAMED = /(?:^|[^\w$.-])(?:node|nodejs)\.exe(?![\w.-])/i; + +/** Whether a step's shell is Node itself, spelled any way a command could spell it. */ +function shellRunsNode(shell) { + const [program = ""] = String(shell ?? "").trim().split(/\s+/); + return NODE_PROGRAM.test(program); +} + +const DIRECTORY_CHANGES = new Set(["cd", "pushd", "popd"]); +/** The shells whose grammar `shell-commands.mjs` implements, whether they run a step or a command. */ +const POSIX_SHELLS = new Set(["bash", "sh", "zsh", "dash"]); +const SHELLS = new Set([...POSIX_SHELLS, "source", "."]); +const PACKAGE_RUNNERS = new Set(["npx", "pnpx", "bunx"]); +const PACKAGE_MANAGERS = new Set(["pnpm", "npm", "yarn", "corepack"]); + +/** Package-manager subcommands that run neither the repository's scripts nor an installed package. */ +const INERT_SUBCOMMANDS = new Set([ + "install", "i", "ci", "add", "audit", "view", "info", "show", "whoami", "config", "get", "set", + "ls", "list", "why", "outdated", "store", "bin", "root", "prefix", "cache", "dist-tag", "ping", + "help", "enable", "prepare", "use", +]); + +/** Options a package manager takes before its subcommand whose value is the next word. */ +const MANAGER_VALUES = new Set(["--filter", "-F", "--dir", "-C", "--prefix", "--cwd", "--workspace"]); + +/** Options that make Node run something other than a script file. */ +const NODE_RUNS_OTHER_CODE = new Set([ + "-e", "--eval", "-p", "--print", "-", "-i", "--interactive", "--run", "--test", +]); + +/** Options after which Node prints something and exits without running anything. */ +const NODE_EXITS = new Set(["-v", "--version", "-h", "--help", "--v8-options"]); + +/** Options that load environment variables from a file, where NODE_OPTIONS can be set. */ +const NODE_READS_ENV_FILE = new Set(["--env-file", "--env-file-if-exists"]); + +/** Options whose value is a module Node loads before the script. */ +const NODE_PRELOADS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader"]); + +/** + * Node's own options that take a value, which may be written as the next word. + * + * `node --conditions development x.mjs` runs `x.mjs` with the condition set; so does `-C`. V8's + * options, `--max-old-space-size` among them, take a value only after `=`, and an option written + * with `=` is accepted whatever its name. + */ +const NODE_TAKES_VALUE = new Set([ + "-C", "--conditions", "--disable-warning", "--input-type", + "--redirect-warnings", "--diagnostic-dir", "--title", "--watch-path", "--report-dir", + "--report-directory", "--report-filename", "--cpu-prof-dir", "--heap-prof-dir", + "--unhandled-rejections", "--dns-result-order", "--localstorage-file", "--openssl-config", + "--icu-data-dir", +]); + +/** Node's options that take no value. */ +const NODE_FLAGS = new Set([ + "-c", "--check", "--enable-source-maps", "--expose-gc", "--frozen-intrinsics", "--no-addons", + "--no-deprecation", "--no-warnings", "--pending-deprecation", "--preserve-symlinks", + "--preserve-symlinks-main", "--throw-deprecation", "--trace-deprecation", "--trace-exit", + "--trace-uncaught", "--trace-warnings", "--watch", "--abort-on-uncaught-exception", + "--experimental-vm-modules", "--experimental-strip-types", "--no-experimental-strip-types", + "--experimental-transform-types", "--experimental-detect-module", + "--no-experimental-detect-module", "--experimental-require-module", + "--no-experimental-require-module", +]); + +const MOVED = "after changing directory, which this reader does not follow; give the step a working-directory instead"; + +/** + * Every program a workflow's jobs start before installing dependencies, and every place this + * reader could not settle. + * + * @param {string} text a workflow file's contents + * @param {Repository} repo + * @returns {{starts: Start[], refusals: Refusal[]}} + */ +export function dependencyFreeStarts(text, repo) { + const found = { starts: [], refusals: [] }; + for (const sequence of jobSequences(text, repo).values()) { + const install = sequence.findIndex(event => event.kind === "install"); + for (const event of install === -1 ? sequence : sequence.slice(0, install)) { + if (event.kind === "script") readScript(event, { repo, found, following: [] }); + else if (event.kind === "start") found.starts.push(startOf(event)); + else if (event.kind === "refusal") found.refusals.push({ where: event.where, reason: event.reason }); + } + } + return found; +} + +function startOf({ where, entry, preloads }) { + return { where, entry, preloads }; +} + +function refuse(state, reason) { + state.found.refusals.push({ where: state.where, reason }); +} + +/** The programs a shell script starts, in the order it starts them. */ +function readScript({ where, script, cwd }, context) { + const state = { movedDirectory: false, ...context, where, cwd }; + for (const command of shellCommands(script)) readCommand(command, state); +} + +function readCommand(command, state) { + if (command.words.some(word => /^NODE_OPTIONS(?:=|$)/.test(word.text))) { + refuse(state, NODE_OPTIONS_REASON); + return; + } + const words = programWords(command.words); + const [program] = words; + if (program !== undefined && !program.literal) readRuntimeProgram(command, program, state); + else if (program === undefined) residual(command, state); + else if (NODE_PROGRAM.test(program.text)) readNodeCommand(words, state); + else if (DIRECTORY_CHANGES.has(program.text)) state.movedDirectory = true; + else if (SHELLS.has(program.text)) readShellCommand(command, words, state); + else if (PACKAGE_RUNNERS.has(program.text) || PACKAGE_MANAGERS.has(program.text)) { + readPackageManager(command, words, state); + } else if (program.text.includes("/")) readPathCommand(command, words, state); + else residual(command, state); +} + +/** A command's words from the program it runs: assignments, reserved words and wrappers removed. */ +function programWords(words) { + let i = 0; + while ( + i < words.length && + (ASSIGNMENT.test(words[i].text) || (words[i].literal && PREFIXES.has(words[i].text))) + ) { + i += 1; + } + return words.slice(i); +} + +function readNodeCommand(words, state) { + if (state.movedDirectory) return refuse(state, `starts Node ${MOVED}`); + const parsed = nodeArguments(words.slice(1)); + if ("exits" in parsed) return undefined; + if ("refusal" in parsed) return refuse(state, parsed.refusal); + const entry = repositoryPath(parsed.entry, state.cwd, "script"); + if ("refusal" in entry) return refuse(state, entry.refusal); + const preloads = []; + for (const specifier of parsed.preloads) { + const path = /^[./]/.test(specifier) ? repositoryPath(specifier, state.cwd, "preload") : null; + if (path !== null && "refusal" in path) return refuse(state, path.refusal); + preloads.push(path === null ? { package: specifier } : { path: path.path }); + } + state.found.starts.push({ where: state.where, entry: entry.path, preloads }); + return undefined; +} + +/** + * The script a `node` command starts and the modules its options preload, read with Node's + * grammar: `node [options] [--]