diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 83c4c144f3..d665a2e62e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -1,6 +1,9 @@ import * as t from "@babel/types"; -import { semanticBranchMayBeSkipped } from "./javascriptSemanticEvaluationOrder.js"; +import { + semanticBranchMayBeSkipped, + semanticIterationCardinality, +} from "./javascriptSemanticEvaluationOrder.js"; import { failedJavaScriptSemanticIr, @@ -16,12 +19,14 @@ import { collectSemanticReferencesSteps, immutableSemanticBindings, immutableSemanticScopes, + semanticReferenceRole, } from "./javascriptSemanticProjection.js"; import { semanticObjectPatternKeys, runtimeFunctionParameters, semanticStaticPropertyKey, semanticWriteTargets, + semanticWriteSiteTarget, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import type { @@ -29,12 +34,15 @@ import type { JavaScriptSemanticBindingState, JavaScriptSemanticScopeState, JavaScriptBindingIndex, + JavaScriptSemanticReferenceEffectsAtSite, } from "./javascriptSemanticState.js"; import { currentSemanticScope, resolveSemanticBindingState, semanticResolutionBlocked, resolveSemanticBindingFromScope, + semanticBindingLookupFromScope, + semanticBindingLookupAt, semanticBindingId, semanticScopeId, semanticStableInitializer, @@ -47,7 +55,11 @@ import { import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; -import { indexJavaScriptAmbientWritesSteps } from "./javascriptSemanticAmbientWrites.js"; +import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js"; +import { + indexJavaScriptAmbientWritesSteps, + isUnmodifiedGlobalReference, +} from "./javascriptSemanticAmbientWrites.js"; import { hasUncertainTypeOnlyModuleSideEffect, semanticImportEqualsSpecifier, @@ -74,7 +86,11 @@ import { import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; -import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; +import { + SemanticPropertyPathCoverage, + semanticPropertyPathKeyMatches, + type JavaScriptSemanticPropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; import { indexCommonJsExportReceiversSteps } from "./javascriptCommonJsExportReceivers.js"; import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutionMode.js"; @@ -136,13 +152,131 @@ export const analyzeParsedJavaScriptReferences = ( return collectSemanticReferences(file.program, state, name); }; +const indexedAmbientWriteStates = + new WeakSet(); +const indexedMemberMutationStates = + new WeakSet(); + +function* indexSemanticAmbientWritesSteps( + state: JavaScriptSemanticAnalysisState, +): Generator { + if (indexedAmbientWriteStates.has(state)) return; + yield* indexJavaScriptAmbientWritesSteps(state.program, state); + indexedAmbientWriteStates.add(state); +} + +/** Index alias-aware mutation facts only for consumers that request them. */ +function* indexSemanticMemberMutationFactsSteps( + state: JavaScriptSemanticAnalysisState, +): Generator { + if (indexedMemberMutationStates.has(state)) return; + yield* indexSemanticAmbientWritesSteps(state); + yield* collectSemanticMemberMutationsSteps(state.program, state); + indexedMemberMutationStates.add(state); +} + +/** Resolve one identifier or member use and summarize its source-site effects. */ +const semanticReferenceEffectsAtSite = ( + state: JavaScriptSemanticAnalysisState, + site: t.Node, + referenceNode: t.Node, + requestedPath?: JavaScriptSemanticPropertyPath, +): JavaScriptSemanticReferenceEffectsAtSite => { + const { root, path: inferredPath } = semanticReferenceRoot(referenceNode); + const memberEffectsIndexed = indexedMemberMutationStates.has(state); + if (!t.isIdentifier(root)) + return { + bindingId: null, + resolution: "unknown", + memberEffectsIndexed, + effects: [], + }; + const lookup = semanticBindingLookupAt(state, root, root.name); + const binding = "binding" in lookup ? lookup.binding : undefined; + const blocked = lookup.kind !== "binding" && lookup.kind !== "ambient"; + const resolution = + lookup.kind === "binding" ? "resolved" : blocked ? "unknown" : "unbound"; + const path = requestedPath ?? inferredPath; + const effects: JavaScriptSemanticReferenceEffectsAtSite["effects"][number][] = + []; + const retain = ( + kind: JavaScriptSemanticReferenceEffectsAtSite["effects"][number]["kind"], + node: t.Node | undefined, + effectPath?: JavaScriptSemanticPropertyPath, + ): void => { + if (effectPath !== undefined && !semanticPathsOverlap(path, effectPath)) + return; + effects.push({ + kind, + ...(effectPath === undefined ? {} : { path: effectPath }), + timing: semanticEffectTiming(site, node, state), + }); + }; + if (binding !== undefined) { + for (const node of state.bindingWrites.get(binding.bindingId) ?? []) + retain("binding-write", node); + if (indexedMemberMutationStates.has(state)) { + for (const effect of binding.memberWrites) + retain("member-write", effect.node, effect.path); + for (const effect of binding.escapedPaths) + retain("escape", effect.node, effect.path); + } + } else if (lookup.kind === "ambient" || lookup.kind === "uncertain-ambient") { + for (const node of state.ambientBindingWrites.get(root.name) ?? []) + retain("ambient-write", node); + } + return { + bindingId: binding?.bindingId ?? null, + resolution, + memberEffectsIndexed, + effects, + }; +}; + +const semanticReferenceRoot = ( + node: t.Node, +): { readonly root: t.Node; readonly path: JavaScriptSemanticPropertyPath } => { + const path: (string | number | null)[] = []; + let current = unwrapJavaScriptExpression(node).node; + while ( + t.isMemberExpression(current) || + t.isOptionalMemberExpression(current) + ) { + const key = semanticStaticPropertyKey(current.property, current.computed); + path.unshift(key ?? null); + current = unwrapJavaScriptExpression(current.object).node; + } + return { root: current, path }; +}; + +const semanticPathsOverlap = ( + left: JavaScriptSemanticPropertyPath, + right: JavaScriptSemanticPropertyPath, +): boolean => { + for (let index = 0; index < Math.min(left.length, right.length); index++) { + const leftKey = left[index]; + const rightKey = right[index]; + if (leftKey === undefined || rightKey === undefined) return true; + const leftMatchesRight = + typeof rightKey === "string" || typeof rightKey === "number" + ? semanticPropertyPathKeyMatches(leftKey, String(rightKey)) + : true; + const rightMatchesLeft = + typeof leftKey === "string" || typeof leftKey === "number" + ? semanticPropertyPathKeyMatches(rightKey, String(leftKey)) + : true; + if (!leftMatchesRight && !rightMatchesLeft) return false; + } + return true; +}; + /** Share the lexical index with bounded, demand-driven boundary analyses. */ export function* indexJavaScriptBindingsSteps( program: t.Program, ): Generator { const state = createState(program); yield* collectDefinitionsSteps(program, state); - yield* indexJavaScriptAmbientWritesSteps(program, state); + yield* indexSemanticAmbientWritesSteps(state); return state; } @@ -167,8 +301,8 @@ export function* analyzeParsedJavaScriptSemanticsSteps( ): Generator { const state = createState(file.program, staticImportResolutionMode); yield* collectDefinitionsSteps(file.program, state); - yield* indexJavaScriptAmbientWritesSteps(file.program, state); - yield* collectSemanticMemberMutationsSteps(file.program, state); + yield* indexSemanticAmbientWritesSteps(state); + yield* indexSemanticMemberMutationFactsSteps(state); const commonJsReceivers = yield* indexCommonJsExportReceiversSteps(state); yield* traverseJavaScriptAstSteps(file.program, { enter: (node) => collectSemanticModuleLink(node, state, commonJsReceivers), @@ -262,20 +396,38 @@ export interface JavaScriptReceiverIndex extends JavaScriptBindingIndex { */ export function* indexJavaScriptBindingsWithReceiversSteps( program: t.Program, + hostContext?: "commonjs-wrapper", ): Generator { const state = createState(program); + if (hostContext === "commonjs-wrapper") { + const scope = currentSemanticScope(state.scopes); + const binding: JavaScriptSemanticBindingState = { + ...createBinding(scope, "require", "parameter", false), + hostOrigin: "commonjs-wrapper", + }; + scope.bindings.set(binding.name, binding); + state.bindingsById.set(binding.bindingId, binding); + } yield* collectDefinitionsSteps(program, state); const receivers = yield* classifyDefinitionReceiversSteps(program, state); - let ambientIndexed = false; let references: readonly JavaScriptSemanticReference[] | undefined; return { sourceType: program.sourceType, bindings: state, receivers, *indexAmbientWritesSteps() { - if (ambientIndexed) return; - yield* indexJavaScriptAmbientWritesSteps(program, state); - ambientIndexed = true; + yield* indexSemanticAmbientWritesSteps(state); + }, + *indexMemberMutationFactsSteps() { + yield* indexSemanticMemberMutationFactsSteps(state); + }, + effectsForReferenceAt(site, referenceNode, propertyPath) { + return semanticReferenceEffectsAtSite( + state, + site, + referenceNode, + propertyPath, + ); }, *referencesSteps() { references ??= yield* collectSemanticReferencesSteps(program, state); @@ -713,6 +865,9 @@ const createState = ( scopesById: new Map([[root.scopeId, root]]), scopeByNode: new WeakMap([[program, root]]), bindingsById: new Map(), + bindingWrites: new Map(), + prototypeRequireMutationObserved: false, + prototypeEnumerationMayAffect: false, callables: [], callableNodesById: new Map(), moduleLinks: [], @@ -740,6 +895,7 @@ function* collectDefinitionsSteps( readonly scope: JavaScriptSemanticScopeState; readonly conditionallySkipped: boolean; }[] = []; + let needsPrototypeFacts = false; const functionBodies: { readonly body: t.BlockStatement; readonly scope: JavaScriptSemanticScopeState; @@ -748,6 +904,12 @@ function* collectDefinitionsSteps( yield* traverseJavaScriptAstSteps(program, { enter: (node, parent, readAncestors) => { if (parent !== null) state.parentsByNode.set(node, parent); + if ( + t.isWithStatement(node) || + t.isForInStatement(node) || + t.isForOfStatement(node) + ) + needsPrototypeFacts = true; let parentScope = currentSemanticScope(stack); if ( parent !== null && @@ -777,6 +939,7 @@ function* collectDefinitionsSteps( location: range(parent), bindingsComplete: false, bindings: new Map(), + dynamicWithStatement: parent, }; state.scopes.push(dynamicScope); state.scopesById.set(dynamicScope.scopeId, dynamicScope); @@ -837,6 +1000,8 @@ function* collectDefinitionsSteps( stack.pop(); }, }); + if (needsPrototypeFacts) + yield* indexPrototypeUncertaintySteps(program, state); // All declarations must exist before resolving writes, including hoisted // functions and local declarations that shadow an outer binding. for (const [ @@ -857,6 +1022,112 @@ function* collectDefinitionsSteps( } } +type PrototypeOwner = "Object" | "Array" | "String"; + +const prototypeOwners = ["Object", "Array", "String"] as const; + +/** Preserve uncertainty when source can change or expose a built-in prototype. */ +function* indexPrototypeUncertaintySteps( + program: t.Program, + state: JavaScriptSemanticAnalysisState, +): Generator { + const taint = (owner: PrototypeOwner): void => { + if (owner === "Object") state.prototypeRequireMutationObserved = undefined; + state.prototypeEnumerationMayAffect = undefined; + }; + const globalOwner = (node: t.Node): PrototypeOwner | undefined => { + const value = unwrapJavaScriptExpression(node).node; + for (const owner of prototypeOwners) { + if (isUnmodifiedGlobalReference(value, state, owner)) return owner; + if ( + t.isIdentifier(value, { name: owner }) && + resolveSemanticBindingState(state, value, owner) === undefined + ) + return owner; + } + return undefined; + }; + const prototypeOwner = (node: t.Node): PrototypeOwner | undefined => { + const value = unwrapJavaScriptExpression(node).node; + return (t.isMemberExpression(value) || + t.isOptionalMemberExpression(value)) && + semanticStaticPropertyKey(value.property, value.computed) === "prototype" + ? globalOwner(value.object) + : undefined; + }; + const objectMethod = (node: t.Node): string | undefined => { + const callee = unwrapJavaScriptExpression(node).node; + return (t.isMemberExpression(callee) || + t.isOptionalMemberExpression(callee)) && + globalOwner(callee.object) === "Object" + ? (semanticStaticPropertyKey(callee.property, callee.computed) ?? + undefined) + : undefined; + }; + const mutators = new Set([ + "defineProperty", + "defineProperties", + "setPrototypeOf", + ]); + yield* traverseJavaScriptAstSteps(program, { + enter: (node, parent) => { + const write = semanticWriteSiteTarget(node); + if (write !== null) { + for (const target of semanticWriteTargets(write)) { + const value = unwrapJavaScriptExpression(target).node; + if ( + t.isMemberExpression(value) || + t.isOptionalMemberExpression(value) + ) { + const owner = + prototypeOwner(value.object) ?? globalOwner(value.object); + if (owner !== undefined) taint(owner); + } else { + const owner = globalOwner(value); + if (owner !== undefined) taint(owner); + } + } + } + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { + const method = objectMethod(node.callee); + const callee = unwrapJavaScriptExpression(node.callee).node; + if ( + t.isMemberExpression(callee) || + t.isOptionalMemberExpression(callee) + ) { + const owner = prototypeOwner(callee.object); + if (owner !== undefined) taint(owner); + } + for (const [index, argument] of node.arguments.entries()) { + const owner = prototypeOwner(argument) ?? globalOwner(argument); + if (owner === undefined) continue; + // These intrinsics mutate only their first argument. Linking another + // object's prototype to this one does not mutate this prototype. + if (method !== undefined && mutators.has(method) && index !== 0) + continue; + taint(owner); + } + } + const owner = prototypeOwner(node) ?? globalOwner(node); + if (owner === undefined || parent === null) return; + if ( + (t.isMemberExpression(parent) || + t.isOptionalMemberExpression(parent)) && + parent.object === node + ) + return; + if ( + (t.isCallExpression(parent) || t.isOptionalCallExpression(parent)) && + parent.arguments.includes(node as t.Expression) + ) + return; + if (t.isIdentifier(node) && semanticReferenceRole(node, parent) === null) + return; + taint(owner); + }, + }); +} + const copyParameterToBody = (input: { readonly identifier: t.Identifier; readonly body: t.BlockStatement; @@ -1043,22 +1314,44 @@ const bindAssignment = ( scope, state, conditionallySkipped, + node, ); else for (const identifier of semanticWriteTargets(node.left)) if (t.isIdentifier(identifier)) - addAssignment(identifier, node, scope, state, conditionallySkipped); + addAssignment( + identifier, + node, + scope, + state, + conditionallySkipped, + node, + ); } else if (t.isUpdateExpression(node)) { const argument = unwrapJavaScriptExpression(node.argument).node; if (t.isIdentifier(argument)) addAssignment(argument, node, scope, state, conditionallySkipped); - } else if (t.isForOfStatement(node) || t.isForInStatement(node)) + } else if (t.isForOfStatement(node) || t.isForInStatement(node)) { + if ( + semanticIterationCardinality( + node, + state.prototypeEnumerationMayAffect, + ) === "empty" + ) + return; for (const pattern of t.isVariableDeclaration(node.left) ? node.left.declarations.map(({ id }) => id) : [node.left]) { for (const identifier of semanticWriteTargets(pattern)) if (t.isIdentifier(identifier)) - addAssignment(identifier, node, scope, state, conditionallySkipped); + addAssignment( + identifier, + node, + scope, + state, + conditionallySkipped, + node, + ); if (t.isForOfStatement(node)) // Keep the loop value unknown, but preserve references yielded into // each bound slot so writes/escapes can reach their iterable origins. @@ -1071,6 +1364,7 @@ const bindAssignment = ( referenceOnly: true, }); } + } }; const nestedScope = ( @@ -1455,20 +1749,24 @@ const addAssignment = ( scope: JavaScriptSemanticScopeState, state: JavaScriptSemanticAnalysisState, conditionallySkipped: boolean, + writeSite?: t.Node, ): void => { - const binding = resolveSemanticBindingFromScope( - scope, - identifier.name, - state, - ); + const lookup = semanticBindingLookupFromScope(scope, identifier.name, state); + if (lookup.kind === "captured") return; + const binding = "binding" in lookup ? lookup.binding : undefined; + conditionallySkipped ||= + lookup.kind !== "binding" && lookup.kind !== "ambient"; if (binding === undefined) { // Unbound identifiers can denote host wrapper bindings (such as require), // which are separate from properties written through the global object. const writes = state.ambientBindingWrites.get(identifier.name) ?? []; - writes.push(identifier); + writes.push(writeSite ?? initializer); state.ambientBindingWrites.set(identifier.name, writes); return; } + const writes = state.bindingWrites.get(binding.bindingId) ?? []; + writes.push(writeSite ?? initializer); + state.bindingWrites.set(binding.bindingId, writes); binding.mutable = true; binding.definitions.push({ kind: "assignment", location: range(identifier) }); binding.initializers.push({ node: initializer, projection: [] }); diff --git a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts index 82adfdb318..9ddef42fae 100644 --- a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts +++ b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts @@ -1,9 +1,70 @@ import * as t from "@babel/types"; -import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import { + readExactJavaScriptLiteral, + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; const preserveGrouping = { stopAtParentheses: true } as const; +export type JavaScriptSemanticIterationCardinality = + | "empty" + | "nonempty" + | "unknown"; + +/** Exact source-only cardinality for literal loop heads. */ +export const semanticIterationCardinality = ( + loop: t.ForInStatement | t.ForOfStatement, + prototypeMayAffectEnumeration: boolean | undefined, +): JavaScriptSemanticIterationCardinality => { + const iterable = unwrapJavaScriptExpression(loop.right).node; + if (t.isForOfStatement(loop)) { + if (prototypeMayAffectEnumeration !== false) return "unknown"; + if (t.isArrayExpression(iterable)) { + if (iterable.elements.length === 0) return "empty"; + if ( + iterable.elements.some( + (element) => element === null || !t.isSpreadElement(element), + ) + ) + return "nonempty"; + return "unknown"; + } + if (t.isStringLiteral(iterable)) + return iterable.value.length === 0 ? "empty" : "nonempty"; + return "unknown"; + } + if (t.isObjectExpression(iterable)) { + let prototype: t.Node | undefined; + for (const property of iterable.properties) { + if (t.isSpreadElement(property)) return "unknown"; + const key = semanticStaticPropertyKey(property.key, property.computed); + if ( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + key === "__proto__" + ) { + prototype = unwrapJavaScriptExpression(property.value).node; + continue; + } + if (key === null) return "unknown"; + return "nonempty"; + } + if (t.isNullLiteral(prototype)) return "empty"; + if (prototype !== undefined && !readExactJavaScriptLiteral(prototype).found) + return "unknown"; + return prototypeMayAffectEnumeration === false ? "empty" : "unknown"; + } + if (t.isNullLiteral(iterable)) return "empty"; + if (t.isStringLiteral(iterable) && iterable.value.length > 0) + return "nonempty"; + if (readExactJavaScriptLiteral(iterable).found) + return prototypeMayAffectEnumeration === false ? "empty" : "unknown"; + return "unknown"; +}; + /** Instance values run on construction; their computed keys run at definition. */ export const delayedInstanceFieldValue = ( parent: t.Node | undefined, diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index e0b6f2623c..1400d25258 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -1146,7 +1146,11 @@ const observableMutationPaths = ( const hasParsedRequireOrigin = binding.initializers.some( ({ node }) => semanticRequireOrigin(node, state) !== undefined, ); - if (binding.directOrigins.length > 0 || hasParsedRequireOrigin) + if ( + binding.directOrigins.length > 0 || + hasParsedRequireOrigin || + binding.hostOrigin !== undefined + ) (observer.origins ??= new Set()).add("opaque"); for (const initializer of binding.initializers) { link( @@ -1176,6 +1180,11 @@ const observableMutationPaths = ( // Keep class/getter projections distinct so cycle pruning does not // discard observable escapes through a shared getter result. if ( + t.isNewExpression(expression) && + semanticClassCandidates(expression.callee, state).length === 0 + ) + (observer.origins ??= new Set()).add("opaque"); + else if ( t.isObjectExpression(expression) || t.isArrayExpression(expression) || t.isClass(expression) || diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 524facbf89..7c25046bd6 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -3,13 +3,18 @@ import * as t from "@babel/types"; import { delayedInstanceFieldValue, semanticBranchMayBeSkipped, + semanticIterationCardinality, } from "./javascriptSemanticEvaluationOrder.js"; import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import type { JavaScriptSemanticBindingState } from "./javascriptSemanticState.js"; +import type { + JavaScriptSemanticBindingState, + JavaScriptSemanticAnalysisState, + JavaScriptSemanticEffectTiming, +} from "./javascriptSemanticState.js"; interface StatementPosition { readonly body: readonly t.Statement[]; @@ -120,8 +125,10 @@ export const semanticTopLevelEffectFollowsCapture = ( if (t.isFunction(node) || t.isLoop(node)) return null; if (delayedInstanceFieldValue(node, child)) return null; if (t.isVariableDeclarator(node)) declarator = node; - if (selected === null && t.isStatement(node)) - selected = statementPosition(node, parents, declarator); + if (t.isStatement(node)) { + const enclosing = statementPosition(node, parents, declarator); + if (enclosing !== null) selected = enclosing; + } if (t.isProgram(node)) return selected; child = node; node = parents.get(node); @@ -141,6 +148,118 @@ export const semanticTopLevelEffectFollowsCapture = ( return expressionEvaluationOrder(capture, effect, parents) === "before"; }; +/** Include expression order when a write operation contains the read site. */ +export const semanticEffectFollowsReference = ( + reference: t.Node, + effect: t.Node | undefined, + parents: WeakMap, +): boolean => { + if (reference === effect) return false; + let current: t.Node | undefined = reference; + let child: t.Node | undefined; + while (current !== undefined && current !== effect) { + if (t.isFunction(current)) break; + child = current; + current = parents.get(current); + } + const loopInput = + (t.isForInStatement(effect) || t.isForOfStatement(effect)) && + child === effect.right; + let once = current; + child = undefined; + while (once !== undefined) { + const initialLoopInput = + (t.isForStatement(once) && child === once.init) || + ((t.isForInStatement(once) || t.isForOfStatement(once)) && + child === once.right) || + (once === effect && loopInput); + if ( + t.isFunction(once) || + (t.isLoop(once) && !initialLoopInput) || + delayedInstanceFieldValue(once, child) + ) + break; + child = once; + once = parents.get(once); + } + return ( + (current === effect && + once === undefined && + (t.isAssignmentExpression(effect) || + t.isUpdateExpression(effect) || + t.isVariableDeclarator(effect) || + t.isCallExpression(effect) || + t.isOptionalCallExpression(effect) || + t.isNewExpression(effect))) || + (current === effect && once === undefined && loopInput) || + semanticTopLevelEffectFollowsCapture(reference, effect, parents) + ); +}; + +export const semanticEffectTiming = ( + site: t.Node, + effect: t.Node | undefined, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticEffectTiming => { + if (effect === undefined) return "unknown"; + // Invocation receives arguments after their values have been selected. + if ( + site === effect && + (t.isCallExpression(site) || + t.isOptionalCallExpression(site) || + t.isNewExpression(site)) + ) + return "after"; + const loop = semanticEnclosingIteration(effect, state); + if (loop !== undefined) { + const cardinality = semanticIterationCardinality( + loop, + state.prototypeEnumerationMayAffect, + ); + if (cardinality === "empty") return "after"; + if (cardinality === "nonempty") { + const loopParent = state.parentsByNode.get(loop); + const crossesFunction = (node: t.Node): boolean => { + let current: t.Node | undefined = node; + while (current !== undefined) { + if (t.isFunction(current)) return true; + current = state.parentsByNode.get(current); + } + return false; + }; + if ( + loopParent === state.program && + !crossesFunction(site) && + typeof loop.start === "number" && + typeof loop.end === "number" && + typeof site.start === "number" + ) { + if (site.start > loop.end) return "before"; + if (site.start < loop.start) return "after"; + } + } + } + if (semanticEffectFollowsReference(site, effect, state.parentsByNode)) + return "after"; + if (semanticEffectFollowsReference(effect, site, state.parentsByNode)) + return "before"; + return "unknown"; +}; + +const semanticEnclosingIteration = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): t.ForInStatement | t.ForOfStatement | undefined => { + let current: t.Node | undefined = node; + while (current !== undefined) { + if (t.isForInStatement(current) || t.isForOfStatement(current)) + return current; + if (t.isFunction(current)) return undefined; + current = state.parentsByNode.get(current); + } + return undefined; +}; + /** Memoize descendant paths toward the currently compared source expression. */ const expressionSiblings = ( earlier: t.Node, diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index d903020207..825eefef1a 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -6,6 +6,7 @@ import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; import { resolveSemanticBindingState, isUnmodifiedCommonJsWrapperBinding, + commonJsWrapperBindingDeclaration, semanticResolutionBlocked, semanticStableInitializer, type JavaScriptSemanticAnalysisState, @@ -16,7 +17,10 @@ import { } from "./javascriptAstValues.js"; import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; -import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticEffectTiming, + semanticTopLevelEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; export interface JavaScriptRequireOrigin { @@ -37,17 +41,49 @@ export const semanticRequireLoaderAt = ( state: JavaScriptSemanticAnalysisState, factoryBindingIds: ReadonlySet, ): JavaScriptRequireLoaderFact => { - if (!t.isIdentifier(node.callee, { name: "require" })) return "local"; - const binding = resolveSemanticBindingState(state, node.callee, "require"); - if (binding === undefined) - return semanticResolutionBlocked(state, node.callee, "require") + const callee = unwrapJavaScriptExpression(node.callee).node; + const loader = t.isIdentifier(callee, { name: "require" }) + ? callee + : (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + ["resolve", "main"].includes( + semanticStaticPropertyKey(callee.property, callee.computed) ?? "", + ) && + t.isIdentifier(callee.object, { name: "require" }) + ? callee.object + : undefined; + if (loader === undefined) return "local"; + const binding = resolveSemanticBindingState(state, loader, "require"); + if (binding === undefined) { + if (semanticResolutionBlocked(state, loader, "require")) return "unknown"; + return (state.ambientBindingWrites.get("require") ?? []).some( + (write) => semanticEffectTiming(node, write, state) !== "after", + ) ? "unknown" : "commonjs-loader"; + } if (factoryBindingIds.has(binding.bindingId)) return binding.initializers.length === 0 ? "factory-loader" : "unknown"; + if ( + binding.hostOrigin === "commonjs-wrapper" && + [...binding.declarationKinds].every((kind) => kind === "var") + ) { + if (binding.definitions.some(({ kind }) => kind === "function")) + return "local"; + return commonJsWrapperBindingHasPriorWrite(node, binding, state) + ? "unknown" + : "commonjs-loader"; + } if (isWrapperRequireInitializerCall(binding, node, state)) return "commonjs-loader"; - if (isUnmodifiedCommonJsWrapperBinding(node.callee, "require", state)) + const wrapperBinding = commonJsWrapperBindingDeclaration( + loader, + "require", + state, + ); + if ( + wrapperBinding !== undefined && + !commonJsWrapperBindingHasPriorWrite(node, wrapperBinding, state) + ) return "commonjs-loader"; if (isSemanticCreateRequireBinding(binding, state)) return "create-require"; const initializer = semanticStableInitializer(binding, state)?.node; @@ -66,6 +102,42 @@ export const semanticRequireLoaderAt = ( return "unknown"; }; +const commonJsWrapperBindingHasPriorWrite = ( + site: t.Node, + binding: NonNullable>, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const writes = state.bindingWrites.get(binding.bindingId) ?? []; + const declarationInitializers = binding.initializers + .map(({ node }) => node) + .filter( + (initializer) => + !writes.some((write) => semanticNodeWithin(initializer, write, state)), + ); + return ( + writes.some( + (write) => semanticEffectTiming(site, write, state) !== "after", + ) || + declarationInitializers.some( + (initializer) => + semanticEffectTiming(site, initializer, state) !== "after", + ) + ); +}; + +const semanticNodeWithin = ( + node: t.Node, + ancestor: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + let current: t.Node | undefined = node; + while (current !== undefined) { + if (current === ancestor) return true; + current = state.parentsByNode.get(current); + } + return false; +}; + /** Classify a call through an exact recovered factory-loader binding. */ export const semanticFactoryRequireLoaderAt = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index f65a520124..585541e82b 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -1,4 +1,8 @@ import * as t from "@babel/types"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import type { JavaScriptSemanticPropertyPath, @@ -42,11 +46,29 @@ export interface JavaScriptSemanticMemberEffect { readonly node: t.Node | undefined; } +export type JavaScriptSemanticEffectTiming = "before" | "after" | "unknown"; + +export interface JavaScriptSemanticReferenceEffect { + readonly kind: "binding-write" | "member-write" | "escape" | "ambient-write"; + readonly path?: JavaScriptSemanticPropertyPath; + readonly timing: JavaScriptSemanticEffectTiming; +} + +/** Source-ordered effects relevant to one binding or projected member use. */ +export interface JavaScriptSemanticReferenceEffectsAtSite { + readonly bindingId: string | null; + readonly resolution: JavaScriptSemanticReference["resolution"]; + /** Whether alias-aware member and escape effects were collected for this source. */ + readonly memberEffectsIndexed: boolean; + readonly effects: readonly JavaScriptSemanticReferenceEffect[]; +} + export interface JavaScriptSemanticRequiredModuleEffect extends JavaScriptSemanticMemberEffect { readonly kind: "write" | "escape"; } export interface JavaScriptSemanticBindingState { + readonly hostOrigin?: "commonjs-wrapper"; readonly bindingId: string; readonly scopeId: string; readonly name: string; @@ -72,6 +94,8 @@ export interface JavaScriptSemanticScopeState { readonly location: JavaScriptSemanticScope["location"]; bindingsComplete: boolean; readonly bindings: Map; + /** Object environment introduced by `with`, active only for its body. */ + readonly dynamicWithStatement?: t.WithStatement; } /** Shared state for semantic collection and evaluation. */ @@ -89,6 +113,12 @@ export interface JavaScriptSemanticAnalysisState { /** Scope boundaries and descendant scopes resolved on demand. */ readonly scopeByNode: WeakMap; readonly bindingsById: Map; + /** Resolved identifier writes retained at their exact source sites. */ + readonly bindingWrites: Map; + /** Source evidence that Object.prototype may supply a `require` property. */ + prototypeRequireMutationObserved: boolean | undefined; + /** Source evidence that Object.prototype may add inherited enumerable keys. */ + prototypeEnumerationMayAffect: boolean | undefined; /** Effects on a cached require exports object, independent of local projections. */ readonly requiredModuleEffects: Map< string, @@ -132,6 +162,12 @@ export interface JavaScriptBindingIndex { readonly sourceType: t.Program["sourceType"]; readonly bindings: JavaScriptSemanticAnalysisState; indexAmbientWritesSteps(): Generator; + indexMemberMutationFactsSteps(): Generator; + effectsForReferenceAt( + site: t.Node, + referenceNode: t.Node, + propertyPath?: JavaScriptSemanticPropertyPath, + ): JavaScriptSemanticReferenceEffectsAtSite; referencesSteps(): Generator; } @@ -185,35 +221,18 @@ export const resolveSemanticBindingState = ( node: t.Node, name: string, ): JavaScriptSemanticBindingState | undefined => { - let scope = semanticScopeForNode(state, node); - while (scope !== undefined) { - const binding = scope.bindings.get(name); - if (binding !== undefined) return binding; - if (!scope.bindingsComplete) return undefined; - scope = - scope.parentScopeId === null - ? undefined - : state.scopesById.get(scope.parentScopeId); - } - return undefined; + const lookup = semanticBindingLookupAt(state, node, name); + return lookup.kind === "binding" ? lookup.binding : undefined; }; -/** Report whether an omitted binding scope blocks a trustworthy miss. */ +/** Report whether an omitted or dynamic binding blocks a trustworthy miss. */ export const semanticResolutionBlocked = ( state: JavaScriptSemanticAnalysisState, node: t.Node, name: string, ): boolean => { - let scope = semanticScopeForNode(state, node); - while (scope !== undefined) { - if (scope.bindings.has(name)) return false; - if (!scope.bindingsComplete) return true; - scope = - scope.parentScopeId === null - ? undefined - : state.scopesById.get(scope.parentScopeId); - } - return false; + const lookup = semanticBindingLookupAt(state, node, name); + return lookup.kind !== "binding" && lookup.kind !== "ambient"; }; const semanticScopeForNode = ( @@ -238,25 +257,232 @@ const semanticScopeForNode = ( return undefined; }; -/** Resolve one name starting from an explicitly selected lexical scope. */ +type SemanticBindingLookup = + | { + readonly kind: "binding" | "uncertain-binding"; + readonly binding: JavaScriptSemanticBindingState; + } + | { readonly kind: "ambient" | "uncertain-ambient" | "captured" | "unknown" }; + +/** Retain both a dynamic lookup's uncertainty and its possible outer write owner. */ +export const semanticBindingLookupFromScope = ( + scope: JavaScriptSemanticScopeState | undefined, + name: string, + state: JavaScriptSemanticAnalysisState, +): SemanticBindingLookup => { + let uncertain = false; + while (scope !== undefined) { + const binding = scope.bindings.get(name); + if (binding !== undefined) + return { kind: uncertain ? "uncertain-binding" : "binding", binding }; + if (!scope.bindingsComplete) { + if (scope.dynamicWithStatement === undefined) return { kind: "unknown" }; + const lookup = semanticWithRequireLookup( + state, + scope.dynamicWithStatement, + name, + ); + if (lookup === "captures") return { kind: "captured" }; + if (lookup === "unknown") uncertain = true; + } + scope = + scope.parentScopeId === null + ? undefined + : state.scopesById.get(scope.parentScopeId); + } + return { kind: uncertain ? "uncertain-ambient" : "ambient" }; +}; + +/** Return certain and possible binding ownership without discarding uncertainty. */ +export const semanticBindingLookupAt = ( + state: JavaScriptSemanticAnalysisState, + node: t.Node, + name: string, +): SemanticBindingLookup => + semanticBindingLookupFromScope( + semanticScopeForNode(state, node), + name, + state, + ); + +/** Resolve a certain lexical binding from an explicitly selected scope. */ export const resolveSemanticBindingFromScope = ( scope: JavaScriptSemanticScopeState, name: string, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticBindingState | undefined => { - let candidate: JavaScriptSemanticScopeState | undefined = scope; - while (candidate !== undefined) { - const binding = candidate.bindings.get(name); - if (binding !== undefined) return binding; - if (!candidate.bindingsComplete) return undefined; - candidate = - candidate.parentScopeId === null - ? undefined - : state.scopesById.get(candidate.parentScopeId); + const lookup = semanticBindingLookupFromScope(scope, name, state); + return lookup.kind === "binding" ? lookup.binding : undefined; +}; + +type SemanticWithRequireLookup = "captures" | "skips" | "unknown"; +const semanticUnscopablesKey = Symbol("semantic-unscopables-key"); + +/** Classify only source-visible own `require` entries in a literal with object. */ +const semanticWithRequireLookup = ( + state: JavaScriptSemanticAnalysisState, + statement: t.WithStatement, + name: string, +): SemanticWithRequireLookup => { + if (name !== "require") return "unknown"; + const object = unwrapJavaScriptExpression(statement.object).node; + if (!t.isObjectExpression(object)) return "unknown"; + let ownRequire = false; + let hasUnknownOwnProperties = false; + let prototypeIsNull = false; + let unknownPrototype = false; + for (const property of object.properties) { + if (t.isSpreadElement(property)) { + hasUnknownOwnProperties = true; + continue; + } + const key = semanticWithPropertyName( + property.key, + property.computed, + state, + ); + if (key === "require") ownRequire = true; + else if ( + key === "__proto__" && + t.isObjectProperty(property) && + !property.computed && + !property.shorthand + ) { + const prototype = unwrapJavaScriptExpression(property.value).node; + if (t.isNullLiteral(prototype)) prototypeIsNull = true; + else unknownPrototype = true; + } else if (key === null && property.computed) { + hasUnknownOwnProperties = true; + } + } + if (ownRequire) { + const unscopablesValue = finalSemanticWithSymbolProperty(object, state); + if (unscopablesValue === "unknown") return "unknown"; + if (unscopablesValue === undefined) + return prototypeIsNull || + (!unknownPrototype && state.prototypeRequireMutationObserved === false) + ? "captures" + : "unknown"; + const unscopables = unwrapJavaScriptExpression(unscopablesValue).node; + if (!t.isObjectExpression(unscopables)) + return isSemanticWithPrimitive(unscopables) ? "captures" : "unknown"; + const entry = finalSemanticWithStringProperty(unscopables, "require"); + if (entry === "unknown") return "unknown"; + if (entry === undefined) { + const prototype = semanticWithPrototypeKind(unscopables); + return prototype === "null" || + (prototype === "ordinary" && + state.prototypeRequireMutationObserved === false) + ? "captures" + : "unknown"; + } + const truthiness = semanticWithStaticTruthiness(entry); + return truthiness === true + ? "skips" + : truthiness === false + ? "captures" + : "unknown"; + } + if (hasUnknownOwnProperties || unknownPrototype) return "unknown"; + if (prototypeIsNull) return "skips"; + return state.prototypeRequireMutationObserved === false ? "skips" : "unknown"; +}; + +const semanticWithPropertyName = ( + key: t.Node, + computed: boolean, + state: JavaScriptSemanticAnalysisState, +): string | typeof semanticUnscopablesKey | null => { + if ( + computed && + t.isMemberExpression(key) && + !key.computed && + t.isIdentifier(key.object, { name: "Symbol" }) && + semanticStaticPropertyKey(key.property, false) === "unscopables" && + resolveSemanticBindingState(state, key.object, "Symbol") === undefined && + !semanticResolutionBlocked(state, key.object, "Symbol") + ) + return semanticUnscopablesKey; + return semanticStaticPropertyKey(key, computed); +}; + +const finalSemanticWithSymbolProperty = ( + object: t.ObjectExpression, + state: JavaScriptSemanticAnalysisState, +): t.Node | undefined | "unknown" => { + for (let index = object.properties.length - 1; index >= 0; index -= 1) { + const property = object.properties[index]; + if (property === undefined || t.isSpreadElement(property)) return "unknown"; + const name = semanticWithPropertyName( + property.key, + property.computed, + state, + ); + if (name === semanticUnscopablesKey) + return t.isObjectProperty(property) ? property.value : "unknown"; + if (name === null && property.computed) return "unknown"; } return undefined; }; +const semanticWithPrototypeKind = ( + object: t.ObjectExpression, +): "null" | "ordinary" | "other" => { + for (const property of object.properties) { + if ( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + t.isIdentifier(property.key, { name: "__proto__" }) + ) + return t.isNullLiteral(unwrapJavaScriptExpression(property.value).node) + ? "null" + : "other"; + } + return "ordinary"; +}; + +const finalSemanticWithStringProperty = ( + object: t.ObjectExpression, + sought: string, +): t.Node | undefined | "unknown" => { + for (let index = object.properties.length - 1; index >= 0; index -= 1) { + const property = object.properties[index]; + if (property === undefined || t.isSpreadElement(property)) return "unknown"; + const name = semanticStaticPropertyKey(property.key, property.computed); + if (name === sought) + return t.isObjectProperty(property) ? property.value : "unknown"; + if (name === null && property.computed) return "unknown"; + } + return undefined; +}; + +const semanticWithStaticTruthiness = (node: t.Node): boolean | undefined => { + const value = unwrapJavaScriptExpression(node).node; + if (t.isBooleanLiteral(value)) return value.value; + if (t.isNullLiteral(value)) return false; + if (t.isStringLiteral(value)) return value.value.length > 0; + if (t.isNumericLiteral(value)) + return value.value !== 0 && !Number.isNaN(value.value); + if ( + t.isObjectExpression(value) || + t.isArrayExpression(value) || + t.isFunctionExpression(value) || + t.isArrowFunctionExpression(value) + ) + return true; + return undefined; +}; + +const isSemanticWithPrimitive = (node: t.Node): boolean => + t.isNullLiteral(node) || + t.isBooleanLiteral(node) || + t.isStringLiteral(node) || + t.isNumericLiteral(node) || + t.isBigIntLiteral(node) || + (t.isUnaryExpression(node, { operator: "-" }) && + (t.isNumericLiteral(node.argument) || t.isBigIntLiteral(node.argument))); + /** Select the function/program owner for a var declaration. */ export const semanticVariableScope = ( scope: JavaScriptSemanticScopeState, @@ -294,6 +520,16 @@ export const isUnmodifiedCommonJsWrapperBinding = ( name: string, state: JavaScriptSemanticAnalysisState, ): boolean => { + const binding = commonJsWrapperBindingDeclaration(node, name, state); + return binding !== undefined && binding.initializers.length === 0; +}; + +/** Program `var` redeclarations retain wrapper identity until a source write. */ +export const commonJsWrapperBindingDeclaration = ( + node: t.Node, + name: string, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState | undefined => { // Parser sourceType separates explicit modules from script/CommonJS source; // specifier resolution mode does not establish that distinction. if ( @@ -301,13 +537,13 @@ export const isUnmodifiedCommonJsWrapperBinding = ( !["module", "exports", "require"].includes(name) || !t.isIdentifier(node, { name }) ) - return false; + return undefined; const binding = resolveSemanticBindingState(state, node, name); - if (binding === undefined || binding.initializers.length !== 0) return false; + if (binding === undefined) return undefined; const scope = state.scopesById.get(binding.scopeId); - return ( - scope?.kind === "program" && + return scope?.kind === "program" && binding.declarationKinds.size > 0 && [...binding.declarationKinds].every((kind) => kind === "var") - ); + ? binding + : undefined; }; diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index 234121a512..a7ea10a97a 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -1,256 +1,99 @@ import * as t from "@babel/types"; +import { semanticStaticPropertyKey } from "./javascript/javascriptAstValues.js"; import { - semanticStaticPropertyKey, - semanticWriteSiteTarget, - semanticWriteTargets, - unwrapJavaScriptExpression, -} from "./javascript/javascriptAstValues.js"; -import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js"; + indexJavaScriptBindingsWithReceiversSteps, + type JavaScriptReceiverIndex, +} from "./javascript/javascriptSemanticAnalysis.js"; +import { semanticRequireLoaderAt } from "./javascript/javascriptSemanticRequireOrigin.js"; import { - semanticBindingId, - semanticScopeId, -} from "./javascript/javascriptSemanticState.js"; -import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; -import type { JavaScriptSemanticReference } from "./javascript/javascriptSemanticIr.js"; + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascript/javascriptSemanticTraversal.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. - */ +/** Recover source dependencies using the shared loader and mutation owners. */ export const unshadowedReferenceSourceRequireCalls = ( file: Pick, path: string, ): ReadonlySet => { - const references = new Map( - analyzeParsedJavaScriptReferences(file).map((reference) => [ - `${reference.location.start.line}:${reference.location.start.column}`, - reference, - ]), + // Unmarked paths depend on package scope or compiler options. Preserve their + // candidate CommonJS edges; explicit module paths have no wrapper loader. + const wrapperMode = commonJsWrapperMode(path); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps( + file.program, + wrapperMode === "absent" ? undefined : "commonjs-wrapper", + ), ); - 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 isLoaderReference = (identifier: t.Identifier): boolean => { - const start = identifier.loc?.start; - const reference = - start === undefined - ? undefined - : references.get(`${start.line}:${start.column}`); - return ( - (allowsUnboundRequire && reference?.resolution === "unbound") || - (reference?.resolution === "resolved" && - reference.bindingId === programRequireBinding) - ); - }; - const loaderMembersIntact = requireLoaderMembersIntact( - file.program, - isLoaderReference, - ); - const calls = new Set(); - + const factoryBindings: ReadonlySet = new Set(); + const candidates: { + readonly call: t.CallExpression; + readonly identifier: t.Identifier; + }[] = []; traverseJavaScriptAst(file.program, { - enter: (node, _parent, readAncestors) => { + enter: (node) => { if (!t.isCallExpression(node)) return; const identifier = requireCalleeIdentifier(node.callee); - const start = identifier?.loc?.start; - if (start === undefined) return; + if (identifier === undefined) return; + const fact = semanticRequireLoaderAt( + node, + index.bindings, + factoryBindings, + ); if ( - readAncestors().some( - (ancestor) => - t.isWithStatement(ancestor) && contains(ancestor.body, node), - ) + fact === "create-require" || + (fact === "commonjs-loader" && wrapperMode !== "absent") ) - return; - // `require.resolve` and `require.main` denote Node's members only - // while no visible write or escape can have replaced them. - if (!t.isIdentifier(node.callee) && !loaderMembersIntact) 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); + candidates.push({ call: node, identifier }); }, }); - return calls; -}; - -/** - * Whether the loader's `resolve` and `main` members keep Node's identity. A - * write to either member or to a computed member, or `require` used as a - * value other than a call or member read (where a mutator or alias can - * replace members), leaves that identity unknown. - */ -const requireLoaderMembersIntact = ( - program: t.Program, - isLoaderReference: (identifier: t.Identifier) => boolean, -): boolean => { - let intact = true; - const isLoaderMemberTarget = (target: t.Node): boolean => { - if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target)) - return false; - const object = unwrapJavaScriptExpression(target.object).node; - if (!t.isIdentifier(object, { name: "require" })) return false; - if (!isLoaderReference(object)) return false; - const key = semanticStaticPropertyKey(target.property, target.computed); - return key === null || key === "resolve" || key === "main"; - }; - traverseJavaScriptAst(program, { - enter: (node, parent, readAncestors) => { - if (!intact) return; - const site = semanticWriteSiteTarget(node); - if (site !== null) { - for (const target of semanticWriteTargets(site)) - if (isLoaderMemberTarget(target)) intact = false; - return; - } - if ( - !t.isIdentifier(node, { name: "require" }) || - parent === null || - !isLoaderReference(node) + if (candidates.some(({ call }) => !t.isIdentifier(call.callee))) + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return new Set( + candidates + .filter( + ({ call, identifier }) => + t.isIdentifier(call.callee) || + !hasPriorMemberMutation(index, call, identifier), ) - return; - // Inert parentheses and TypeScript wrappers do not change how the - // loader value is used. - let used: t.Node = node; - let user: t.Node | undefined = parent; - const read = readAncestors(); - const ancestors = read.at(-1) === parent ? read : [...read, parent]; - for (let index = ancestors.length - 1; index >= 0; index -= 1) { - user = ancestors[index]; - if ( - user === undefined || - unwrapJavaScriptExpression(user).node !== node - ) - break; - used = user; - } - const usedAsLoader = - ((t.isCallExpression(user) || t.isOptionalCallExpression(user)) && - user.callee === used) || - ((t.isMemberExpression(user) || t.isOptionalMemberExpression(user)) && - user.object === used) || - t.isUnaryExpression(user, { operator: "typeof" }); - if (!usedAsLoader) intact = false; - }, - }); - return intact; -}; - -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, -): boolean => { - const programScope = semanticScopeId("program", program); - const importedNames = new Set(); - 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) - ); - }), + .map(({ call }) => call), ); }; -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" }); +type CommonJsWrapperMode = "present" | "absent" | "unknown"; + +const commonJsWrapperMode = (path: string): CommonJsWrapperMode => + path.endsWith(".cjs") + ? "present" + : path.endsWith(".mjs") + ? "absent" + : isExplicitModuleSourcePath(path) + ? "absent" + : "unknown"; + +const hasPriorMemberMutation = ( + index: JavaScriptReceiverIndex, + site: t.CallExpression, + identifier: t.Identifier, +): boolean => { + const callee = site.callee; + const property = t.isMemberExpression(callee) + ? semanticStaticPropertyKey(callee.property, callee.computed) + : undefined; + if (property === undefined || property === null) return true; + const facts = index.effectsForReferenceAt(site, identifier, [property]); + return ( + !facts.memberEffectsIndexed || + facts.effects.some( + ({ kind, timing }) => + (kind === "member-write" || kind === "escape") && timing !== "after", + ) + ); +}; const requireCalleeIdentifier = ( callee: t.Node | null | undefined, diff --git a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts index 0c88a38014..d89e0638e5 100644 --- a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts @@ -120,6 +120,162 @@ it("resolves require calls by lexical binding and source mode", async () => { ]); }); +const dynamicLoaderSources: Record = { + "active-with.cjs": [ + "with ({ require: null }) { require = () => 'local'; }", + "console.log(require('./loaded.cjs'));", + ].join("\n"), + "block-local-with.cjs": [ + "function patch() {", + " { let require = () => 'before'; with ({}) { require = () => 'local'; } }", + "}", + "patch();", + "console.log(require('./loaded.cjs'));", + ].join("\n"), + "for-in-empty-primitive.cjs": [ + "for (var require in { __proto__: 0 }) {}", + "console.log(require('./loaded.cjs'));", + ].join("\n"), + "for-in-nonempty.cjs": [ + "for (var require in { local: true }) {}", + "try { require('./throwing.cjs'); }", + "catch (error) {", + " if (!(error instanceof TypeError)) throw error;", + " console.log('replaced');", + "}", + ].join("\n"), + "for-of-empty.cjs": [ + "for (var require of []) {}", + "console.log(require('./loaded.cjs'));", + ].join("\n"), + "for-of-nonempty.cjs": [ + "for (var require of [() => 'local']) {}", + "console.log(require('./throwing.cjs'));", + ].join("\n"), + "function-parameter-with.cjs": [ + "function patch(require) { with ({}) { require = () => 'local'; } }", + "patch(() => 'before');", + "console.log(require('./loaded.cjs'));", + ].join("\n"), + "inactive-with-expression.cjs": [ + "with ({ require: (() => {", + " with ({}) { require = () => 'local'; }", + " return null;", + "})() }) {}", + "console.log(require('./throwing.cjs'));", + ].join("\n"), + "ordered-write.cjs": [ + "require('./ordered-before.cjs');", + "with ({}) { require = () => 'local'; }", + "console.log(require('./ordered-after.cjs'));", + ].join("\n"), + "primitive-unscopables.cjs": [ + "with ({ require: null, [Symbol.unscopables]: null }) {", + " require = () => 'local';", + "}", + "console.log(require('./loaded.cjs'));", + ].join("\n"), + "object-unscopables.cjs": [ + "with ({ require: null, [Symbol.unscopables]: { require: true } }) {", + " require = () => 'local';", + "}", + "console.log(require('./throwing.cjs'));", + ].join("\n"), + "with-write.cjs": [ + "with ({}) { require = () => 'local'; }", + "console.log(require('./throwing.cjs'));", + ].join("\n"), + "assignment-rhs.cjs": + "var require; require = require('./loaded.cjs'); console.log(require);", + "for-of-sparse.cjs": + "for (var require of [,]) {} try { require('./throwing.cjs'); } catch (error) { if (!(error instanceof TypeError)) throw error; console.log('replaced'); }", + "for-in-inherited.cjs": + "for (var require in { __proto__: { item: true } }) {} try { require('./throwing.cjs'); } catch (error) { if (!(error instanceof TypeError)) throw error; console.log('replaced'); }", + "prototype-read-only.cjs": + "Object.setPrototypeOf({}, Object.prototype); for (var require in { __proto__: 0 }) {} console.log(require('./loaded.cjs'));", + "prototype-alias.cjs": + "const prototype = Object.prototype; prototype.item = true; for (var require in {}) {} try { require('./throwing.cjs'); } catch (error) { if (!(error instanceof TypeError)) throw error; console.log('replaced'); }", + "array-iterator.cjs": + "Array.prototype[Symbol.iterator] = function* () { yield () => 'local'; }; for (var require of []) {} console.log(require('./throwing.cjs'));", + "string-unscopables.cjs": + "with ({ require: null, ['@@unscopables']: { require: true } }) { require = () => 'local'; } console.log(require('./loaded.cjs'));", + "inherited-unscopables.cjs": + "with ({ require: null, [Symbol.unscopables]: { __proto__: { require: true } } }) { require = () => 'local'; } console.log(require('./throwing.cjs'));", + "prototype-unscopables.cjs": + "Object.prototype[Symbol.unscopables] = { require: true }; with ({ require: null }) { require = () => 'local'; } console.log(require('./throwing.cjs'));", + "loaded.cjs": "console.log('loaded');\nmodule.exports = 'real';\n", + "throwing.cjs": "throw new Error('A shadowed require must not load me');\n", + "ordered-before.cjs": "console.log('before-loaded');\n", + "ordered-after.cjs": + "throw new Error('The replaced loader must not load me');\n", +}; + +it("keeps require edges according to the loader at each source site", async () => { + const root = await createTestTempDirectory("rea-reference-require-dynamic-"); + + await Promise.all( + Object.entries(dynamicLoaderSources).map(([path, source]) => + writeFile(join(root, path), source), + ), + ); + + const runNode = promisify(execFile); + for (const [path, stdout] of [ + ["assignment-rhs.cjs", "loaded\nreal\n"], + ["for-of-sparse.cjs", "replaced\n"], + ["for-in-inherited.cjs", "replaced\n"], + ["prototype-read-only.cjs", "loaded\nreal\n"], + ["prototype-alias.cjs", "replaced\n"], + ["array-iterator.cjs", "local\n"], + ["string-unscopables.cjs", "loaded\nreal\n"], + ["inherited-unscopables.cjs", "local\n"], + ["prototype-unscopables.cjs", "local\n"], + ["active-with.cjs", "loaded\nreal\n"], + ["block-local-with.cjs", "loaded\nreal\n"], + ["for-in-empty-primitive.cjs", "loaded\nreal\n"], + ["for-in-nonempty.cjs", "replaced\n"], + ["for-of-empty.cjs", "loaded\nreal\n"], + ["for-of-nonempty.cjs", "local\n"], + ["function-parameter-with.cjs", "loaded\nreal\n"], + ["inactive-with-expression.cjs", "local\n"], + ["ordered-write.cjs", "before-loaded\nlocal\n"], + ["primitive-unscopables.cjs", "loaded\nreal\n"], + ["object-unscopables.cjs", "local\n"], + ["with-write.cjs", "local\n"], + ] as const) { + const executed = await runNode(process.execPath, [join(root, path)], { + cwd: root, + timeout: 5_000, + }); + expect(executed.stdout).toBe(stdout); + expect(executed.stderr).toBe(""); + } + + const result = await importReferenceSource({ + root, + caller: "reference-require-dynamic-test", + policy: { secretPatterns: [] }, + }); + if (!result.ok) throw result.error; + expect( + result.value.relationships + .filter(({ kind }) => kind === "requires") + .map(({ from_path, to }) => [from_path, to]) + .sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")), + ).toEqual([ + ["active-with.cjs", "loaded.cjs"], + ["assignment-rhs.cjs", "loaded.cjs"], + ["block-local-with.cjs", "loaded.cjs"], + ["for-in-empty-primitive.cjs", "loaded.cjs"], + ["for-of-empty.cjs", "loaded.cjs"], + ["function-parameter-with.cjs", "loaded.cjs"], + ["ordered-write.cjs", "ordered-before.cjs"], + ["primitive-unscopables.cjs", "loaded.cjs"], + ["prototype-read-only.cjs", "loaded.cjs"], + ["string-unscopables.cjs", "loaded.cjs"], + ]); +}); + it("does not attribute overwritten require members to Node's resolver", async () => { const root = await createTestTempDirectory("rea-reference-require-member-"); const sources: Record = {