diff --git a/scripts/lib/import-specifiers.d.mts b/scripts/lib/import-specifiers.d.mts new file mode 100644 index 0000000000..ae29fe371b --- /dev/null +++ b/scripts/lib/import-specifiers.d.mts @@ -0,0 +1,18 @@ +/** One static import or re-export specifier and its source line. */ +export interface ImportSpecifierFact { + readonly specifier: string; + readonly line: number; +} + +/** Static string specifier on one import or re-export node, when it has one. */ +export function importSpecifier(node: { + readonly type: string; +}): string | undefined; + +/** + * Static import and re-export specifiers in source order. + * Comments and string literals are not imports. + */ +export function readImportSpecifiers( + source: string, +): readonly ImportSpecifierFact[]; diff --git a/scripts/lib/import-specifiers.mjs b/scripts/lib/import-specifiers.mjs new file mode 100644 index 0000000000..776b0440da --- /dev/null +++ b/scripts/lib/import-specifiers.mjs @@ -0,0 +1,43 @@ +import { parse } from "@babel/parser"; +import { traverseFast } from "@babel/types"; + +const literalValue = (node) => { + if (node?.type === "StringLiteral") return node.value; + if (node?.type === "TemplateLiteral" && node.expressions.length === 0) + return node.quasis[0]?.value.cooked ?? undefined; + return undefined; +}; + +/** Static string specifier on one import or re-export node, when it has one. */ +export const importSpecifier = (node) => { + switch (node.type) { + case "ImportDeclaration": + case "ExportNamedDeclaration": + case "ExportAllDeclaration": + case "ImportExpression": + case "TSImportType": + return literalValue(node.source); + case "TSImportEqualsDeclaration": + return node.moduleReference.type === "TSExternalModuleReference" + ? literalValue(node.moduleReference.expression) + : undefined; + default: + return undefined; + } +}; + +/** + * Static import and re-export specifiers in source order. + * Comments and string literals are not imports. Non-string dynamic imports + * are omitted. Callers decide which specifiers are in-repo edges. + */ +export const readImportSpecifiers = (source) => { + const specifiers = []; + const tree = parse(source, { sourceType: "module", plugins: ["typescript"] }); + traverseFast(tree, (node) => { + const specifier = importSpecifier(node); + if (specifier === undefined) return; + specifiers.push({ specifier, line: node.loc?.start.line ?? 1 }); + }); + return specifiers; +}; diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index 315362873b..a5dbc26615 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -1,8 +1,8 @@ -import { parse } from "@babel/parser"; -import { traverseFast } from "@babel/types"; import { isAbsolute, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { readImportSpecifiers } from "./import-specifiers.mjs"; + const PURE_LAYERS = new Set(["domain", "contracts"]); const MIGRATED_PROVIDER_ROOTS = new Set([ "sqlite", @@ -27,30 +27,6 @@ const MIGRATED_PROVIDER_ROOTS = new Set([ const repositoryPath = (root, path) => relative(root, path).replaceAll("\\", "/"); -const literalValue = (node) => { - if (node?.type === "StringLiteral") return node.value; - if (node?.type === "TemplateLiteral" && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? undefined; - return undefined; -}; - -const importSpecifier = (node) => { - switch (node.type) { - case "ImportDeclaration": - case "ExportNamedDeclaration": - case "ExportAllDeclaration": - case "ImportExpression": - case "TSImportType": - return literalValue(node.source); - case "TSImportEqualsDeclaration": - return node.moduleReference.type === "TSExternalModuleReference" - ? literalValue(node.moduleReference.expression) - : undefined; - default: - return undefined; - } -}; - const sourceTarget = (file, specifier) => { if (specifier.startsWith("file:")) return fileURLToPath(specifier); if (isAbsolute(specifier)) return resolve(specifier); @@ -115,26 +91,14 @@ const failedBoundary = (file, target) => { export const inspectModuleBoundaries = (file, source, root) => { const absoluteFile = resolve(root, file); const importer = repositoryPath(root, absoluteFile); - const tree = parse(source, { - sourceType: "module", - plugins: ["typescript"], - }); const violations = []; - traverseFast(tree, (node) => { - const specifier = importSpecifier(node); - if (specifier === undefined) return; + for (const { specifier, line } of readImportSpecifiers(source)) { const resolved = sourceTarget(absoluteFile, specifier); - if (resolved === undefined) return; + if (resolved === undefined) continue; const target = repositoryPath(root, resolved); const boundary = failedBoundary(importer, target); if (boundary !== undefined) - violations.push({ - file: importer, - line: node.loc?.start.line ?? 1, - specifier, - target, - boundary, - }); - }); + violations.push({ file: importer, line, specifier, target, boundary }); + } return violations; }; diff --git a/src/domain/analysisView/analysisViewCycles.test.ts b/src/domain/analysisView/analysisViewCycles.test.ts index d4441ac681..791fc192ad 100644 --- a/src/domain/analysisView/analysisViewCycles.test.ts +++ b/src/domain/analysisView/analysisViewCycles.test.ts @@ -1,48 +1,16 @@ -import { parse } from "@babel/parser"; -import { traverseFast, type Node } from "@babel/types"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { readImportSpecifiers } from "../../../scripts/lib/import-specifiers.mjs"; + const root = dirname(fileURLToPath(import.meta.url)); -/** String specifiers only, matching scripts/lib/module-boundaries.mjs. */ -const literalValue = (node: Node | null | undefined): string | undefined => { - if (node?.type === "StringLiteral") return node.value; - if (node?.type === "TemplateLiteral" && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? undefined; - return undefined; -}; - -const importSpecifier = (node: Node): string | undefined => { - switch (node.type) { - case "ImportDeclaration": - case "ExportAllDeclaration": - case "TSImportType": - return literalValue(node.source); - case "ExportNamedDeclaration": - case "ImportExpression": - return literalValue(node.source); - case "TSImportEqualsDeclaration": - return node.moduleReference.type === "TSExternalModuleReference" - ? literalValue(node.moduleReference.expression) - : undefined; - default: - return undefined; - } -}; - -/** Import and re-export specifiers, using the same Babel nodes as the module-boundary guard. */ -export const relativeSpecifiers = (source: string): readonly string[] => { - const specifiers: string[] = []; - const tree = parse(source, { sourceType: "module", plugins: ["typescript"] }); - traverseFast(tree, (node) => { - const specifier = importSpecifier(node); - if (specifier?.startsWith(".") === true) specifiers.push(specifier); - }); - return specifiers; -}; +const relativeSpecifiers = (source: string): readonly string[] => + readImportSpecifiers(source) + .map(({ specifier }) => specifier) + .filter((specifier) => specifier.startsWith(".")); /** * Cycles that already existed outside the analysis-view hub. Membership is @@ -137,25 +105,6 @@ const stronglyConnected = ( }; describe("analysis view import cycles", () => { - it("reads side-effect imports and ignores import-like text", () => { - expect( - relativeSpecifiers(` - // from "./commented.js" - /* import "./block-comment.js"; */ - const text = "from './string.js'"; - import "./side-effect.js"; - import { value } from "./named.js"; - export { value as again } from "./reexported.js"; - const loaded = import("./dynamic.js"); - `), - ).toEqual([ - "./side-effect.js", - "./named.js", - "./reexported.js", - "./dynamic.js", - ]); - }); - it("keeps projectors from importing the request and result hub", () => { const cycles = stronglyConnected(importGraph()).map((cycle) => cycle.join("\n"), diff --git a/src/domain/javascript/javascriptCommonJsExportReceivers.ts b/src/domain/javascript/javascriptCommonJsExportReceivers.ts index b3dca48d6d..10d4a9b1bf 100644 --- a/src/domain/javascript/javascriptCommonJsExportReceivers.ts +++ b/src/domain/javascript/javascriptCommonJsExportReceivers.ts @@ -7,7 +7,6 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { - isUnshadowedGlobal, resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; @@ -18,7 +17,10 @@ import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js"; -import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js"; +import { + isUnshadowedGlobal, + semanticEffectTiming, +} from "./javascriptSemanticMutationInitializers.js"; import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; const exportInvocations = new WeakMap< diff --git a/src/domain/javascript/javascriptGraphqlOperations.ts b/src/domain/javascript/javascriptGraphqlOperations.ts index 820379b784..af7b922122 100644 --- a/src/domain/javascript/javascriptGraphqlOperations.ts +++ b/src/domain/javascript/javascriptGraphqlOperations.ts @@ -16,7 +16,7 @@ import { import type { JavaScriptRpcEndpoint, JavaScriptRpcScan, -} from "./javascriptRpcEndpoints.js"; +} from "./javascriptRpcEndpoint.js"; /** * Find source-body string values owned by a GraphQL Source-shaped literal. diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index e6326d8d30..29957ae9e9 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -3,14 +3,14 @@ import * as t from "@babel/types"; import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; import { - isUnshadowedGlobal, resolveSemanticBindingState, semanticScopeId, semanticStableInitializer, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; +import { isUnshadowedGlobal } from "./javascriptSemanticMutationInitializers.js"; +import { readSemanticRequireOrigin } from "./javascriptSemanticRequireOriginSlot.js"; import { childNodes, javaScriptAstChildKeys, @@ -500,7 +500,7 @@ export const createSemanticModuleInterop = ( const required = converted === undefined ? undefined - : semanticRequireOrigin(converted.input, state)?.origin; + : readSemanticRequireOrigin(converted.input, state)?.origin; if ( converted === undefined || required === undefined || @@ -596,7 +596,7 @@ export const createSemanticModuleInterop = ( resolveSemanticBindingState(state, input, input.name) !== binding ) return undefined; - const required = semanticRequireOrigin(initial.node, state)?.origin; + const required = readSemanticRequireOrigin(initial.node, state)?.origin; if (required === undefined || required.importedPath.length !== 0) return undefined; if (convertedCall === undefined) return undefined; diff --git a/src/domain/javascript/javascriptRpcEndpoint.ts b/src/domain/javascript/javascriptRpcEndpoint.ts new file mode 100644 index 0000000000..1498d02f5b --- /dev/null +++ b/src/domain/javascript/javascriptRpcEndpoint.ts @@ -0,0 +1,15 @@ +import type * as t from "@babel/types"; + +/** One RPC operation or transport URL recovered from client-side syntax. */ +export interface JavaScriptRpcEndpoint { + readonly value: string; + readonly mechanism: string; +} + +/** Feed every AST node in pre-order, then drain finish() for Source findings. */ +export interface JavaScriptRpcScan { + inspect(node: t.Node): JavaScriptRpcEndpoint | undefined; + finish(): Iterable< + (JavaScriptRpcEndpoint & { readonly node: t.Node }) | void + >; +} diff --git a/src/domain/javascript/javascriptRpcEndpoints.ts b/src/domain/javascript/javascriptRpcEndpoints.ts index c3097aee4e..78f2490005 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.ts @@ -9,19 +9,15 @@ import { calleeName } from "./javascriptStaticAnalysisHelpers.js"; import type { JavaScriptBindingIndex } from "./javascriptSemanticState.js"; import { createJavaScriptGraphqlScan } from "./javascriptGraphqlOperations.js"; -/** One RPC operation or transport URL recovered from client-side syntax. */ -export interface JavaScriptRpcEndpoint { - readonly value: string; - readonly mechanism: string; -} +import type { + JavaScriptRpcEndpoint, + JavaScriptRpcScan, +} from "./javascriptRpcEndpoint.js"; -/** Feed every AST node in pre-order, then drain finish() for Source findings. */ -export interface JavaScriptRpcScan { - inspect(node: t.Node): JavaScriptRpcEndpoint | undefined; - finish(): Iterable< - (JavaScriptRpcEndpoint & { readonly node: t.Node }) | void - >; -} +export type { + JavaScriptRpcEndpoint, + JavaScriptRpcScan, +} from "./javascriptRpcEndpoint.js"; // tRPC proxies preserve router and procedure names as property names, so they // survive identifier minification. Method names follow @trpc/react-query and diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index be004588e0..688ff5c4a8 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -10,16 +10,15 @@ import { } from "./javascriptElectronMemberWrites.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; import { - isUnshadowedGlobal, resolveSemanticBindingState, semanticStableInitializer, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; -import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js"; import { + isUnshadowedGlobal, semanticBindingAtRead, - semanticEffectTiming, semanticDefiniteEffectPrecedesReference, + semanticEffectTiming, } from "./javascriptSemanticMutationInitializers.js"; import { semanticIterationSources, @@ -30,20 +29,24 @@ import { semanticPropertyPathKeyMatches, } from "./javascriptSemanticPropertyPaths.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; -import { semanticRequireLoaderAt } from "./javascriptSemanticRequireOrigin.js"; +import { + GLOBAL_OBJECT_NAMES, + globalObjectName, + globalReferenceNamespace, + isUnmodifiedGlobalReference, + isUnmodifiedImportMetaUrl, +} from "./javascriptSemanticGlobalReference.js"; +import { readSemanticRequireLoader } from "./javascriptSemanticRequireOriginSlot.js"; import { semanticCopiedReferencePath, semanticReferenceIterationUnknown, semanticReferenceSourcesInactive, } from "./javascriptSemanticReferenceSources.js"; -const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"]; - -const globalObjectName = ( - node: t.Node, - state: JavaScriptSemanticAnalysisState, -): string | undefined => - GLOBAL_OBJECT_NAMES.find((name) => isUnshadowedGlobal(node, state, name)); +export { + isUnmodifiedGlobalReference, + isUnmodifiedImportMetaUrl, +} from "./javascriptSemanticGlobalReference.js"; /** Global self references retain object identity, rather than adding a property. */ const canonicalAmbientPath = ( @@ -801,7 +804,7 @@ export function* indexJavaScriptAmbientWritesSteps( (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && !state.ambientWrites.covers(["global", "require", "resolve"]) && !state.ambientEscapes.covers(["global", "require", "resolve"]) && - semanticRequireLoaderAt(node, state, new Set()) === "commonjs-loader" + readSemanticRequireLoader(node, state, new Set()) === "commonjs-loader" ) continue; retainEscapeEffect(effect); @@ -835,42 +838,6 @@ export function* indexJavaScriptAmbientWritesSteps( state.ambientEscapes.retain(["global", null]); } -/** Match lexical global identity; null denotes a direct global root. */ -const globalReferenceNamespace = ( - callee: t.Node, - state: JavaScriptSemanticAnalysisState, - path: readonly [string, ...string[]], -): string | null | undefined => { - const { root, members } = electronMemberPath(callee); - const matches = (expected: readonly string[]) => - members.length === expected.length && - members.every((key, index) => key === expected[index]); - if (isUnshadowedGlobal(root, state, path[0]) && matches(path.slice(1))) - return null; - const namespace = globalObjectName(root, state); - return namespace !== undefined && matches(path) ? namespace : undefined; -}; - -/** - * Recognize an ambient property path, optionally through a global object, without a visible - * source-local overwrite. This is a syntax-local identity fact, not proof that - * the runtime's globals have never been patched by other code. - */ -export const isUnmodifiedGlobalReference = ( - callee: t.Node, - state: JavaScriptSemanticAnalysisState, - name: string | readonly [string, ...string[]], -): boolean => { - const path: readonly [string, ...string[]] = - typeof name === "string" ? [name] : name; - if (state.ambientWrites.covers(["global", ...path])) return false; - const namespace = globalReferenceNamespace(callee, state, path); - return ( - namespace !== undefined && - (namespace === null || !state.ambientWrites.covers(["global", namespace])) - ); -}; - export type GlobalReferenceRead = | { readonly status: "unmodified" | "unrelated" | "unknown" } | { readonly status: "replacement"; readonly value: t.Expression }; @@ -975,10 +942,3 @@ export const createGlobalReferenceReader = ( return { status: "replacement", value: confirmed.right }; }; }; - -/** A module URL base is known only while its property has no visible write. */ -export const isUnmodifiedImportMetaUrl = ( - node: t.Node | undefined, - state: JavaScriptSemanticAnalysisState, -): boolean => - isImportMetaUrl(node) && !state.ambientWrites.covers(["import-meta", "url"]); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 2cb3140745..ac0dd13d7c 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -5,6 +5,7 @@ import { semanticBindingCanBeRebound, semanticOpaqueBindingReadMayHaveChanged, } from "./javascriptSemanticOpaqueEffects.js"; +import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticReceiverFacts.js"; import * as t from "@babel/types"; import { @@ -204,7 +205,10 @@ function* indexSemanticAmbientWritesSteps( yield* indexJavaScriptAmbientWritesSteps( state.program, state, - createSemanticOpaqueGlobalObserver(state), + createSemanticOpaqueGlobalObserver(state, { + createAmbientPathResolver, + createSemanticModuleInterop, + }), (node) => observePossibleCommonJsDelegateWrite(node, state), ); if (state.opaqueEffects.globalSites.length > 0) @@ -506,15 +510,7 @@ export function* analyzeParsedJavaScriptSemanticsSteps( * Receiver identity facts for the overloaded `.open` syntax, plus * `configuration` for `.get` reads of a `getConfiguration(...)` result. */ -export type JavaScriptOpenReceiverFact = - | "window" - | "indexed-db" - | "cache-storage" - | "local" - | "local-indexed-db" - | "local-cache-storage" - | "configuration" - | "extension-state"; +export type { JavaScriptOpenReceiverFact } from "./javascriptSemanticReceiverFacts.js"; /** Lexical receiver identities that select static endpoint interpretations. */ export interface JavaScriptReceiverFacts { diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 7668963085..ea8c872e19 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -20,16 +20,18 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { - isUnshadowedGlobal, resolveSemanticBindingState, semanticStableInitializer, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; +import { + isUnshadowedGlobal, + semanticBindingAtRead, +} from "./javascriptSemanticMutationInitializers.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { builtinDataEffectMethod, dataEffectLiteralString, diff --git a/src/domain/javascript/javascriptSemanticCycles.test.ts b/src/domain/javascript/javascriptSemanticCycles.test.ts new file mode 100644 index 0000000000..fa2ed595a8 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticCycles.test.ts @@ -0,0 +1,119 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { readImportSpecifiers } from "../../../scripts/lib/import-specifiers.mjs"; + +const root = dirname(fileURLToPath(import.meta.url)); + +const relativeSpecifiers = (source: string): readonly string[] => + readImportSpecifiers(source) + .map(({ specifier }) => specifier) + .filter((specifier) => specifier.startsWith(".")); + +/** + * Cycles that already existed outside the semantic analysis hub. Membership is + * exact so a new edge cannot hide inside the allow-list. + */ +const allowedCycles = [ + [ + "electronStaticAnalysisTypes.ts", + "javascriptRouteFacts.ts", + "javascriptSourceCoordinates.ts", + "javascriptStaticAnalysisTypes.ts", + ], + [ + "javascriptRuntimeReconciliationActive.ts", + "javascriptRuntimeReconciliationCaptureParsing.ts", + ], + ["javascriptStaticAnalysisFindings.ts", "javascriptStaticAnalysisState.ts"], +]; + +const productionFiles = (directory: string): string[] => { + const files: string[] = []; + for (const name of readdirSync(directory)) { + const path = join(directory, name); + if (statSync(path).isDirectory()) files.push(...productionFiles(path)); + else if (name.endsWith(".ts") && !name.includes(".test.")) files.push(path); + } + return files; +}; + +const importGraph = (): Map> => { + const files = new Set( + productionFiles(root).map((path) => relative(root, path)), + ); + const graph = new Map>(); + for (const file of files) { + const source = readFileSync(join(root, file), "utf8"); + const edges = new Set(); + for (const spec of relativeSpecifiers(source)) { + const resolved = resolve(dirname(join(root, file)), spec).replace( + /\.js$/, + ".ts", + ); + const target = relative(root, resolved); + if (files.has(target)) edges.add(target); + } + graph.set(file, edges); + } + return graph; +}; + +const stronglyConnected = ( + graph: Map>, +): readonly (readonly string[])[] => { + let index = 0; + const stack: string[] = []; + const onStack = new Set(); + const indices = new Map(); + const low = new Map(); + const components: string[][] = []; + const visit = (node: string): void => { + indices.set(node, index); + low.set(node, index); + index += 1; + stack.push(node); + onStack.add(node); + for (const next of graph.get(node) ?? []) { + if (!indices.has(next)) { + visit(next); + low.set(node, Math.min(low.get(node) ?? index, low.get(next) ?? index)); + } else if (onStack.has(next)) { + low.set( + node, + Math.min(low.get(node) ?? index, indices.get(next) ?? index), + ); + } + } + if (low.get(node) === indices.get(node)) { + const component: string[] = []; + let member = ""; + while (member !== node) { + member = stack.pop() ?? node; + onStack.delete(member); + component.push(member); + } + if (component.length > 1) components.push(component.sort()); + } + }; + for (const node of [...graph.keys()].sort()) { + if (!indices.has(node)) visit(node); + } + return components.sort( + (left, right) => + right.length - left.length || left.join().localeCompare(right.join()), + ); +}; + +describe("javascript semantic import cycles", () => { + it("keeps the semantic modules out of one import cycle", () => { + const cycles = stronglyConnected(importGraph()).map((cycle) => + cycle.join("\n"), + ); + expect(cycles).toEqual( + allowedCycles.map((cycle) => [...cycle].sort().join("\n")), + ); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.ts index f31ec62430..423bf91dd3 100644 --- a/src/domain/javascript/javascriptSemanticExpressionShapes.ts +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.ts @@ -12,10 +12,12 @@ import { isErasedJavaScriptDeclaration, isUnmodeledJavaScriptRuntimeScope, } from "./javascriptModuleDeclaration.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; import { isUnshadowedGlobal, + semanticBindingAtRead, +} from "./javascriptSemanticMutationInitializers.js"; +import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; +import { resolveSemanticBindingState, semanticStableInitializer, type JavaScriptSemanticAnalysisState, diff --git a/src/domain/javascript/javascriptSemanticGetterQueries.ts b/src/domain/javascript/javascriptSemanticGetterQueries.ts index 904e7b65d2..b87eac3e8a 100644 --- a/src/domain/javascript/javascriptSemanticGetterQueries.ts +++ b/src/domain/javascript/javascriptSemanticGetterQueries.ts @@ -1,5 +1,5 @@ import type * as t from "@babel/types"; -import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReferences.js"; +import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReference.js"; import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; import { SEMANTIC_GETTER_EXPANSION_LIMIT } from "./javascriptSemanticResourceLimits.js"; diff --git a/src/domain/javascript/javascriptSemanticGetterResultWalker.ts b/src/domain/javascript/javascriptSemanticGetterResultWalker.ts new file mode 100644 index 0000000000..7c2fe5f831 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticGetterResultWalker.ts @@ -0,0 +1,408 @@ +import * as t from "@babel/types"; +import { + unwrapJavaScriptExpression, + semanticStaticPropertyKey, +} from "./javascriptAstValues.js"; +import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticBindingState, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReference.js"; +import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import { + SemanticGetterQuery, + type GetterQueryJob, +} from "./javascriptSemanticGetterQueries.js"; + +type ClassNode = t.ClassDeclaration | t.ClassExpression; +interface AliasVisit { + readonly identity: string; + readonly pathLength: number; + readonly parent?: AliasVisit; +} +interface GetterWork { + readonly node: t.Node; + readonly path: PropertyPath; + readonly aliases?: AliasVisit | undefined; + readonly escapes?: true; + readonly privateRead?: { + readonly node: t.MemberExpression | t.OptionalMemberExpression; + readonly name: string; + readonly path: PropertyPath; + }; +} +export interface GetterResultResolvers { + readonly classReferences: ( + lookup: { + readonly owner: ClassNode; + readonly receiver?: ClassNode; + readonly kind: "static" | "instance" | "prototype"; + }, + path: PropertyPath, + escapes: boolean, + ) => readonly Reference[]; + readonly objectReferences: ( + receiver: t.ObjectExpression, + path: PropertyPath, + ) => readonly Reference[]; + readonly privateReceiver: ( + receiver: ClassNode, + owner: ClassNode, + path: PropertyPath, + isStatic: boolean, + ) => boolean; + readonly bases: (node: t.Node) => readonly ClassNode[]; + readonly containerReferences?: ( + node: t.ObjectExpression | t.ArrayExpression, + path: PropertyPath, + ) => readonly Reference[]; +} + +/** Branch syntax contributes alternatives without evaluating application code. */ +const resultBranches = (node: t.Node): readonly t.Node[] | undefined => { + if (t.isConditionalExpression(node)) return [node.consequent, node.alternate]; + if (t.isLogicalExpression(node)) return [node.left, node.right]; + if (t.isSequenceExpression(node)) { + const last = node.expressions.at(-1); + return last === undefined ? [] : [last]; + } + if (t.isAssignmentExpression(node)) { + if (node.operator === "=") return [node.right]; + if (["&&=", "||=", "??="].includes(node.operator)) + return [node.left, node.right]; + } + return undefined; +}; + +/** Each walk owns local aliases; getter-to-getter traversal belongs to the query worklist. */ +export class GetterResultWalker { + readonly #pending: GetterWork[] = []; + readonly #aliases = new Map>(); + readonly #scope: string | undefined; + + constructor( + readonly job: GetterQueryJob, + readonly query: SemanticGetterQuery, + readonly resolvers: GetterResultResolvers, + roots: readonly { + readonly node: t.Node | null; + }[] = semanticCallableResultExpressions(job.invocation.getter), + ) { + for (const { node } of roots) + if (node !== null) this.#enqueue(() => ({ node, path: job.path })); + this.#scope = query.state.scopeByNode.get(job.invocation.getter)?.scopeId; + } + + run(): void { + for (let cursor = 0; cursor < this.#pending.length; cursor++) { + const current = this.#pending[cursor]; + if (current !== undefined) this.#visit(current); + } + } + + #enqueue(create: () => GetterWork): void { + if (!this.query.reserveResult()) return; + const work = create(); + // The fallback escapes receiver roots. Keeping local suffix permutations + // here would repeat the precise expansion inside a single getter. + this.#pending.push( + this.query.widening + ? { + ...work, + path: [], + escapes: true, + ...(work.privateRead === undefined + ? {} + : { privateRead: { ...work.privateRead, path: [] } }), + } + : work, + ); + } + + #retain(current: GetterWork, reference: Reference): void { + this.query.retain(reference, current.escapes || this.job.escapes); + } + + #privateRead(current: GetterWork): void { + if (current.privateRead !== undefined) + this.#retain(current, { + node: current.privateRead.node, + path: current.escapes ? [] : current.privateRead.path, + }); + } + + #localBinding(expression: t.Identifier) { + const state = this.query.state; + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, + ); + let scope = + binding === undefined ? undefined : state.scopesById.get(binding.scopeId); + while (scope !== undefined && scope.scopeId !== this.#scope) { + if (scope.kind === "function" || scope.parentScopeId === null) + return undefined; + scope = state.scopesById.get(scope.parentScopeId); + } + return scope !== undefined && + binding?.referenceInitializers.every( + (initializer) => initializer.copyKind === undefined, + ) + ? binding + : undefined; + } + + #cycle(current: GetterWork, identity: string): AliasVisit | undefined { + for (let visit = current.aliases; visit !== undefined; visit = visit.parent) + if (visit.identity === identity) return visit; + return undefined; + } + + #alias(current: GetterWork, expression: t.Identifier): boolean { + const binding = this.#localBinding(expression); + if (binding === undefined) return false; + const identity = JSON.stringify([ + binding.bindingId, + expression.start, + expression.end, + ]); + const previous = this.#cycle(current, identity); + if (previous !== undefined) { + // Growing aliases can reach arbitrarily deep children of the same roots. + if (current.path.length > previous.pathLength && !current.escapes) + this.#enqueue(() => ({ + ...current, + path: [], + aliases: undefined, + escapes: true, + })); + return true; + } + const key = JSON.stringify([ + current.path, + current.privateRead?.name, + current.privateRead?.path, + current.escapes, + ]); + const known = this.#aliases.get(identity) ?? new Set(); + if (!known.has(key)) { + known.add(key); + this.#aliases.set(identity, known); + this.#aliasOrigins(current, expression, binding, identity); + } + return true; + } + + #aliasOrigins( + current: GetterWork, + expression: t.Identifier, + binding: JavaScriptSemanticBindingState, + identity: string, + ): void { + const state = this.query.state; + const { binding: origins } = semanticBindingAtRead( + binding, + expression, + state.parentsByNode, + ); + const aliases: AliasVisit = { + identity, + pathLength: current.path.length, + ...(current.aliases === undefined ? {} : { parent: current.aliases }), + }; + for (const initializer of [ + ...origins.initializers, + ...origins.referenceInitializers.filter( + (origin) => !semanticReferenceSourcesInactive(origin, state), + ), + ]) + this.#enqueue(() => ({ + ...current, + node: initializer.node, + path: [...initializer.projection, ...current.path], + aliases, + })); + } + + #ownsThis(expression: t.ThisExpression | t.Super): boolean { + let owner = this.query.state.parentsByNode.get(expression); + while ( + owner !== undefined && + !t.isClass(owner) && + !(t.isFunction(owner) && !t.isArrowFunctionExpression(owner)) + ) + owner = this.query.state.parentsByNode.get(owner); + return owner === this.job.invocation.getter; + } + + #privateValue(current: GetterWork, owner: ClassNode): void { + const read = current.privateRead; + if (read === undefined) return; + const { getter, receiver = owner } = this.job.invocation; + const isStatic = t.isClassMethod(getter) && getter.static; + if ( + !t.isClass(receiver) || + !this.resolvers.privateReceiver(receiver, owner, current.path, isStatic) + ) { + this.#privateRead(current); + return; + } + for (const member of owner.body.body) + if ( + t.isClassPrivateProperty(member) && + member.static === isStatic && + member.key.id.name === read.name && + member.value != null + ) + this.#retain(current, { + node: member.value, + path: current.escapes ? [] : read.path, + }); + } + + #thisValue( + current: GetterWork, + expression: t.ThisExpression | t.Super, + ): void { + if (!this.#ownsThis(expression)) return; + const { getter, owner, receiver = owner } = this.job.invocation; + if (current.privateRead !== undefined && t.isClass(owner)) { + this.#privateValue(current, owner); + return; + } + const references = t.isClass(receiver) + ? this.resolvers.classReferences( + { + owner: receiver, + kind: + t.isClassMethod(getter) && getter.static ? "static" : "instance", + }, + current.path, + current.escapes || this.job.escapes, + ) + : this.resolvers.objectReferences(receiver, current.path); + for (const reference of references) this.#retain(current, reference); + } + + #superValue( + current: GetterWork, + expression: t.MemberExpression | t.OptionalMemberExpression, + ): boolean { + const { getter, owner, receiver = owner } = this.job.invocation; + if ( + !t.isSuper(expression.object) || + !t.isClass(owner) || + owner.superClass == null + ) + return false; + const key = semanticStaticPropertyKey( + expression.property, + expression.computed, + ); + for (const base of this.resolvers.bases(owner.superClass)) + for (const reference of this.resolvers.classReferences( + { + owner: base, + receiver: t.isClass(receiver) ? receiver : owner, + kind: + t.isClassMethod(getter) && getter.static ? "static" : "prototype", + }, + [key, ...current.path], + current.escapes || this.job.escapes, + )) + this.#retain(current, reference); + return true; + } + + #member( + current: GetterWork, + expression: t.MemberExpression | t.OptionalMemberExpression, + ): void { + if ( + current.privateRead !== undefined && + t.isPrivateName(expression.property) + ) { + this.#privateRead(current); + return; + } + if ( + t.isPrivateName(expression.property) && + t.isClass(this.job.invocation.owner) + ) { + const name = expression.property.id.name; + this.#enqueue(() => ({ + ...current, + node: expression.object, + path: [], + privateRead: { + node: expression, + name, + path: current.path, + }, + })); + return; + } + if (!this.#superValue(current, expression)) + this.#enqueue(() => ({ + ...current, + node: expression.object, + path: [ + semanticStaticPropertyKey(expression.property, expression.computed), + ...current.path, + ], + })); + } + + #visit(current: GetterWork): void { + const expression = unwrapJavaScriptExpression(current.node).node; + if (t.isIdentifier(expression) && this.#alias(current, expression)) return; + if (t.isForOfStatement(expression)) { + this.#enqueue(() => ({ + ...current, + node: expression.right, + path: [null, ...current.path], + })); + return; + } + if (t.isThisExpression(expression) || t.isSuper(expression)) + this.#thisValue(current, expression); + else if ( + t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression) + ) + this.#member(current, expression); + else { + const branches = resultBranches(expression); + if (branches !== undefined) + for (const node of branches) + this.#enqueue(() => ({ ...current, node })); + else if (current.privateRead !== undefined) this.#privateRead(current); + else if ( + current.path.length > 0 && + (t.isObjectExpression(expression) || t.isArrayExpression(expression)) && + this.resolvers.containerReferences !== undefined + ) { + for (const reference of this.resolvers.containerReferences( + expression, + current.path, + )) + this.#enqueue(() => ({ + ...current, + node: reference.node, + path: reference.path, + })); + } else this.#retain(current, { node: expression, path: current.path }); + } + } +} + +export const expandSemanticGetterResult = ( + job: GetterQueryJob, + query: SemanticGetterQuery, + resolvers: GetterResultResolvers, +): void => new GetterResultWalker(job, query, resolvers).run(); diff --git a/src/domain/javascript/javascriptSemanticGetterResults.ts b/src/domain/javascript/javascriptSemanticGetterResults.ts index 5b8433cd82..ab47ec150b 100644 --- a/src/domain/javascript/javascriptSemanticGetterResults.ts +++ b/src/domain/javascript/javascriptSemanticGetterResults.ts @@ -1,415 +1,12 @@ import * as t from "@babel/types"; -import { - unwrapJavaScriptExpression, - semanticStaticPropertyKey, -} from "./javascriptAstValues.js"; -import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; -import { - resolveSemanticBindingState, - type JavaScriptSemanticBindingState, - type JavaScriptSemanticAnalysisState, -} from "./javascriptSemanticState.js"; import { semanticObjectPropertyReferences, semanticArrayPropertyReferences, type JavaScriptSemanticObjectReference as Reference, } from "./javascriptSemanticObjectReferences.js"; -import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; -import { - SemanticGetterQuery, - type GetterQueryJob, -} from "./javascriptSemanticGetterQueries.js"; - -type ClassNode = t.ClassDeclaration | t.ClassExpression; -interface AliasVisit { - readonly identity: string; - readonly pathLength: number; - readonly parent?: AliasVisit; -} -interface GetterWork { - readonly node: t.Node; - readonly path: PropertyPath; - readonly aliases?: AliasVisit | undefined; - readonly escapes?: true; - readonly privateRead?: { - readonly node: t.MemberExpression | t.OptionalMemberExpression; - readonly name: string; - readonly path: PropertyPath; - }; -} -export interface GetterResultResolvers { - readonly classReferences: ( - lookup: { - readonly owner: ClassNode; - readonly receiver?: ClassNode; - readonly kind: "static" | "instance" | "prototype"; - }, - path: PropertyPath, - escapes: boolean, - ) => readonly Reference[]; - readonly objectReferences: ( - receiver: t.ObjectExpression, - path: PropertyPath, - ) => readonly Reference[]; - readonly privateReceiver: ( - receiver: ClassNode, - owner: ClassNode, - path: PropertyPath, - isStatic: boolean, - ) => boolean; - readonly bases: (node: t.Node) => readonly ClassNode[]; - readonly containerReferences?: ( - node: t.ObjectExpression | t.ArrayExpression, - path: PropertyPath, - ) => readonly Reference[]; -} - -/** Branch syntax contributes alternatives without evaluating application code. */ -const resultBranches = (node: t.Node): readonly t.Node[] | undefined => { - if (t.isConditionalExpression(node)) return [node.consequent, node.alternate]; - if (t.isLogicalExpression(node)) return [node.left, node.right]; - if (t.isSequenceExpression(node)) { - const last = node.expressions.at(-1); - return last === undefined ? [] : [last]; - } - if (t.isAssignmentExpression(node)) { - if (node.operator === "=") return [node.right]; - if (["&&=", "||=", "??="].includes(node.operator)) - return [node.left, node.right]; - } - return undefined; -}; - -/** Each walk owns local aliases; getter-to-getter traversal belongs to the query worklist. */ -class GetterResultWalker { - readonly #pending: GetterWork[] = []; - readonly #aliases = new Map>(); - readonly #scope: string | undefined; - - constructor( - readonly job: GetterQueryJob, - readonly query: SemanticGetterQuery, - readonly resolvers: GetterResultResolvers, - roots: readonly { - readonly node: t.Node | null; - }[] = semanticCallableResultExpressions(job.invocation.getter), - ) { - for (const { node } of roots) - if (node !== null) this.#enqueue(() => ({ node, path: job.path })); - this.#scope = query.state.scopeByNode.get(job.invocation.getter)?.scopeId; - } - - run(): void { - for (let cursor = 0; cursor < this.#pending.length; cursor++) { - const current = this.#pending[cursor]; - if (current !== undefined) this.#visit(current); - } - } - - #enqueue(create: () => GetterWork): void { - if (!this.query.reserveResult()) return; - const work = create(); - // The fallback escapes receiver roots. Keeping local suffix permutations - // here would repeat the precise expansion inside a single getter. - this.#pending.push( - this.query.widening - ? { - ...work, - path: [], - escapes: true, - ...(work.privateRead === undefined - ? {} - : { privateRead: { ...work.privateRead, path: [] } }), - } - : work, - ); - } - - #retain(current: GetterWork, reference: Reference): void { - this.query.retain(reference, current.escapes || this.job.escapes); - } - - #privateRead(current: GetterWork): void { - if (current.privateRead !== undefined) - this.#retain(current, { - node: current.privateRead.node, - path: current.escapes ? [] : current.privateRead.path, - }); - } - - #localBinding(expression: t.Identifier) { - const state = this.query.state; - const binding = resolveSemanticBindingState( - state, - expression, - expression.name, - ); - let scope = - binding === undefined ? undefined : state.scopesById.get(binding.scopeId); - while (scope !== undefined && scope.scopeId !== this.#scope) { - if (scope.kind === "function" || scope.parentScopeId === null) - return undefined; - scope = state.scopesById.get(scope.parentScopeId); - } - return scope !== undefined && - binding?.referenceInitializers.every( - (initializer) => initializer.copyKind === undefined, - ) - ? binding - : undefined; - } - - #cycle(current: GetterWork, identity: string): AliasVisit | undefined { - for (let visit = current.aliases; visit !== undefined; visit = visit.parent) - if (visit.identity === identity) return visit; - return undefined; - } - - #alias(current: GetterWork, expression: t.Identifier): boolean { - const binding = this.#localBinding(expression); - if (binding === undefined) return false; - const identity = JSON.stringify([ - binding.bindingId, - expression.start, - expression.end, - ]); - const previous = this.#cycle(current, identity); - if (previous !== undefined) { - // Growing aliases can reach arbitrarily deep children of the same roots. - if (current.path.length > previous.pathLength && !current.escapes) - this.#enqueue(() => ({ - ...current, - path: [], - aliases: undefined, - escapes: true, - })); - return true; - } - const key = JSON.stringify([ - current.path, - current.privateRead?.name, - current.privateRead?.path, - current.escapes, - ]); - const known = this.#aliases.get(identity) ?? new Set(); - if (!known.has(key)) { - known.add(key); - this.#aliases.set(identity, known); - this.#aliasOrigins(current, expression, binding, identity); - } - return true; - } - - #aliasOrigins( - current: GetterWork, - expression: t.Identifier, - binding: JavaScriptSemanticBindingState, - identity: string, - ): void { - const state = this.query.state; - const { binding: origins } = semanticBindingAtRead( - binding, - expression, - state.parentsByNode, - ); - const aliases: AliasVisit = { - identity, - pathLength: current.path.length, - ...(current.aliases === undefined ? {} : { parent: current.aliases }), - }; - for (const initializer of [ - ...origins.initializers, - ...origins.referenceInitializers.filter( - (origin) => !semanticReferenceSourcesInactive(origin, state), - ), - ]) - this.#enqueue(() => ({ - ...current, - node: initializer.node, - path: [...initializer.projection, ...current.path], - aliases, - })); - } - - #ownsThis(expression: t.ThisExpression | t.Super): boolean { - let owner = this.query.state.parentsByNode.get(expression); - while ( - owner !== undefined && - !t.isClass(owner) && - !(t.isFunction(owner) && !t.isArrowFunctionExpression(owner)) - ) - owner = this.query.state.parentsByNode.get(owner); - return owner === this.job.invocation.getter; - } - - #privateValue(current: GetterWork, owner: ClassNode): void { - const read = current.privateRead; - if (read === undefined) return; - const { getter, receiver = owner } = this.job.invocation; - const isStatic = t.isClassMethod(getter) && getter.static; - if ( - !t.isClass(receiver) || - !this.resolvers.privateReceiver(receiver, owner, current.path, isStatic) - ) { - this.#privateRead(current); - return; - } - for (const member of owner.body.body) - if ( - t.isClassPrivateProperty(member) && - member.static === isStatic && - member.key.id.name === read.name && - member.value != null - ) - this.#retain(current, { - node: member.value, - path: current.escapes ? [] : read.path, - }); - } - - #thisValue( - current: GetterWork, - expression: t.ThisExpression | t.Super, - ): void { - if (!this.#ownsThis(expression)) return; - const { getter, owner, receiver = owner } = this.job.invocation; - if (current.privateRead !== undefined && t.isClass(owner)) { - this.#privateValue(current, owner); - return; - } - const references = t.isClass(receiver) - ? this.resolvers.classReferences( - { - owner: receiver, - kind: - t.isClassMethod(getter) && getter.static ? "static" : "instance", - }, - current.path, - current.escapes || this.job.escapes, - ) - : this.resolvers.objectReferences(receiver, current.path); - for (const reference of references) this.#retain(current, reference); - } - - #superValue( - current: GetterWork, - expression: t.MemberExpression | t.OptionalMemberExpression, - ): boolean { - const { getter, owner, receiver = owner } = this.job.invocation; - if ( - !t.isSuper(expression.object) || - !t.isClass(owner) || - owner.superClass == null - ) - return false; - const key = semanticStaticPropertyKey( - expression.property, - expression.computed, - ); - for (const base of this.resolvers.bases(owner.superClass)) - for (const reference of this.resolvers.classReferences( - { - owner: base, - receiver: t.isClass(receiver) ? receiver : owner, - kind: - t.isClassMethod(getter) && getter.static ? "static" : "prototype", - }, - [key, ...current.path], - current.escapes || this.job.escapes, - )) - this.#retain(current, reference); - return true; - } - - #member( - current: GetterWork, - expression: t.MemberExpression | t.OptionalMemberExpression, - ): void { - if ( - current.privateRead !== undefined && - t.isPrivateName(expression.property) - ) { - this.#privateRead(current); - return; - } - if ( - t.isPrivateName(expression.property) && - t.isClass(this.job.invocation.owner) - ) { - const name = expression.property.id.name; - this.#enqueue(() => ({ - ...current, - node: expression.object, - path: [], - privateRead: { - node: expression, - name, - path: current.path, - }, - })); - return; - } - if (!this.#superValue(current, expression)) - this.#enqueue(() => ({ - ...current, - node: expression.object, - path: [ - semanticStaticPropertyKey(expression.property, expression.computed), - ...current.path, - ], - })); - } - - #visit(current: GetterWork): void { - const expression = unwrapJavaScriptExpression(current.node).node; - if (t.isIdentifier(expression) && this.#alias(current, expression)) return; - if (t.isForOfStatement(expression)) { - this.#enqueue(() => ({ - ...current, - node: expression.right, - path: [null, ...current.path], - })); - return; - } - if (t.isThisExpression(expression) || t.isSuper(expression)) - this.#thisValue(current, expression); - else if ( - t.isMemberExpression(expression) || - t.isOptionalMemberExpression(expression) - ) - this.#member(current, expression); - else { - const branches = resultBranches(expression); - if (branches !== undefined) - for (const node of branches) - this.#enqueue(() => ({ ...current, node })); - else if (current.privateRead !== undefined) this.#privateRead(current); - else if ( - current.path.length > 0 && - (t.isObjectExpression(expression) || t.isArrayExpression(expression)) && - this.resolvers.containerReferences !== undefined - ) { - for (const reference of this.resolvers.containerReferences( - expression, - current.path, - )) - this.#enqueue(() => ({ - ...current, - node: reference.node, - path: reference.path, - })); - } else this.#retain(current, { node: expression, path: current.path }); - } - } -} - -export const expandSemanticGetterResult = ( - job: GetterQueryJob, - query: SemanticGetterQuery, - resolvers: GetterResultResolvers, -): void => new GetterResultWalker(job, query, resolvers).run(); +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; +import { SemanticGetterQuery } from "./javascriptSemanticGetterQueries.js"; +import { GetterResultWalker } from "./javascriptSemanticGetterResultWalker.js"; /** Project an effect operand through the same local aliases and `this` owner as getter results. */ export const semanticGetterReceiverReferences = ( diff --git a/src/domain/javascript/javascriptSemanticGlobalReference.ts b/src/domain/javascript/javascriptSemanticGlobalReference.ts new file mode 100644 index 0000000000..8f991523fc --- /dev/null +++ b/src/domain/javascript/javascriptSemanticGlobalReference.ts @@ -0,0 +1,56 @@ +import * as t from "@babel/types"; +import { electronMemberPath } from "./javascriptElectronMemberWrites.js"; +import { isUnshadowedGlobal } from "./javascriptSemanticMutationInitializers.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; +import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js"; + +export const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"]; + +export const globalObjectName = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): string | undefined => + GLOBAL_OBJECT_NAMES.find((name) => isUnshadowedGlobal(node, state, name)); + +/** Match lexical global identity; null denotes a direct global root. */ +export const globalReferenceNamespace = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + path: readonly [string, ...string[]], +): string | null | undefined => { + const { root, members } = electronMemberPath(callee); + const matches = (expected: readonly string[]) => + members.length === expected.length && + members.every((key, index) => key === expected[index]); + if (isUnshadowedGlobal(root, state, path[0]) && matches(path.slice(1))) + return null; + const namespace = globalObjectName(root, state); + return namespace !== undefined && matches(path) ? namespace : undefined; +}; + +/** + * Recognize an ambient property path, optionally through a global object, without a visible + * source-local overwrite. This is a syntax-local identity fact, not proof that + * the runtime's globals have never been patched by other code. + */ +export const isUnmodifiedGlobalReference = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + name: string | readonly [string, ...string[]], +): boolean => { + const path: readonly [string, ...string[]] = + typeof name === "string" ? [name] : name; + if (state.ambientWrites.covers(["global", ...path])) return false; + const namespace = globalReferenceNamespace(callee, state, path); + return ( + namespace !== undefined && + (namespace === null || !state.ambientWrites.covers(["global", namespace])) + ); +}; + +/** A module URL base is known only while its property has no visible write. */ +export const isUnmodifiedImportMetaUrl = ( + node: t.Node | undefined, + state: JavaScriptSemanticAnalysisState, +): boolean => + isImportMetaUrl(node) && !state.ambientWrites.covers(["import-meta", "url"]); diff --git a/src/domain/javascript/javascriptSemanticIterationFacts.ts b/src/domain/javascript/javascriptSemanticIterationFacts.ts new file mode 100644 index 0000000000..a6efca5827 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticIterationFacts.ts @@ -0,0 +1,19 @@ +import type * as t from "@babel/types"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; + +/** Shared shape of an iterator or property-read source. */ +export interface IterationSourceValue { + readonly node: t.Node; + readonly projection: JavaScriptSemanticPropertyPath; + readonly iterationSources?: readonly IterationSourceValue[]; +} + +/** An iterator receiver or property reference store, with default guards. */ +export interface SemanticIterationSource extends IterationSourceValue { + readonly iterationSources?: readonly IterationSourceValue[]; + readonly kind: "iteration" | "reference-store"; + readonly mutation: t.Node; + readonly target?: t.MemberExpression; + readonly fallbackSources?: readonly IterationSourceValue[]; + readonly requiredSources?: readonly IterationSourceValue[]; +} diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts index a863e3d55e..70e8581ffa 100644 --- a/src/domain/javascript/javascriptSemanticIterationSources.ts +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -12,22 +12,15 @@ import { } from "./javascriptSemanticPropertyPaths.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { semanticCopiedReferencePath } from "./javascriptSemanticReferenceSources.js"; +import type { + IterationSourceValue, + SemanticIterationSource, +} from "./javascriptSemanticIterationFacts.js"; -interface IterationSourceValue { - readonly node: t.Node; - readonly projection: JavaScriptSemanticPropertyPath; - readonly iterationSources?: readonly IterationSourceValue[]; -} - -/** An iterator receiver or property reference store, with default guards. */ -export interface SemanticIterationSource extends IterationSourceValue { - readonly iterationSources?: readonly IterationSourceValue[]; - readonly kind: "iteration" | "reference-store"; - readonly mutation: t.Node; - readonly target?: t.MemberExpression; - readonly fallbackSources?: readonly IterationSourceValue[]; - readonly requiredSources?: readonly IterationSourceValue[]; -} +export type { + IterationSourceValue, + SemanticIterationSource, +} from "./javascriptSemanticIterationFacts.js"; /** A property value obtained by destructuring or an own-enumerable copy. */ export interface SemanticPropertyReadSource diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 01eb3b1383..2276d8a1ba 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -11,11 +11,13 @@ import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { semanticScopeId } from "./javascriptSemanticState.js"; -import type { - JavaScriptSemanticBindingState, - JavaScriptSemanticAnalysisState, - JavaScriptSemanticEffectTiming, +import { + resolveSemanticBindingState, + semanticResolutionBlocked, + semanticScopeId, + type JavaScriptSemanticBindingState, + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticEffectTiming, } from "./javascriptSemanticState.js"; interface StatementPosition { @@ -1207,3 +1209,100 @@ export const semanticEscapeFollowsCapture = ( positionPrecedes(capturePosition, escapePosition) ); }; + +/** Explicit wrapper facts and program `var` redeclarations retain their lexical identity. */ +export const commonJsWrapperBindingDeclaration = ( + node: t.Node, + name: string, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState | undefined => { + if ( + !["module", "exports", "require", "__dirname", "__filename"].includes( + name, + ) || + !t.isIdentifier(node, { name }) + ) + return undefined; + const binding = resolveSemanticBindingState(state, node, name); + if (binding === undefined) return undefined; + const scope = state.scopesById.get(binding.scopeId); + if ( + scope?.kind !== "program" || + ![...binding.declarationKinds].every((kind) => kind === "var") + ) + return undefined; + // The index owns explicit wrapper parameters, including ones with no source + // redeclaration. Babel's module classification does not invalidate that fact. + if (binding.hostOrigin === "commonjs-wrapper") return binding; + // Without an explicit host fact, preserve the legacy script-var inference. + return state.program.sourceType === "script" && + binding.declarationKinds.size > 0 + ? binding + : undefined; +}; + +/** Retained value and pattern stores share one source-ordered write boundary. */ +export const semanticBindingHasPriorValueWrite = ( + site: t.Node, + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (binding.definitions.some(({ kind }) => kind === "function")) return true; + const writes = state.bindingWrites.get(binding.bindingId) ?? []; + const read = semanticBindingAtRead(binding, site, state.parentsByNode); + const retained = + read.status === "ordered" + ? new Set([ + ...read.binding.initializers, + ...read.binding.referenceInitializers, + ]) + : undefined; + const representedWrites = new Set(); + const retainedWrites = new Set(); + for (const source of [ + ...binding.initializers, + ...binding.referenceInitializers, + ]) { + let owner: t.Node | undefined = source.node; + while (owner !== undefined && !writes.includes(owner)) + owner = state.parentsByNode.get(owner); + if (owner !== undefined) { + representedWrites.add(owner); + if (retained === undefined || retained.has(source)) + retainedWrites.add(owner); + } else if ( + (retained === undefined || retained.has(source)) && + semanticEffectTiming(site, source.node, state) !== "after" + ) + return true; + } + return writes.some( + (write) => + (!representedWrites.has(write) || retainedWrites.has(write)) && + semanticEffectTiming(site, write, state) !== "after", + ); +}; + +/** A program-level `var` redeclaration still denotes its untouched CJS wrapper parameter. */ +export const isUnmodifiedCommonJsWrapperBinding = ( + node: t.Node, + name: string, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const binding = commonJsWrapperBindingDeclaration(node, name, state); + return ( + binding !== undefined && + !semanticBindingHasPriorValueWrite(node, binding, state) + ); +}; + +/** Identify a global name only when no lexical or dynamic environment shadows it. */ +export const isUnshadowedGlobal = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, + name: string, +): boolean => + t.isIdentifier(node, { name }) && + (resolveSemanticBindingState(state, node, name) === undefined + ? !semanticResolutionBlocked(state, node, name) + : isUnmodifiedCommonJsWrapperBinding(node, name, state)); diff --git a/src/domain/javascript/javascriptSemanticObjectReference.ts b/src/domain/javascript/javascriptSemanticObjectReference.ts new file mode 100644 index 0000000000..b826081aee --- /dev/null +++ b/src/domain/javascript/javascriptSemanticObjectReference.ts @@ -0,0 +1,19 @@ +import type * as t from "@babel/types"; +import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; + +/** One selected object or class property, including prototype and copy facts. */ +export interface JavaScriptSemanticObjectReference { + readonly node: t.Node; + readonly path: PropertyPath; + /** This selection crossed the object's prototype, rather than an own slot. */ + readonly inherited?: true; + /** A shallow copy selects this prefix now while retaining its child identity. */ + readonly capturedSelectionAt?: t.Node; + readonly ownEnumerable?: true; + readonly iterableFallbackPath?: PropertyPath; + readonly escapes?: true; + /** A retry omitted suffixes, so escaping constructed children must cover them. */ + readonly widenedGetterResult?: true; + /** The allocation owned by this binding, rather than a read of its prior value. */ + readonly ownerInitializer?: t.Node; +} diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index a400e8f3dd..7cf109bf47 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -19,23 +19,11 @@ import { SemanticGetterQuery, type GetterQueryJob, } from "./javascriptSemanticGetterQueries.js"; -import { expandSemanticGetterResult } from "./javascriptSemanticGetterResults.js"; +import { expandSemanticGetterResult } from "./javascriptSemanticGetterResultWalker.js"; -export interface JavaScriptSemanticObjectReference { - readonly node: t.Node; - readonly path: PropertyPath; - /** This selection crossed the object's prototype, rather than an own slot. */ - readonly inherited?: true; - /** A shallow copy selects this prefix now while retaining its child identity. */ - readonly capturedSelectionAt?: t.Node; - readonly ownEnumerable?: true; - readonly iterableFallbackPath?: PropertyPath; - readonly escapes?: true; - /** A retry omitted suffixes, so escaping constructed children must cover them. */ - readonly widenedGetterResult?: true; - /** The allocation owned by this binding, rather than a read of its prior value. */ - readonly ownerInitializer?: t.Node; -} +import type { JavaScriptSemanticObjectReference } from "./javascriptSemanticObjectReference.js"; + +export type { JavaScriptSemanticObjectReference } from "./javascriptSemanticObjectReference.js"; export const semanticClassCandidates = ( root: t.Node, diff --git a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts index efdd17bbb7..85950f8e84 100644 --- a/src/domain/javascript/javascriptSemanticOpaqueEffects.ts +++ b/src/domain/javascript/javascriptSemanticOpaqueEffects.ts @@ -1,31 +1,20 @@ import * as t from "@babel/types"; import { javaScriptProgramStrictness } from "./javascriptSourceParser.js"; import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; -import { createAmbientPathResolver } from "./javascriptSemanticAmbientWrites.js"; -import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; import { + isUnshadowedGlobal, semanticEffectTiming, semanticBindingEffectFollowsCapture, semanticBindingEffectFollowsRead, semanticReadPrecedesEffectWithinActivation, } from "./javascriptSemanticMutationInitializers.js"; import { - isUnshadowedGlobal, resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -/** One source owns sites and the minimal scope tree reaching them; O(AST) storage. */ -export interface JavaScriptOpaqueScopeEffects { - /** Cheap source evidence collected with declarations, before alias queries. */ - mayReferenceGlobalEval: boolean; - readonly sites: WeakSet; - readonly sloppySites: WeakSet; - readonly byScope: Map; - readonly children: Map>; - readonly globalSites: (t.CallExpression | t.OptionalCallExpression)[]; -} +export type { JavaScriptOpaqueScopeEffects } from "./javascriptSemanticState.js"; export const indexSemanticOpaqueEffects = ( candidates: readonly { @@ -59,11 +48,30 @@ export const indexSemanticOpaqueEffects = ( * neither retain a second call list nor create another alias resolver. */ export const createSemanticOpaqueGlobalObserver = ( state: JavaScriptSemanticAnalysisState, + dependencies: { + createAmbientPathResolver: ( + state: JavaScriptSemanticAnalysisState, + observeGlobals: boolean, + ) => ( + node: t.Node, + site: t.Node | undefined, + ) => { paths: readonly (readonly (string | null)[])[] }; + createSemanticModuleInterop: ( + state: JavaScriptSemanticAnalysisState, + options?: { + intrinsicPath: (node: t.Node) => readonly string[] | undefined; + }, + ) => { + intrinsicInvocation: ( + node: t.CallExpression | t.OptionalCallExpression, + ) => { readonly path: readonly string[] } | undefined; + }; + }, ): ((node: t.Node) => void) | undefined => { if (!state.opaqueEffects.mayReferenceGlobalEval) return undefined; - const ambientPaths = createAmbientPathResolver(state, true); + const ambientPaths = dependencies.createAmbientPathResolver(state, true); let invocationSite: t.Node | undefined; - const interop = createSemanticModuleInterop(state, { + const interop = dependencies.createSemanticModuleInterop(state, { intrinsicPath: (node) => { const paths = ambientPaths(node, invocationSite).paths; const path = paths.find((path) => path[1] === "eval") ?? paths[0]; diff --git a/src/domain/javascript/javascriptSemanticPropertyKeys.ts b/src/domain/javascript/javascriptSemanticPropertyKeys.ts index 37751fa386..b33f61a43d 100644 --- a/src/domain/javascript/javascriptSemanticPropertyKeys.ts +++ b/src/domain/javascript/javascriptSemanticPropertyKeys.ts @@ -3,7 +3,7 @@ import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { isUnmodifiedGlobalReference } from "./javascriptSemanticGlobalReference.js"; import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; // These intrinsic properties hold symbols, so ToPropertyKey cannot produce a diff --git a/src/domain/javascript/javascriptSemanticReceiverFacts.ts b/src/domain/javascript/javascriptSemanticReceiverFacts.ts new file mode 100644 index 0000000000..84b6d12b71 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticReceiverFacts.ts @@ -0,0 +1,10 @@ +/** Lexical receiver identities that select static endpoint interpretations. */ +export type JavaScriptOpenReceiverFact = + | "window" + | "indexed-db" + | "cache-storage" + | "local" + | "local-indexed-db" + | "local-cache-storage" + | "configuration" + | "extension-state"; diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 5386568620..426caf95f3 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -14,13 +14,18 @@ import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; import { resolveSemanticBindingState, - isUnmodifiedCommonJsWrapperBinding, - commonJsWrapperBindingDeclaration, semanticResolutionBlocked, semanticStableInitializer, - semanticBindingHasPriorValueWrite, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; +import { + commonJsWrapperBindingDeclaration, + isUnmodifiedCommonJsWrapperBinding, + semanticBindingAtRead, + semanticBindingHasPriorValueWrite, + semanticEffectTiming, + semanticTopLevelEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; import { semanticStaticPropertyKey, semanticMemberChain, @@ -31,11 +36,6 @@ import { staticSourceRelativeUrlPath, stringValue, } from "./javascriptStaticAnalysisHelpers.js"; -import { - semanticEffectTiming, - semanticBindingAtRead, - semanticTopLevelEffectFollowsCapture, -} from "./javascriptSemanticMutationInitializers.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; import { semanticIterationSources } from "./javascriptSemanticIterationSources.js"; @@ -53,22 +53,20 @@ import { type SemanticAliasSelectionPath, } from "./javascriptSemanticAliasSelections.js"; import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; +import { + semanticRequireOriginReaders, + type JavaScriptRequireLoaderFact, + type JavaScriptRequireOrigin, +} from "./javascriptSemanticRequireOriginSlot.js"; import { isUnmodifiedGlobalReference, isUnmodifiedImportMetaUrl, } from "./javascriptSemanticAmbientWrites.js"; -export interface JavaScriptRequireOrigin { - readonly origin: JavaScriptModuleOrigin; - readonly call: t.CallExpression; -} - -export type JavaScriptRequireLoaderFact = - | "commonjs-loader" - | "factory-loader" - | "create-require" - | "local" - | "unknown"; +export type { + JavaScriptRequireLoaderFact, + JavaScriptRequireOrigin, +} from "./javascriptSemanticRequireOriginSlot.js"; const possibleCommonJsDelegateWriteStates = new WeakSet(); @@ -1661,3 +1659,6 @@ export const semanticMutableBuiltinOrigin = ( ? { specifier, path } : undefined; }; + +semanticRequireOriginReaders.origin = semanticRequireOrigin; +semanticRequireOriginReaders.loader = semanticRequireLoaderAt; diff --git a/src/domain/javascript/javascriptSemanticRequireOriginSlot.ts b/src/domain/javascript/javascriptSemanticRequireOriginSlot.ts new file mode 100644 index 0000000000..df53f15b56 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticRequireOriginSlot.ts @@ -0,0 +1,48 @@ +import type * as t from "@babel/types"; +import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; + +/** An unshadowed literal require and the member path selected from its result. */ +export interface JavaScriptRequireOrigin { + readonly origin: JavaScriptModuleOrigin; + readonly call: t.CallExpression; +} + +export type JavaScriptRequireLoaderFact = + | "commonjs-loader" + | "factory-loader" + | "create-require" + | "local" + | "unknown"; + +type RequireOriginReader = ( + node: t.Node | null | undefined, + state: JavaScriptSemanticAnalysisState, +) => JavaScriptRequireOrigin | undefined; + +type RequireLoaderReader = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + state: JavaScriptSemanticAnalysisState, + factoryBindingIds: ReadonlySet, +) => JavaScriptRequireLoaderFact; + +/** + * Require-origin queries are owned by javascriptSemanticRequireOrigin.ts, which + * also depends on evaluation. Lower layers read them through this slot so the + * import edge stays one way. The owner assigns both readers when it loads. + */ +export const semanticRequireOriginReaders: { + origin: RequireOriginReader | undefined; + loader: RequireLoaderReader | undefined; +} = { origin: undefined, loader: undefined }; + +export const readSemanticRequireOrigin: RequireOriginReader = (node, state) => + semanticRequireOriginReaders.origin?.(node, state); + +export const readSemanticRequireLoader: RequireLoaderReader = ( + node, + state, + factoryBindingIds, +) => + semanticRequireOriginReaders.loader?.(node, state, factoryBindingIds) ?? + "unknown"; diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 1c85fcb456..946ac31977 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -1,15 +1,10 @@ -import type { JavaScriptOpaqueScopeEffects } from "./javascriptSemanticOpaqueEffects.js"; -import type { SemanticIterationSource } from "./javascriptSemanticIterationSources.js"; import * as t from "@babel/types"; import { semanticStaticPropertyKey, staticJavaScriptTruthiness, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { - semanticBindingAtRead, - semanticEffectTiming, -} from "./javascriptSemanticMutationInitializers.js"; +import type { SemanticIterationSource } from "./javascriptSemanticIterationFacts.js"; import type { JavaScriptSemanticPropertyPath, @@ -121,6 +116,17 @@ export interface JavaScriptSemanticScopeState { readonly implicitArguments?: true; } +/** One source owns sites and the minimal scope tree reaching them; O(AST) storage. */ +export interface JavaScriptOpaqueScopeEffects { + /** Cheap source evidence collected with declarations, before alias queries. */ + mayReferenceGlobalEval: boolean; + readonly sites: WeakSet; + readonly sloppySites: WeakSet; + readonly byScope: Map; + readonly children: Map>; + readonly globalSites: (t.CallExpression | t.OptionalCallExpression)[]; +} + /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { arrayIterationUnknown: boolean; @@ -547,100 +553,3 @@ export const semanticVariableScope = ( } return candidate; }; - -/** Identify a global name only when no lexical or dynamic environment shadows it. */ -export const isUnshadowedGlobal = ( - node: t.Node, - state: JavaScriptSemanticAnalysisState, - name: string, -): boolean => - t.isIdentifier(node, { name }) && - (resolveSemanticBindingState(state, node, name) === undefined - ? !semanticResolutionBlocked(state, node, name) - : isUnmodifiedCommonJsWrapperBinding(node, name, state)); - -/** A program-level `var` redeclaration still denotes its untouched CJS wrapper parameter. */ -export const isUnmodifiedCommonJsWrapperBinding = ( - node: t.Node, - name: string, - state: JavaScriptSemanticAnalysisState, -): boolean => { - const binding = commonJsWrapperBindingDeclaration(node, name, state); - return ( - binding !== undefined && - !semanticBindingHasPriorValueWrite(node, binding, state) - ); -}; - -/** Retained value and pattern stores share one source-ordered write boundary. */ -export const semanticBindingHasPriorValueWrite = ( - site: t.Node, - binding: JavaScriptSemanticBindingState, - state: JavaScriptSemanticAnalysisState, -): boolean => { - if (binding.definitions.some(({ kind }) => kind === "function")) return true; - const writes = state.bindingWrites.get(binding.bindingId) ?? []; - const read = semanticBindingAtRead(binding, site, state.parentsByNode); - const retained = - read.status === "ordered" - ? new Set([ - ...read.binding.initializers, - ...read.binding.referenceInitializers, - ]) - : undefined; - const representedWrites = new Set(); - const retainedWrites = new Set(); - for (const source of [ - ...binding.initializers, - ...binding.referenceInitializers, - ]) { - let owner: t.Node | undefined = source.node; - while (owner !== undefined && !writes.includes(owner)) - owner = state.parentsByNode.get(owner); - if (owner !== undefined) { - representedWrites.add(owner); - if (retained === undefined || retained.has(source)) - retainedWrites.add(owner); - } else if ( - (retained === undefined || retained.has(source)) && - semanticEffectTiming(site, source.node, state) !== "after" - ) - return true; - } - return writes.some( - (write) => - (!representedWrites.has(write) || retainedWrites.has(write)) && - semanticEffectTiming(site, write, state) !== "after", - ); -}; - -/** Explicit wrapper facts and program `var` redeclarations retain their lexical identity. */ -export const commonJsWrapperBindingDeclaration = ( - node: t.Node, - name: string, - state: JavaScriptSemanticAnalysisState, -): JavaScriptSemanticBindingState | undefined => { - if ( - !["module", "exports", "require", "__dirname", "__filename"].includes( - name, - ) || - !t.isIdentifier(node, { name }) - ) - return undefined; - const binding = resolveSemanticBindingState(state, node, name); - if (binding === undefined) return undefined; - const scope = state.scopesById.get(binding.scopeId); - if ( - scope?.kind !== "program" || - ![...binding.declarationKinds].every((kind) => kind === "var") - ) - return undefined; - // The index owns explicit wrapper parameters, including ones with no source - // redeclaration. Babel's module classification does not invalidate that fact. - if (binding.hostOrigin === "commonjs-wrapper") return binding; - // Without an explicit host fact, preserve the legacy script-var inference. - return state.program.sourceType === "script" && - binding.declarationKinds.size > 0 - ? binding - : undefined; -}; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index e2fe0f63b0..11b48f9663 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -42,7 +42,7 @@ import { type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; +import { readSemanticRequireOrigin } from "./javascriptSemanticRequireOriginSlot.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { readExactJavaScriptLiteral, @@ -1277,7 +1277,7 @@ const provenanceForExpression = ( expressionDepth: context.expressionDepth + unwrapped.depth, }); } - const required = semanticRequireOrigin(node, context.state); + const required = readSemanticRequireOrigin(node, context.state); if (required !== undefined) return semanticOriginsProvenance([required.origin]); if (t.isSequenceExpression(node)) { diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index d732aa1921..f892d9a9b4 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -17,7 +17,7 @@ import { semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; +import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticReceiverFacts.js"; import { compareUnicodeCodePoints, compositeKey, diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 6a149cb3fc..b9498a413a 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -1,6 +1,6 @@ import type * as t from "@babel/types"; import type { JavaScriptBundlerFactoryRoles } from "./javascriptBundlerFactoryRoles.js"; -import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; +import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticReceiverFacts.js"; import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js"; import type { JavaScriptExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; diff --git a/tests/evaluation/importSpecifiers.test.ts b/tests/evaluation/importSpecifiers.test.ts new file mode 100644 index 0000000000..db7225d73b --- /dev/null +++ b/tests/evaluation/importSpecifiers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { + importSpecifier, + readImportSpecifiers, +} from "../../scripts/lib/import-specifiers.mjs"; + +describe("import specifier reader", () => { + it("reads side-effect imports and ignores import-like text", () => { + expect( + readImportSpecifiers(` + // from "./commented.js" + /* import "./block-comment.js"; */ + const text = "from './string.js'"; + import "./side-effect.js"; + import { value } from "./named.js"; + export { value as again } from "./reexported.js"; + const loaded = import("./dynamic.js"); + `).map(({ specifier }) => specifier), + ).toEqual([ + "./side-effect.js", + "./named.js", + "./reexported.js", + "./dynamic.js", + ]); + }); + + it("reads type, namespace, and empty-template specifiers", () => { + const facts = readImportSpecifiers(`import type { Fact } from "./types.js"; +export * from "./all.js"; +import name = require("./equals.js"); +type Query = import("./query.js").Query; +const dynamic = import(\`./template.js\`); +const computed = import(name); +import "node:fs"; +`); + expect(facts).toEqual([ + { specifier: "./types.js", line: 1 }, + { specifier: "./all.js", line: 2 }, + { specifier: "./equals.js", line: 3 }, + { specifier: "./query.js", line: 4 }, + { specifier: "./template.js", line: 5 }, + { specifier: "node:fs", line: 7 }, + ]); + }); + + it("ignores nodes that are not static imports", () => { + expect(importSpecifier({ type: "Identifier" })).toBeUndefined(); + }); +});