Files
reverse-engineering-anything/src/domain/referenceSourceRequireBindings.ts
T
Martin Klein 7b15762d09 fix(reference): report sloppy scripts, PHP, C# and enclosing Git facts (#1770)
* fix(reference): report sloppy scripts, PHP, C# and enclosing Git facts

Importing the source/ subtree of a legacy PHP and C# checkout produced
four wrong or missing facts:

- Babel's unambiguous mode parses a module first and keeps its
  strict-mode recovery errors after falling back to a script, so a valid
  sloppy-mode `delete messages;` was reported as a parse failure and the
  semantic IR marked the file as parser-recovered. Parsing now goes
  through one owner that re-parses such a script as a script.
- .php and .cs files had no language; they are now detected as PHP and
  C# (detection only, no parsing).
- A root without its own .git was reported as having no version
  control even inside a checkout. The adapter now resolves the nearest
  enclosing repository, as Git does, and reports `none` only when no
  ancestor has one.
- The CLI recorded importer_version as null although the package
  version is known.

* fix(reference): keep module strictness and classify C# and PHP as source

Review follow-up. `.mjs` and `.mts` files are modules by extension, but
the script fallback re-parsed them in sloppy mode and erased real
strict-mode errors. The parser boundary now owns the extension rule,
parses explicit modules as modules and applies the fallback only when
the mode is ambiguous; the require-binding analysis uses the same rule.

`.cs` and `.php` received a language but stayed classified as unknown,
so source-to-bundle comparison dropped them. Both are now source
extensions, like the other detected languages.

* fix(reference): honor package module scope and keep unknown-mode strict errors

Review follow-up. A `.js` file under a package.json with `"type":
"module"` is an ES module, but it went through the script fallback that
erases strict-mode errors. The import now reads each in-inventory
package.json and parses `.js` files in a module scope as modules. Other
`.js` and `.ts` sources keep an unknown mode, because bundlers treat them
as modules without that field: they are no longer forced to script, and
errors that only the module parse reports are kept as an entry limitation.

* fix(reference): bound package scopes and keep application parsing unchanged

Review follow-up.
- `.cjs` files parse as scripts, so module-only strict errors no longer
  become limitations on CommonJS trees. `.cts` keeps an unknown mode
  because TypeScript compiles ES import syntax there.
- An unreadable or linked package.json now bounds the module scope with
  an unknown type instead of letting an outer `"type": "module"` apply.
- The application pipeline parses as on main again. Its module-only
  strict errors keep a source partial; package-mode handling stays in the
  reference importer.

* refactor(reference): keep unmarked module modes unknown instead of resolving package scope

Review follow-up. Resolving Node package scope for `.js` kept pulling in
more of Node's lookup semantics (require bindings, excluded or
directory-named manifests). Only extensions now select a mode: `.mjs`
and `.mts` parse as modules and `.cjs` as scripts. Every other source
keeps an unknown mode, so module-only strict errors stay visible as an
entry limitation instead of being dropped or asserted. This matches the
require-binding analysis, which also treats unmarked paths as unknown.
2026-10-13 05:52:27 +09:00

188 lines
6.5 KiB
TypeScript

