diff --git a/src/application/javascript/JavaScriptApplicationDeclarations.test.ts b/src/application/javascript/JavaScriptApplicationDeclarations.test.ts deleted file mode 100644 index 30a80bb625..0000000000 --- a/src/application/javascript/JavaScriptApplicationDeclarations.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { expect, it } from "vitest"; - -import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; -import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; -import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; - -it.each(["types.d.ts", "types.d.mts", "types.d.cts"])( - "keeps valid ambient declarations complete in %s", - async (path) => { - const root = await createTestTempDirectory("rea-declaration-file-"); - const source = "export const value: number;\n"; - await writeFile(join(root, path), source); - const result = await analyzeJavaScriptApplication({ - input_path: root, - format: "directory", - }); - if (!result.ok) throw result.error; - const analysis = javascriptApplicationAnalysisResultSchema.parse( - result.value.normalized_result, - ); - expect(analysis.graph.coverage.status).toBe("complete"); - expect(parseJavaScriptSource(source, path)?.errors).toEqual([]); - }, -); - -it("retains the missing-initializer diagnostic for ordinary TypeScript", () => { - const errors = parseJavaScriptSource( - "export const value: number;", - "types.ts", - )?.errors; - expect(errors?.map(({ message }) => message)).toContain( - "Missing initializer in const declaration. (1:26)", - ); -}); - -it("keeps CommonJS TypeScript angle-bracket assertions complete", async () => { - const root = await createTestTempDirectory("rea-cts-assertion-"); - const source = "export const value = 42;\n"; - await writeFile(join(root, "app.cts"), source); - const result = await analyzeJavaScriptApplication({ - input_path: root, - format: "directory", - }); - if (!result.ok) throw result.error; - const analysis = javascriptApplicationAnalysisResultSchema.parse( - result.value.normalized_result, - ); - expect(analysis.graph.coverage.status).toBe("complete"); - expect(parseJavaScriptSource(source, "app.cts")?.errors).toEqual([]); -}); diff --git a/src/application/javascript/JavaScriptApplicationSatisfies.test.ts b/src/application/javascript/JavaScriptApplicationSatisfies.test.ts deleted file mode 100644 index 8507180f18..0000000000 --- a/src/application/javascript/JavaScriptApplicationSatisfies.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { expect, it } from "vitest"; - -import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; -import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; -import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; -import { topLevelBinding } from "../../domain/javascript/javascriptSemanticAnalysis.fixture.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; - -it.each([ - '"ready" satisfies string', - '("ready" satisfies string) as string', - '("ready" satisfies string)!', -])("retains the runtime primitive through %s", (expression) => { - const ir = analyzeJavaScriptSemantics(`const value = ${expression};`); - expect(topLevelBinding(ir, "value").value).toEqual({ - status: "literal", - value: "ready", - }); -}); - -it("retains exported constant evidence through a type-only satisfies expression", async () => { - const root = await createTestTempDirectory("rea-ts-satisfies-"); - await writeFile( - join(root, "app.ts"), - 'export const value = "ready" satisfies string;\n', - ); - const result = await analyzeJavaScriptApplication({ - input_path: root, - format: "directory", - }); - if (!result.ok) throw result.error; - const analysis = javascriptApplicationAnalysisResultSchema.parse( - result.value.normalized_result, - ); - expect( - analysis.semantic_graph.nodes.filter( - ({ kind, properties }) => - kind === "literal" && properties.value === "ready", - ), - ).toHaveLength(1); -}); - -it("retains local call targets and exported callable links through satisfies", () => { - const ir = analyzeJavaScriptSemantics( - 'export const render = (() => "ready") satisfies (() => string); render();', - ); - const callable = ir.callables[0]; - if (callable === undefined) throw new Error("Missing callable"); - expect( - ir.moduleLinks.find(({ localName }) => localName === "render")?.callableId, - ).toBe(callable.callableId); - expect(ir.callSites[0]?.calleeCallableIds).toEqual([callable.callableId]); - expect(ir.callSites[0]?.resolution).toBe("exact"); -}); diff --git a/src/application/javascript/JavaScriptApplicationTypeScript.test.ts b/src/application/javascript/JavaScriptApplicationTypeScript.test.ts new file mode 100644 index 0000000000..6d59578e73 --- /dev/null +++ b/src/application/javascript/JavaScriptApplicationTypeScript.test.ts @@ -0,0 +1,144 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { canonicalJson } from "../../domain/comparisonSemantics.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; +import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; +import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; + +type ApplicationAnalysis = ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse +>; + +const analyzeFiles = async ( + files: ReadonlyMap, +): Promise => { + const root = await createTestTempDirectory("rea-typescript-application-"); + await Promise.all( + [...files].map(([path, source]) => writeFile(join(root, path), source)), + ); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + const analysis = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + return javascriptApplicationAnalysisResultSchema.parse( + JSON.parse(canonicalJson(analysis, "TypeScript application analysis")), + ); +}; + +it.each(["types.d.ts", "types.d.mts", "types.d.cts"])( + "keeps valid ambient declarations complete in %s", + async (path) => { + const source = "export const value: number;\n"; + const analysis = await analyzeFiles(new Map([[path, source]])); + + expect(analysis.graph.coverage.status).toBe("complete"); + expect(parseJavaScriptSource(source, path)?.errors).toEqual([]); + }, +); + +it("retains the missing-initializer diagnostic for ordinary TypeScript", () => { + const errors = parseJavaScriptSource( + "export const value: number;", + "types.ts", + )?.errors; + expect(errors?.map(({ message }) => message)).toContain( + "Missing initializer in const declaration. (1:26)", + ); +}); + +it("keeps CommonJS TypeScript angle-bracket assertions complete", async () => { + const source = "export const value = 42;\n"; + const analysis = await analyzeFiles(new Map([["app.cts", source]])); + + expect(analysis.graph.coverage.status).toBe("complete"); + expect(parseJavaScriptSource(source, "app.cts")?.errors).toEqual([]); +}); + +it("retains exported constant evidence through a type-only satisfies expression", async () => { + const analysis = await analyzeFiles( + new Map([["app.ts", 'export const value = "ready" satisfies string;\n']]), + ); + + expect( + analysis.semantic_graph.nodes.filter( + ({ kind, properties }) => + kind === "literal" && properties.value === "ready", + ), + ).toHaveLength(1); +}); + +it("resolves erased wrappers through the composed application graph", async () => { + const source = ` + const dependency = require("./dependency.ts")["value" as const] satisfies number; + export { dependency }; + function render() { return "ready"; } + export const specialized = render satisfies () => string; + specialized(); + `; + const analysis = await analyzeFiles( + new Map([ + ["app.mts", source], + ["dependency.ts", "export const value = 1;\n"], + ]), + ); + + expect(analysis.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: "./dependency.ts", + imported_path: ["value"], + resolved_path: "dependency.ts", + resolution_status: "resolved", + }), + }), + ); + expect(analysis.semantic_graph.relations).toContainEqual( + expect.objectContaining({ relation: "calls", resolution: "resolved" }), + ); + expect( + analysis.graph.nodes.flatMap(({ observations }) => observations), + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + semantic_role: "export-return-shapes", + exported_name: "specialized", + static_return_shapes: expect.arrayContaining([ + expect.objectContaining({ + value_status: "literal", + fields: expect.arrayContaining([ + expect.objectContaining({ path: "", value: "ready" }), + ]), + }), + ]), + }), + }), + ); + expect(parseJavaScriptSource(source, "app.mts")?.errors).toEqual([]); +}); + +it("applies TypeScript .mts ambiguity rules while retaining unambiguous generics", () => { + expect( + parseJavaScriptSource( + "export const identity = (value: T) => value;", + "app.mts", + )?.errors, + ).toEqual([]); + expect( + parseJavaScriptSource( + "export const identity = (value: T) => value;", + "app.mts", + )?.errors.map(({ reasonCode }) => reasonCode), + ).toContain("ReservedArrowTypeParam"); + expect(parseJavaScriptSource("const value = ;", "app.mts")).toBe( + null, + ); +}); diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index b7f8d0efc0..f0a0528c06 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -1,12 +1,38 @@ import * as t from "@babel/types"; import type { JavaScriptSemanticPrimitive } from "./javascriptSemanticValueTypes.js"; +/** + * Remove wrappers that affect TypeScript or Flow checking but not runtime + * expression identity. The returned depth lets bounded evaluators account for + * syntax that normalization would otherwise skip. + */ +export const unwrapJavaScriptExpression = ( + node: t.Node, +): { readonly node: t.Node; readonly depth: number } => { + let current = node; + let depth = 0; + while ( + t.isParenthesizedExpression(current) || + t.isTSAsExpression(current) || + t.isTSTypeAssertion(current) || + t.isTSNonNullExpression(current) || + t.isTSSatisfiesExpression(current) || + t.isTSInstantiationExpression(current) || + t.isTypeCastExpression(current) + ) { + current = current.expression; + depth += 1; + } + return { node: current, depth }; +}; + /** Read one syntax-level primitive only when its value is exact. */ export const readExactJavaScriptLiteral = ( node: t.Node, ): | { readonly found: true; readonly value: JavaScriptSemanticPrimitive } | { readonly found: false } => { + node = unwrapJavaScriptExpression(node).node; if (t.isStringLiteral(node)) return { found: true, value: node.value }; if (t.isNumericLiteral(node) && Number.isFinite(node.value)) return { found: true, value: node.value }; @@ -44,6 +70,7 @@ export const semanticStaticPropertyKey = ( property: t.Node, computed: boolean, ): string | null => { + property = unwrapJavaScriptExpression(property).node; if (t.isStringLiteral(property) || t.isNumericLiteral(property)) return String(property.value); return !computed && t.isIdentifier(property) ? property.name : null; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts index 24bbb17071..9e02cdb00b 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts @@ -390,6 +390,26 @@ describe("semantic expression depth bounds", () => { }); }); + it("counts erased TypeScript wrappers against expression depth", () => { + const parsed = parseJavaScriptSource("const answer = true;"); + const declaration = parsed?.program.body[0]; + const declarator = t.isVariableDeclaration(declaration) + ? declaration.declarations[0] + : undefined; + if (parsed === null || !t.isVariableDeclarator(declarator)) + throw new Error("Expected parsed binding initializer"); + let expression: t.Expression = t.booleanLiteral(true); + for (let index = 0; index < 300; index += 1) + expression = t.tsAsExpression(expression, t.tsStringKeyword()); + declarator.init = expression; + + const ir = analyzeParsedJavaScriptSemantics(parsed); + expect(topLevelBinding(ir, "answer").value).toMatchObject({ + status: "unknown", + resourceLimit: "expression-depth", + }); + }); + it("preserves exact shallow addition alternatives", () => { expect(valueOf('(choice ? "a" : "b") + (other ? "1" : "2")')).toEqual({ status: "union", diff --git a/src/domain/javascript/javascriptSemanticCallResolution.ts b/src/domain/javascript/javascriptSemanticCallResolution.ts index 24673279af..08453a6514 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.ts @@ -6,6 +6,7 @@ import type { } from "./javascriptSemanticIr.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, @@ -51,6 +52,8 @@ const resolveCallables = ( node: t.Node, context: CallResolutionContext, ): LocalCallableResolution => { + const unwrapped = unwrapJavaScriptExpression(node).node; + if (unwrapped !== node) return resolveCallables(unwrapped, context); const direct = semanticCallableIdForNode(node); if (direct !== null && context.callableById.has(direct)) return { @@ -59,13 +62,6 @@ const resolveCallables = ( reason: "Direct local callable.", }; if (t.isIdentifier(node)) return resolveIdentifier(node, context); - if ( - t.isTSAsExpression(node) || - t.isTSTypeAssertion(node) || - t.isTSNonNullExpression(node) || - t.isTSSatisfiesExpression(node) - ) - return resolveCallables(node.expression, context); if (t.isConditionalExpression(node) || t.isLogicalExpression(node)) return resolveAlternatives( t.isConditionalExpression(node) ? node.consequent : node.left, diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index f98b791dca..5485fcde9f 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -14,6 +14,7 @@ import { readExactJavaScriptLiteral, semanticStaticPropertyKey, semanticStaticPropertyName, + unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; export interface SemanticProjectedBinding { @@ -56,15 +57,11 @@ const assignedSourceIs = (node: t.Node, source: t.Node): boolean => { let value = source; while (true) { if (t.isAwaitExpression(value)) value = value.argument; - else if ( - t.isParenthesizedExpression(value) || - t.isTSAsExpression(value) || - t.isTSTypeAssertion(value) || - t.isTypeCastExpression(value) || - t.isTSSatisfiesExpression(value) - ) - value = value.expression; - else break; + else { + const unwrapped = unwrapJavaScriptExpression(value).node; + if (unwrapped === value) break; + value = unwrapped; + } } return value === node; }; diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 83afd13d7d..445cd2e5ee 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -12,6 +12,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { semanticStaticPropertyKey, semanticStaticPropertyName, + unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, @@ -178,16 +179,7 @@ const collectDefaults = ( (node.operator !== "??" && node.operator !== "||") ) return; - let source = node.left; - while ( - t.isParenthesizedExpression(source) || - t.isTSAsExpression(source) || - t.isTSTypeAssertion(source) || - t.isTypeCastExpression(source) || - t.isTSSatisfiesExpression(source) || - t.isTSNonNullExpression(source) - ) - source = source.expression; + const source = unwrapJavaScriptExpression(node.left).node; const candidate = configurationsByNode.get(source); if (candidate === undefined) return; output.push({ diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 622d9f630a..0d3a00f222 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -7,7 +7,10 @@ import { } from "./javascriptSemanticState.js"; import { evaluateSemanticBinding } from "./javascriptSemanticValues.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; -import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; type PropertyPath = readonly (string | number | null)[]; @@ -28,6 +31,11 @@ export const collectSemanticMemberMutations = ( bindings: ReadonlySet, mutation: t.Node, ): void => { + const unwrapped = unwrapJavaScriptExpression(node).node; + if (unwrapped !== node) { + markValue(unwrapped, path, bindings, mutation); + return; + } if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); if (binding === undefined || bindings.has(binding.bindingId)) return; @@ -224,13 +232,6 @@ const referencedValues = ( : []; }); } - if ( - t.isTSAsExpression(node) || - t.isTSTypeAssertion(node) || - t.isTSSatisfiesExpression(node) || - t.isTSNonNullExpression(node) - ) - return [{ node: node.expression, path }]; if (t.isConditionalExpression(node)) return [ { node: node.consequent, path }, diff --git a/src/domain/javascript/javascriptSemanticObjects.ts b/src/domain/javascript/javascriptSemanticObjects.ts index a7872efb02..27f5d73b3c 100644 --- a/src/domain/javascript/javascriptSemanticObjects.ts +++ b/src/domain/javascript/javascriptSemanticObjects.ts @@ -8,7 +8,10 @@ import { semanticCallableIdForNode, semanticReadsBeforeWrite, } from "./javascriptSemanticProjection.js"; -import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, @@ -205,6 +208,7 @@ const expressionObjectIdentity = ( let current = node; let staticPath = true; while (current !== null && current !== undefined) { + current = unwrapJavaScriptExpression(current).node; if ( t.isMemberExpression(current) || t.isOptionalMemberExpression(current) @@ -216,14 +220,7 @@ const expressionObjectIdentity = ( if (name === null) staticPath = false; else path.push(name); current = current.object; - } else if ( - t.isTSAsExpression(current) || - t.isTSTypeAssertion(current) || - t.isTSNonNullExpression(current) || - t.isTSSatisfiesExpression(current) - ) - current = current.expression; - else break; + } else break; } return { bindingId: t.isIdentifier(current) diff --git a/src/domain/javascript/javascriptSemanticPromises.ts b/src/domain/javascript/javascriptSemanticPromises.ts index d956d68f7c..524d3d0ff3 100644 --- a/src/domain/javascript/javascriptSemanticPromises.ts +++ b/src/domain/javascript/javascriptSemanticPromises.ts @@ -5,7 +5,10 @@ import type { JavaScriptSemanticPromiseOperation, } from "./javascriptSemanticIr.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; -import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import { resolveSemanticBindingState, semanticResolutionBlocked, @@ -173,7 +176,9 @@ const isUnshadowedGlobalPromise = ( const isPromiseProducer = ( node: t.Node, state: JavaScriptSemanticAnalysisState, -): boolean => promiseCandidateDetails(unwrapExpression(node), state) !== null; +): boolean => + promiseCandidateDetails(unwrapJavaScriptExpression(node).node, state) !== + null; const semanticPromiseId = (kind: PromiseKind, node: t.Node): string => `promise:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`; @@ -207,7 +212,7 @@ const ownershipAtAncestor = ( ): PromiseOwnership | "boundary" | null => { const { candidate, candidateByNode, state, returnSiteIdFor } = context; if (t.isAwaitExpression(ancestor)) - return unwrapExpression(ancestor.argument) === candidate.node + return unwrapJavaScriptExpression(ancestor.argument).node === candidate.node ? emptyOwnership("awaited") : "boundary"; const outer = candidateByNode.get(ancestor); @@ -230,7 +235,7 @@ const ownershipAtAncestor = ( if (t.isReturnStatement(ancestor)) { if ( ancestor.argument == null || - unwrapExpression(ancestor.argument) !== candidate.node + unwrapJavaScriptExpression(ancestor.argument).node !== candidate.node ) return "boundary"; return returnedPromiseOwnership( @@ -240,14 +245,15 @@ const ownershipAtAncestor = ( ); } if (t.isExpressionStatement(ancestor)) - return unwrapExpression(ancestor.expression) === candidate.node + return unwrapJavaScriptExpression(ancestor.expression).node === + candidate.node ? emptyOwnership("detached") : "boundary"; const callableId = semanticCallableIdForNode(ancestor); if (callableId === null || callableId !== candidate.ownerCallableId) return null; return t.isArrowFunctionExpression(ancestor) && - unwrapExpression(ancestor.body) === candidate.node + unwrapJavaScriptExpression(ancestor.body).node === candidate.node ? returnedPromiseOwnership( candidate.ownerCallableId, range(ancestor.body), @@ -276,7 +282,7 @@ const assignedPromiseBinding = ( ? ancestor.init : ancestor.right; if (source == null) return undefined; - if (unwrapExpression(source) !== candidate) return undefined; + if (unwrapJavaScriptExpression(source).node !== candidate) return undefined; return scalarAssignedResultBinding( assignedSemanticResultBindings(source, [ancestor], state), ); @@ -302,7 +308,8 @@ const outerConsumesCandidate = ( ) { const member = dataEffectMemberCallee(outer.node); return ( - member !== null && unwrapExpression(member.object) === candidate.node + member !== null && + unwrapJavaScriptExpression(member.object).node === candidate.node ); } if ( @@ -316,7 +323,7 @@ const outerConsumesCandidate = ( (element) => element !== null && !t.isSpreadElement(element) && - unwrapExpression(element) === candidate.node, + unwrapJavaScriptExpression(element).node === candidate.node, ) ); } @@ -441,7 +448,7 @@ const resolvePromiseExpression = ( context: PromiseExpressionResolutionContext, ): PromiseResolution => { const { current, candidateByNode, state, seenBindings } = context; - const node = unwrapExpression(rawNode); + const node = unwrapJavaScriptExpression(rawNode).node; const direct = candidateByNode.get(node); if (direct !== undefined && direct.promiseId !== current.promiseId) return { promiseIds: [direct.promiseId], status: "complete" }; @@ -476,18 +483,6 @@ const promiseResolutionContext = ( seenBindings: new Set(), }); -const unwrapExpression = (node: t.Node): t.Node => { - if ( - t.isParenthesizedExpression(node) || - t.isTSAsExpression(node) || - t.isTSSatisfiesExpression(node) || - t.isTSNonNullExpression(node) || - t.isTypeCastExpression(node) - ) - return unwrapExpression(node.expression); - return node; -}; - const combinePromiseResolutions = ( resolutions: readonly PromiseResolution[], ): PromiseResolution => ({ diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index e0bf540460..b5159afc89 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -6,7 +6,10 @@ import { semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; -import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; /** Recover an unshadowed literal require origin and its exact member path. */ @@ -15,23 +18,34 @@ export const semanticRequireOrigin = ( state: JavaScriptSemanticAnalysisState, ): JavaScriptModuleOrigin | undefined => { const members: string[] = []; - while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { - const member = semanticStaticPropertyKey(node.property, node.computed); - if (member === null || !t.isNode(node.object)) return undefined; + let current = node; + while (current !== null && current !== undefined) { + current = unwrapJavaScriptExpression(current).node; + if ( + !t.isMemberExpression(current) && + !t.isOptionalMemberExpression(current) + ) + break; + const member = semanticStaticPropertyKey( + current.property, + current.computed, + ); + if (member === null || !t.isNode(current.object)) return undefined; members.push(member); - node = node.object; + current = current.object; } if ( - !t.isCallExpression(node) || - !t.isIdentifier(node.callee, { name: "require" }) + !t.isCallExpression(current) || + !t.isIdentifier(current.callee, { name: "require" }) ) return undefined; if ( - resolveSemanticBindingState(state, node.callee, "require") !== undefined || - semanticResolutionBlocked(state, node.callee, "require") + resolveSemanticBindingState(state, current.callee, "require") !== + undefined || + semanticResolutionBlocked(state, current.callee, "require") ) return undefined; - const specifier = stringValue(node.arguments[0]); + const specifier = stringValue(current.arguments[0]); return specifier === undefined ? undefined : { specifier, importedPath: members.reverse() }; diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index 13e68279a1..95228ea3e5 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -17,6 +17,7 @@ import type { } from "./javascriptExportShapeComparisonSchemas.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, @@ -177,6 +178,9 @@ const callableIdsForNode = ( admitted: ReadonlySet, seen: ReadonlySet, ): string[] => { + const unwrapped = unwrapJavaScriptExpression(node).node; + if (unwrapped !== node) + return callableIdsForNode(unwrapped, state, admitted, seen); const direct = semanticCallableIdForNode(node); if (direct !== null && admitted.has(direct)) return [direct]; if (t.isIdentifier(node)) { @@ -185,13 +189,6 @@ const callableIdsForNode = ( ? [] : callableIdsForBinding(binding, state, admitted, seen); } - if ( - t.isTSAsExpression(node) || - t.isTSTypeAssertion(node) || - t.isTSNonNullExpression(node) || - t.isTSSatisfiesExpression(node) - ) - return callableIdsForNode(node.expression, state, admitted, seen); return []; }; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index d0eb007b5b..39380a9c00 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -20,6 +20,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { readExactJavaScriptLiteral, semanticStaticPropertyKey, + unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { semanticAmbiguousProvenance, @@ -170,6 +171,18 @@ const evaluateExpression = ( ): JavaScriptSemanticValue => { if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) return semanticResourceLimitUnknown("expression-depth"); + const unwrapped = unwrapJavaScriptExpression(node); + if (unwrapped.depth > 0) { + if ( + context.expressionDepth + unwrapped.depth > + SEMANTIC_EXPRESSION_DEPTH_LIMIT + ) + return semanticResourceLimitUnknown("expression-depth"); + return evaluateExpression(unwrapped.node, { + ...context, + expressionDepth: context.expressionDepth + unwrapped.depth, + }); + } const literal = readExactJavaScriptLiteral(node); if (literal.found) return typeof literal.value === "number" @@ -199,14 +212,6 @@ const evaluateExpression = ( if (t.isBinaryExpression(node, { operator: "+" })) return evaluateAddition(node, context); if (t.isUnaryExpression(node)) return evaluateUnary(node, context); - if ( - (t.isTSAsExpression(node) || - t.isTSTypeAssertion(node) || - t.isTSNonNullExpression(node) || - t.isTSSatisfiesExpression(node)) && - t.isExpression(node.expression) - ) - return evaluateExpression(node.expression, nestedContext(context)); return { status: "unknown", reason: `Unsupported ${node.type} value.` }; }; @@ -531,6 +536,21 @@ const provenanceForExpression = ( "unknown", semanticResourceLimitReason("expression-depth"), ); + const unwrapped = unwrapJavaScriptExpression(node); + if (unwrapped.depth > 0) { + if ( + context.expressionDepth + unwrapped.depth > + SEMANTIC_EXPRESSION_DEPTH_LIMIT + ) + return semanticUnresolvedProvenance( + "unknown", + semanticResourceLimitReason("expression-depth"), + ); + return provenanceForExpression(unwrapped.node, { + ...context, + expressionDepth: context.expressionDepth + unwrapped.depth, + }); + } const required = semanticRequireOrigin(node, context.state); if (required !== undefined) return semanticOriginsProvenance([required]); if (t.isIdentifier(node)) { diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index 86d030bc3e..e413e0cb4c 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -8,9 +8,14 @@ export const parseJavaScriptSource = ( source: string, sourcePath?: string, ): ParsedJavaScriptSource | null => { + const path = sourcePath ?? ""; + const isMts = /\.mts$/iu.test(path); const typescript: ParserPlugin = [ "typescript", - { dts: /\.d\.(?:ts|mts|cts)$/iu.test(sourcePath ?? "") }, + { + dts: /\.d\.(?:ts|mts|cts)$/iu.test(path), + ...(isMts ? { disallowAmbiguousJSXLike: true } : {}), + }, ]; try { return parse(source, { @@ -21,9 +26,10 @@ export const parseJavaScriptSource = ( // forms, including parameter decorators. Without it a decorated // TypeScript source fails to parse at all rather than reporting // recovered syntax, which loses every fact derived from that source. + // TypeScript's .mts mode disables JSX and rejects ambiguous angle syntax. // Plain .ts and .cts artifacts admit angle-bracket type assertions instead of JSX. // Unknown paths retain the existing JSX-capable parser contract. - plugins: /\.(?:ts|cts)$/iu.test(sourcePath ?? "") + plugins: /\.(?:ts|cts|mts)$/iu.test(path) ? ["decorators-legacy", typescript] : ["decorators-legacy", "jsx", typescript], }); diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index ed1cd8e21d..25cd0fcbcb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -14,6 +14,7 @@ import type { import { readExactJavaScriptLiteral, semanticStaticPropertyName, + unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; @@ -100,8 +101,8 @@ const findChunkRuntime = (node: t.Node): string | undefined => { pending.push(current.right, current.left); continue; } - if (t.isParenthesizedExpression(current) || t.isTSAsExpression(current)) - pending.push(current.expression); + const unwrapped = unwrapJavaScriptExpression(current).node; + if (unwrapped !== current) pending.push(unwrapped); } return undefined; }; diff --git a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts index 075597a703..4c585786c2 100644 --- a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts @@ -67,3 +67,15 @@ it("keeps module references attributed across many real factory ranges", () => { )?.module_key, ).toBe("module-0"); }); + +it("retains the chunk runtime through a TypeScript-only expression wrapper", () => { + const analysis = analyzeJavaScriptStaticSource( + '(globalThis.webpackChunkApp satisfies unknown).push([[1], { "entry": function () {} }]);', + ); + expect(analysis.bundler_registrations).toEqual([ + expect.objectContaining({ + runtime: "webpackChunkApp", + modules: [expect.objectContaining({ module_key: "entry" })], + }), + ]); +});