import * as t from "@babel/types";
import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js";
import {
semanticBindingId,
semanticScopeId,
} from "./javascript/javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import type { JavaScriptSemanticReference } from "./javascript/javascriptSemanticIr.js";
import {
isExplicitModuleSourcePath,
type ParsedJavaScriptSource,
} from "./javascript/javascriptSourceParser.js";
/**
* Keep calls that resolve either to Node's CommonJS wrapper loader or to a
* direct ESM `createRequire(import.meta.url)` binding. The shared lexical
* analyzer supplies binding ownership; this boundary accounts for the two
* Node loader bindings without evaluating arbitrary values.
*/
export const unshadowedReferenceSourceRequireCalls = (
file: Pick<ParsedJavaScriptSource, "program">,
path: string,
): ReadonlySet<t.CallExpression> => {
const references = new Map(
analyzeParsedJavaScriptReferences(file).map((reference) => [
`${reference.location.start.line}:${reference.location.start.column}`,
reference,
]),
);
const programRequireBinding = semanticBindingId(
semanticScopeId("program", file.program),
"require",
);
// Syntax alone cannot establish Node's runtime mode: `.js` depends on its
// package scope, and `.ts` may be compiled with a CommonJS module target.
// Keep unmarked paths runtime-unknown and preserve candidate require edges.
const allowsUnboundRequire = !isExplicitModuleSourcePath(path);
const hasTopLevelVarRequire =
allowsUnboundRequire && hasUninitializedTopLevelVarRequire(file.program);
const createRequireBinding = directCreateRequireBinding(
file.program,
references,
);
const requireLoaderWasWritten = [...references.values()].some(
(reference) =>
reference.name === "require" &&
reference.role === "write" &&
(reference.resolution === "unbound" ||
reference.bindingId === programRequireBinding),
);
const calls = new Set<t.CallExpression>();
traverseJavaScriptAst(file.program, {
enter: (node, _parent, readAncestors) => {
if (!t.isCallExpression(node)) return;
const identifier = requireCalleeIdentifier(node.callee);
const start = identifier?.loc?.start;
if (start === undefined) return;
if (
readAncestors().some(
(ancestor) =>
t.isWithStatement(ancestor) && contains(ancestor.body, node),
)
)
return;
const reference = references.get(`${start.line}:${start.column}`);
if (
(allowsUnboundRequire &&
!requireLoaderWasWritten &&
reference?.resolution === "unbound") ||
(((hasTopLevelVarRequire && !requireLoaderWasWritten) ||
createRequireBinding) &&
reference?.resolution === "resolved" &&
reference.bindingId === programRequireBinding)
)
calls.add(node);
},
});
return calls;
};
const contains = (container: t.Node, node: t.Node): boolean =>
typeof container.start === "number" &&
typeof container.end === "number" &&
typeof node.start === "number" &&
typeof node.end === "number" &&
container.start <= node.start &&
node.end <= container.end;
const hasUninitializedTopLevelVarRequire = (program: t.Program): boolean => {
let found = false;
let initialized = false;
traverseJavaScriptAst(program, {
enter: (node, _parent, readAncestors) => {
if (!t.isVariableDeclaration(node, { kind: "var" })) return;
if (
readAncestors().some(
(ancestor) => t.isFunction(ancestor) || t.isStaticBlock(ancestor),
)
)
return;
for (const declaration of node.declarations) {
if (!Object.hasOwn(t.getBindingIdentifiers(declaration.id), "require"))
continue;
found = true;
initialized ||= declaration.init !== null;
}
},
});
return found && !initialized;
};
const directCreateRequireBinding = (
program: t.Program,
references: ReadonlyMap<string, JavaScriptSemanticReference>,
): boolean => {
const programScope = semanticScopeId("program", program);
const importedNames = new Set<string>();
for (const declaration of program.body) {
if (
!t.isImportDeclaration(declaration) ||
(declaration.source.value !== "node:module" &&
declaration.source.value !== "module")
)
continue;
for (const specifier of declaration.specifiers) {
if (
t.isImportSpecifier(specifier) &&
t.isIdentifier(specifier.imported, { name: "createRequire" })
)
importedNames.add(specifier.local.name);
}
}
return program.body.some(
(statement) =>
t.isVariableDeclaration(statement, { kind: "const" }) &&
statement.declarations.some((declaration) => {
if (
!t.isIdentifier(declaration.id, { name: "require" }) ||
!t.isCallExpression(declaration.init) ||
!t.isIdentifier(declaration.init.callee) ||
!importedNames.has(declaration.init.callee.name) ||
!isImportMetaUrl(declaration.init.arguments[0])
)
return false;
const start = declaration.init.callee.loc?.start;
if (start === undefined) return false;
const reference = references.get(`${start.line}:${start.column}`);
return (
reference?.resolution === "resolved" &&
reference.bindingId ===
semanticBindingId(programScope, declaration.init.callee.name)
);
}),
);
};
const isImportMetaUrl = (node: t.Node | undefined): boolean =>
t.isMemberExpression(node) &&
!node.computed &&
t.isIdentifier(node.property, { name: "url" }) &&
t.isMetaProperty(node.object) &&
t.isIdentifier(node.object.meta, { name: "import" }) &&
t.isIdentifier(node.object.property, { name: "meta" });
const requireCalleeIdentifier = (
callee: t.Node | null | undefined,
): t.Identifier | undefined => {
if (t.isIdentifier(callee, { name: "require" })) return callee;
if (
!t.isMemberExpression(callee) ||
!t.isIdentifier(callee.object, { name: "require" }) ||
((!t.isIdentifier(callee.property) || callee.computed) &&
!t.isStringLiteral(callee.property))
)
return undefined;
const propertyName = t.isIdentifier(callee.property)
? callee.property.name
: t.isStringLiteral(callee.property)
? callee.property.value
: undefined;
return propertyName === "resolve" || propertyName === "main"
? callee.object
: undefined;
};