From 16f4609d689445c05a0befc744fce7e72f7047ec Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:20:48 +0800 Subject: [PATCH] fix(javascript): bound semantic expansion and resolve lexical receivers --- docs/javascript-artifact-reconstruction.md | 7 + .../javascript/JavaScriptArtifactAnalysis.ts | 12 +- .../JavaScriptArtifactGraphBuilder.ts | 41 ++++++ .../JavaScriptModuleRelationships.ts | 32 ++++- .../JavaScriptOpenClassification.test.ts | 51 +++++++ .../JavaScriptSemanticGraphBuilder.test.ts | 78 ++++++++++- .../JavaScriptSemanticGraphValueProjection.ts | 26 ++++ .../electronStaticAnalysisValues.ts | 12 +- src/domain/javascript/javascriptAstValues.ts | 25 ++++ .../javascript/javascriptSemanticAnalysis.ts | 129 +++++++++++++++++- .../javascriptSemanticAnalysis.unions.test.ts | 127 ++++++++++++++++- .../javascript/javascriptSemanticCoverage.ts | 27 +++- .../javascriptSemanticDataEffectHelpers.ts | 11 +- .../javascriptSemanticGraphSchemas.ts | 1 + src/domain/javascript/javascriptSemanticIr.ts | 1 + .../javascriptSemanticPrimitives.ts | 43 +++--- .../javascriptSemanticResourceLimits.ts | 47 +++++++ .../javascriptSemanticValueTypes.ts | 6 + .../javascript/javascriptSemanticValues.ts | 67 +++++++-- .../javascript/javascriptStaticAnalysis.ts | 20 ++- .../javascriptStaticAnalysisCalls.ts | 37 +++-- .../javascriptStaticAnalysisHelpers.ts | 31 ++++- .../javascriptStaticAnalysisState.ts | 9 ++ src/domain/webBundleAnalyzer.test.ts | 41 ++++++ src/domain/webBundleAnalyzerInspection.ts | 29 +++- .../javascriptArtifactReconstruction.test.ts | 36 +++++ 26 files changed, 868 insertions(+), 78 deletions(-) create mode 100644 src/application/javascript/JavaScriptOpenClassification.test.ts create mode 100644 src/domain/javascript/javascriptSemanticResourceLimits.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 7f2ab5e5db..8d4bfeb76f 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -224,6 +224,13 @@ These safeguards are not caller-selectable output budgets. Source-map parsing reports truncation when its format or parser safety boundary is reached. The application graph has no aggregate node, edge, root, or observation prefix cap. +Semantic evaluation retains at most 256 distinct primitive candidates per +expression and traverses at most 256 expression levels. It checks union products +before allocating combinations. When either boundary is reached, the value stays +unknown, semantic coverage becomes partial, and graph evidence retains the +expression location and limiting reason. The number of unresolved alternatives +is unknown; it is not reported as an exact omission count. + Byte, entry, path, and graph-shape bounds are hard limits. The parse deadline is checked before and between bounded parsing and traversal phases; the synchronous Babel and JSON parser calls cannot be preempted mid-call, so their input byte diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index d7af23bba0..cb5eb7149a 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -2,7 +2,10 @@ import { createHash } from "node:crypto"; import { setImmediate } from "node:timers/promises"; import { analyzeParsedJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; -import { analyzeParsedJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { + analyzeParsedJavaScriptSemantics, + classifyParsedJavaScriptOpenReceivers, +} from "../../domain/javascript/javascriptSemanticAnalysis.js"; import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js"; @@ -170,7 +173,12 @@ const analyzeArtifactFile = ( state.parseFailures += 1; return; } - const analysis = analyzeParsedJavaScriptStaticSource(file.text.value, parsed); + const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(parsed); + const analysis = analyzeParsedJavaScriptStaticSource( + file.text.value, + parsed, + openReceiverFacts, + ); const staticFindings = findingCount(analysis); const semantics = analysis.parse_status === "complete" || analysis.parse_status === "partial" diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index e910d9cc51..54bd4f4690 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -20,6 +20,13 @@ import { completeApplicationCoverage, partialApplicationCoverage, } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; +import type { JavaScriptSemanticResourceLimit } from "../../domain/javascript/javascriptSemanticValueTypes.js"; +import { semanticCoverageResourceLimits } from "../../domain/javascript/javascriptSemanticCoverage.js"; +import { + SEMANTIC_EXPRESSION_DEPTH_LIMIT, + SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, + semanticResourceLimitReason, +} from "../../domain/javascript/javascriptSemanticResourceLimits.js"; import { addJavaScriptArtifactContainers, addJavaScriptArtifactFiles, @@ -80,6 +87,7 @@ export const buildJavaScriptArtifactGraph = ( }; const graphCoverage = (context: JavaScriptArtifactGraphContext) => { + const resourceLimits = semanticResourceLimits(context); const sourceMapPolicyGap = context.analysis.source_maps.some( ({ status }) => status === "invalid", ); @@ -99,10 +107,42 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => { partialJavaScript; if (context.analysis.truncated_scopes > 0) return partialApplicationCoverage([], null); + if (resourceLimits.length > 0) + return partialApplicationCoverage( + resourceLimits.map(applicationGraphResourceLimit), + null, + ); if (unknownGap) return partialApplicationCoverage([], null); return completeApplicationCoverage(); }; +const semanticResourceLimits = ( + context: JavaScriptArtifactGraphContext, +): JavaScriptSemanticResourceLimit[] => + [ + ...new Set( + context.analysis.files.flatMap(({ semantic }) => + semantic === null + ? [] + : semanticCoverageResourceLimits(semantic.ir.coverage), + ), + ), + ].sort(); + +const applicationGraphResourceLimit = ( + resourceLimit: JavaScriptSemanticResourceLimit, +) => ({ + name: `javascript_semantic_${resourceLimit.replaceAll("-", "_")}`, + value: + resourceLimit === "primitive-candidates" + ? SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT + : SEMANTIC_EXPRESSION_DEPTH_LIMIT, + unit: + resourceLimit === "expression-depth" + ? ("depth" as const) + : ("items" as const), +}); + const graphLimitations = ( context: JavaScriptArtifactGraphContext, coverage: "complete" | "partial" | "unknown" | "unavailable", @@ -150,6 +190,7 @@ const graphLimitations = ( "Sender, frame, URL, and origin checks are observations only; REA does not claim that they enforce a complete authorization policy.", ] : []), + ...semanticResourceLimits(context).map(semanticResourceLimitReason), ...(context.analysis.files.some( ({ javascript }) => (javascript?.electron.native_addon_bindings.length ?? 0) > 0, diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index e87d165bdd..25849528ee 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -6,6 +6,12 @@ import { completeApplicationCoverage, partialApplicationCoverage, } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; +import { semanticCoverageResourceLimits } from "../../domain/javascript/javascriptSemanticCoverage.js"; +import { + SEMANTIC_EXPRESSION_DEPTH_LIMIT, + SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, + semanticResourceLimitReason, +} from "../../domain/javascript/javascriptSemanticResourceLimits.js"; import type { JavaScriptModuleOrigin, JavaScriptSemanticModuleLink, @@ -362,15 +368,35 @@ const relationshipEvidence = ( operation, coverage: semanticCoverage(input.semantic), confidence, - limitations: [...input.semantic.ir.limitations, ...limitations], + limitations: [ + ...input.semantic.ir.limitations, + ...semanticCoverageResourceLimits(input.semantic.ir.coverage).map( + semanticResourceLimitReason, + ), + ...limitations, + ], }); const semanticCoverage = ( semantic: SemanticAnalysis, ): JavaScriptArtifactGraphCoverage => { - if (semantic.ir.coverage.status === "complete") + const resourceLimits = semanticCoverageResourceLimits(semantic.ir.coverage); + if (semantic.ir.coverage.status === "complete" && resourceLimits.length === 0) return completeApplicationCoverage(); - return partialApplicationCoverage([], semantic.ir.coverage.omittedCount); + return partialApplicationCoverage( + resourceLimits.map((resourceLimit) => ({ + name: `javascript_semantic_${resourceLimit.replaceAll("-", "_")}`, + value: + resourceLimit === "primitive-candidates" + ? SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT + : SEMANTIC_EXPRESSION_DEPTH_LIMIT, + unit: + resourceLimit === "expression-depth" + ? ("depth" as const) + : ("items" as const), + })), + semantic.ir.coverage.omittedCount, + ); }; const moduleFormat = ( diff --git a/src/application/javascript/JavaScriptOpenClassification.test.ts b/src/application/javascript/JavaScriptOpenClassification.test.ts new file mode 100644 index 0000000000..66f6b10450 --- /dev/null +++ b/src/application/javascript/JavaScriptOpenClassification.test.ts @@ -0,0 +1,51 @@ +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 { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; + +it("classifies overloaded open calls from lexical receiver facts", async () => { + const inputPath = await createTestTempDirectory( + "rea-js-open-classification-", + ); + await writeFile( + join(inputPath, "app.js"), + ` + const popup = window; + popup.open(url, "/preview"); + { + const indexedDB = { open() {} }; + indexedDB.open("PROPFIND", "/dav"); + indexedDB.open(databaseName, "2"); + } + const xhr = new XMLHttpRequest(); + xhr.open(method, "/api"); + indexedDB.open("records", "2"); + `, + ); + const result = await analyzeJavaScriptApplication({ + input_path: inputPath, + format: "directory", + }); + if (!result.ok) + throw new Error(`Expected analysis success: ${result.error.message}`); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + const endpoints = graph.nodes + .filter(({ kind }) => kind === "endpoint") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.value), + ) + .sort(); + const storage = graph.nodes + .filter(({ kind }) => kind === "storage") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.storage_kind), + ); + expect(endpoints).toEqual(["/api", "/dav"]); + expect(storage).toEqual(["indexed-db"]); +}); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 2707b06d21..a4df0ad064 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -1,3 +1,4 @@ +import * as t from "@babel/types"; import { expect, it } from "vitest"; import { @@ -13,7 +14,12 @@ import { import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; -import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { + analyzeJavaScriptSemantics, + analyzeParsedJavaScriptSemantics, +} from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; +import { semanticCoverageResourceLimits } from "../../domain/javascript/javascriptSemanticCoverage.js"; const SHA256 = "a".repeat(64); const GRAPH_ID = `jag_${"b".repeat(64)}`; @@ -86,6 +92,70 @@ it("keeps dynamic calls explicit and returns complete deterministic results", () expect(repeated).toEqual(first); }); +it("preserves primitive candidate budget unknowns in semantic graph coverage", () => { + const term = '(true ? "a" : "b")'; + const source = `const answer = ${Array.from({ length: 20 }, () => term).join(" + ")};`; + const ir = analyzeJavaScriptSemantics(source); + const graph = graphFor(source, ir); + + expect(ir.coverage).toMatchObject({ + status: "partial", + resourceLimits: ["primitive-candidates"], + }); + expect(graph.unknowns).toContainEqual( + expect.objectContaining({ + family: "data-flow", + reason: "resource-limit", + detail: expect.stringMatching(/primitive candidate budget exceeded/i), + }), + ); +}); + +it("retains resource-limit reasons at nested object property slots", () => { + const term = '(true ? "a" : "b")'; + const source = `const answer = { nested: { value: ${Array.from({ length: 20 }, () => term).join(" + ")} } };`; + const ir = analyzeJavaScriptSemantics(source); + const graph = graphFor(source, ir); + + expect(semanticCoverageResourceLimits(ir.coverage)).toEqual([ + "primitive-candidates", + ]); + expect(graph.unknowns).toContainEqual( + expect.objectContaining({ + family: "object-flow", + reason: "resource-limit", + detail: expect.stringMatching(/Unknown value at property:value/u), + }), + ); +}); + +it("preserves deep expression resource reasons through graph projection", () => { + const source = "const answer = true;"; + const parsed = parseJavaScriptSource(source); + 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 < 5_000; index += 1) + expression = t.unaryExpression("!", expression, true); + declarator.init = expression; + const ir = analyzeParsedJavaScriptSemantics(parsed); + const graph = graphFor(source, ir); + + expect(semanticCoverageResourceLimits(ir.coverage)).toEqual([ + "expression-depth", + ]); + expect(graph.unknowns).toContainEqual( + expect.objectContaining({ + reason: "resource-limit", + detail: expect.stringMatching(/expression depth budget exceeded/i), + }), + ); +}); + it("keeps ambiguous interprocedural flow out of the default traversal", () => { const graph = graphFor(` function left(value) { return value; } @@ -257,7 +327,7 @@ it("keeps duplicate function fingerprints ambiguous", () => { expect(query.summary.total_seed_matches).toBe(2); }); -const graphFor = (source: string) => { +const graphFor = (source: string, ir = analyzeJavaScriptSemantics(source)) => { const file: JavaScriptArtifactFile = { path: "app.js", container_sha256: SHA256, @@ -273,9 +343,7 @@ const graphFor = (source: string) => { { file, javascript: null, - semantic: { - ir: analyzeJavaScriptSemantics(source), - }, + semantic: { ir }, }, ], packages: [], diff --git a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts index bcda8680e5..f4fdfad325 100644 --- a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts @@ -3,11 +3,14 @@ import type { JavaScriptSemanticBinding, JavaScriptSemanticValue, } from "../../domain/javascript/javascriptSemanticIr.js"; +import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import { retainSemanticGraphNode, addSemanticGraphRelation, + addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; +import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; /** Project bounded literal values and object slots for exact query seeds. */ export const projectSemanticValues = ( @@ -76,6 +79,29 @@ const projectValue = (input: ValueProjectionInput): void => { role: `property:${property.name}`, }); } + else if (value.status === "unknown" && value.resourceLimit !== undefined) { + const location = binding.definitions[0]?.location ?? null; + const evidence = observedSemanticEvidence(context.file, location); + const isPropertyValue = role.startsWith("property:"); + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: target.node_id, + family: isPropertyValue ? "object-flow" : "data-flow", + relation_kinds: [isPropertyValue ? "writes-property" : "defines"], + reason: "resource-limit", + detail: `${value.reason} Unknown value at ${role}.`, + candidate_node_ids: [target.node_id], + evidence: { + ...evidence, + authority: "unknown", + state: "unknown", + confidence: "unknown", + limitations: [value.reason], + }, + }), + ); + } }; const addLiteralNode = ( diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index d6d9636c04..554b457b54 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -2,7 +2,10 @@ import * as t from "@babel/types"; import { compareCodePoints } from "../canonicalOrdering.js"; import type { ElectronStaticValue } from "./electronStaticAnalysisTypes.js"; -import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { + readExactJavaScriptLiteral, + semanticStaticPropertyName, +} from "./javascriptAstValues.js"; import { sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; /** Preserve one literal value or the exact inert expression. */ @@ -142,10 +145,9 @@ const literalValue = ( ): | { readonly found: true; readonly value: string | number | boolean | null } | { readonly found: false } => { - if (t.isStringLiteral(node) || t.isNumericLiteral(node)) - return { found: true, value: node.value }; - if (t.isBooleanLiteral(node)) return { found: true, value: node.value }; - if (t.isNullLiteral(node)) return { found: true, value: null }; + if (node === null || node === undefined) return { found: false }; + const literal = readExactJavaScriptLiteral(node); + if (literal.found) return literal; if (t.isTemplateLiteral(node) && node.expressions.length === 0) return { found: true, diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 76c194bf55..b7f8d0efc0 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -1,4 +1,29 @@ import * as t from "@babel/types"; +import type { JavaScriptSemanticPrimitive } from "./javascriptSemanticValueTypes.js"; + +/** 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 } => { + if (t.isStringLiteral(node)) return { found: true, value: node.value }; + if (t.isNumericLiteral(node) && Number.isFinite(node.value)) + return { found: true, value: node.value }; + if (t.isBooleanLiteral(node)) return { found: true, value: node.value }; + if (t.isNullLiteral(node)) return { found: true, value: null }; + if ( + t.isTemplateLiteral(node) && + node.expressions.length === 0 && + node.quasis.length === 1 + ) { + const cooked = node.quasis[0]?.value.cooked; + return cooked === null || cooked === undefined + ? { found: false } + : { found: true, value: cooked }; + } + return { found: false }; +}; /** Read an identifier or literal property name without evaluating syntax. */ export const propertyName = (node: t.Node): string => { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index b9a267fb43..eb599b6a7c 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -22,6 +22,8 @@ import type { } from "./javascriptSemanticState.js"; import { currentSemanticScope, + resolveSemanticBindingState, + semanticResolutionBlocked, resolveSemanticBindingFromScope, semanticScopeId, semanticVariableScope, @@ -35,6 +37,7 @@ import { import { collectJavaScriptDerivedSemantics } from "./javascriptSemanticDerivedAnalysis.js"; import { propertyName, range } from "./javascriptStaticAnalysisHelpers.js"; import { semanticCoverage } from "./javascriptSemanticCoverage.js"; +import { semanticResourceLimitsIn } from "./javascriptSemanticResourceLimits.js"; import { parseJavaScriptSource, type ParsedJavaScriptSource, @@ -111,7 +114,15 @@ export const analyzeParsedJavaScriptSemantics = ( references, moduleLinks, ...derived, - coverage: semanticCoverage(parserPartial), + coverage: semanticCoverage( + parserPartial, + semanticResourceLimitsIn([ + ...bindings.map(({ value }) => value), + ...callables.flatMap((callable) => + callable.returnSites.map(({ value }) => value), + ), + ]), + ), limitations: [ ...(parserPartial ? [ @@ -127,6 +138,122 @@ export const analyzeParsedJavaScriptSemantics = ( }; }; +/** Receiver identity facts for the overloaded `.open` syntax only. */ +export type JavaScriptOpenReceiverFact = + | "window" + | "indexed-db" + | "cache-storage" + | "local" + | "local-indexed-db" + | "local-cache-storage"; + +/** + * Resolve only the lexical receiver identity needed to distinguish browser + * `window.open` from XHR and global storage `.open` calls. This deliberately + * builds definitions without running semantic value or provenance analysis. + */ +export const classifyParsedJavaScriptOpenReceivers = ( + file: ParsedJavaScriptSource, +): ReadonlyMap => { + const state = createState(file.program); + collectDefinitions(file.program, state); + const facts = new Map(); + traverseJavaScriptAst(file.program, { + enter: (node) => { + if ( + (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) || + (!t.isMemberExpression(node.callee) && + !t.isOptionalMemberExpression(node.callee)) || + semanticStaticPropertyKey( + node.callee.property, + node.callee.computed, + ) !== "open" || + !t.isIdentifier(node.callee.object) + ) + return; + const receiver = node.callee.object.name; + const binding = resolveSemanticBindingState(state, node, receiver); + if (binding === undefined) { + if (semanticResolutionBlocked(state, node, receiver)) return; + const fact = openGlobalFact(receiver); + if (fact !== undefined) facts.set(node.start ?? -1, fact); + return; + } + if (receiver === "indexedDB" || receiver === "caches") { + facts.set( + node.start ?? -1, + receiver === "indexedDB" ? "local-indexed-db" : "local-cache-storage", + ); + return; + } + if (binding.mutable || binding.initializers.length !== 1) return; + const initializer = binding.initializers[0]?.node; + if (!t.isIdentifier(initializer)) return; + const initializerBinding = resolveSemanticBindingState( + state, + initializer, + initializer.name, + ); + if (initializerBinding !== undefined) { + if (initializer.name === "indexedDB" || initializer.name === "caches") { + const localFact = + initializer.name === "indexedDB" + ? "local-indexed-db" + : "local-cache-storage"; + facts.set(node.start ?? -1, localFact); + if ( + initializerBinding.mutable || + initializerBinding.initializers.length !== 1 + ) + return; + const nested = initializerBinding.initializers[0]?.node; + if (t.isIdentifier(nested)) { + const nestedFact = resolveGlobalAliasFact( + state, + initializer, + nested.name, + ); + if (nestedFact !== undefined) + facts.set(node.start ?? -1, nestedFact); + } + } else if (initializer.name === "window") { + facts.set(node.start ?? -1, "local"); + } + return; + } + const aliasFact = resolveGlobalAliasFact( + state, + initializer, + initializer.name, + ); + if (aliasFact !== undefined) facts.set(node.start ?? -1, aliasFact); + }, + }); + return facts; +}; + +const openGlobalFact = ( + name: string, +): JavaScriptOpenReceiverFact | undefined => { + if (name === "window") return "window"; + if (name === "indexedDB") return "indexed-db"; + if (name === "caches") return "cache-storage"; + return undefined; +}; + +const resolveGlobalAliasFact = ( + state: JavaScriptSemanticAnalysisState, + node: t.Node, + name: string, +): JavaScriptOpenReceiverFact | undefined => { + if ( + resolveSemanticBindingState(state, node, name) !== undefined || + semanticResolutionBlocked(state, node, name) + ) + return undefined; + return openGlobalFact(name); +}; + const createState = (program: t.Program): JavaScriptSemanticAnalysisState => { const root: JavaScriptSemanticScopeState = { scopeId: semanticScopeId("program", program), diff --git a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts index e6a06e7284..32abf4cc33 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts @@ -1,7 +1,13 @@ +import * as t from "@babel/types"; import { describe, expect, it } from "vitest"; -import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { + analyzeJavaScriptSemantics, + analyzeParsedJavaScriptSemantics, +} from "./javascriptSemanticAnalysis.js"; import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { readExactJavaScriptLiteral } from "./javascriptAstValues.js"; const conditionalCandidates = [ '"one"', @@ -18,6 +24,42 @@ const valueOf = (expression: string) => analyzeJavaScriptSemantics(`const answer = ${expression};`), "answer", ).value; + +describe("exact JavaScript literal reader", () => { + it("reads cooked atoms and rejects raw-only or interpolated templates", () => { + const cookedElement = t.templateElement( + { raw: "raw", cooked: "raw" }, + true, + ); + cookedElement.value.cooked = "cooked"; + const rawOnlyElement = t.templateElement( + { raw: "raw", cooked: "raw" }, + true, + ); + rawOnlyElement.value.cooked = null; + expect(readExactJavaScriptLiteral(t.stringLiteral(""))).toEqual({ + found: true, + value: "", + }); + expect( + readExactJavaScriptLiteral(t.templateLiteral([cookedElement], [])), + ).toEqual({ found: true, value: "cooked" }); + expect( + readExactJavaScriptLiteral(t.templateLiteral([rawOnlyElement], [])), + ).toEqual({ found: false }); + expect( + readExactJavaScriptLiteral( + t.templateLiteral( + [ + t.templateElement({ raw: "", cooked: "" }, false), + t.templateElement({ raw: "", cooked: "" }, true), + ], + [t.identifier("value")], + ), + ), + ).toEqual({ found: false }); + }); +}); const valuesOf = (expressions: readonly string[]) => { const ir = analyzeJavaScriptSemantics( expressions @@ -176,3 +218,86 @@ describe("logical primitive unions", () => { ); }); }); + +describe("semantic expression resource bounds", () => { + it("returns an explicit unknown before expanding a large addition product", () => { + const term = '(choice ? "a" : "b")'; + const expression = Array.from({ length: 20 }, () => term).join(" + "); + const value = valueOf(expression); + expect(value).toMatchObject({ + status: "unknown", + resourceLimit: "primitive-candidates", + reason: expect.stringMatching(/primitive candidate budget exceeded/i), + }); + expect( + analyzeJavaScriptSemantics(`const answer = ${expression};`).coverage, + ).toMatchObject({ + status: "partial", + omittedCount: null, + resourceLimits: ["primitive-candidates"], + }); + }); + + it("returns an explicit unknown for a deeply nested unary AST without throwing", () => { + 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 < 5_000; index += 1) + expression = t.unaryExpression("!", expression, true); + declarator.init = expression; + + const ir = analyzeParsedJavaScriptSemantics(parsed); + expect(ir.coverage).toMatchObject({ + status: "partial", + omittedCount: null, + resourceLimits: ["expression-depth"], + }); + expect(topLevelBinding(ir, "answer").value).toEqual({ + status: "unknown", + resourceLimit: "expression-depth", + reason: expect.stringMatching( + /semantic expression depth budget exceeded/i, + ), + }); + }); + + it("bounds nested conditional evaluation and provenance walks", () => { + 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 < 5_000; index += 1) + expression = t.conditionalExpression( + t.identifier("condition"), + expression, + t.booleanLiteral(false), + ); + declarator.init = expression; + + const ir = analyzeParsedJavaScriptSemantics(parsed); + const value = topLevelBinding(ir, "answer").value; + expect(value).toMatchObject({ + status: "unknown", + resourceLimit: "expression-depth", + reason: expect.stringMatching( + /semantic expression depth budget exceeded/i, + ), + }); + }); + + it("preserves exact shallow addition alternatives", () => { + expect(valueOf('(choice ? "a" : "b") + (other ? "1" : "2")')).toEqual({ + status: "union", + values: ["a1", "a2", "b1", "b2"], + }); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticCoverage.ts b/src/domain/javascript/javascriptSemanticCoverage.ts index e987a64eb5..1f92406e14 100644 --- a/src/domain/javascript/javascriptSemanticCoverage.ts +++ b/src/domain/javascript/javascriptSemanticCoverage.ts @@ -1,3 +1,5 @@ +import type { JavaScriptSemanticResourceLimit } from "./javascriptSemanticValueTypes.js"; + /** Exact retention state for one callable's direct returns. */ export type JavaScriptSemanticReturnCoverage = { readonly retainedCount: number; @@ -9,21 +11,34 @@ export type JavaScriptSemanticReturnCoverage = { /** Coverage state for one semantic recovery pass. */ export type JavaScriptSemanticCoverage = | { - readonly status: "complete" | "partial"; + readonly status: "complete"; readonly omittedCount: 0; + readonly resourceLimits?: readonly JavaScriptSemanticResourceLimit[]; + } + | { + readonly status: "partial"; + readonly omittedCount: 0 | null; + readonly resourceLimits?: readonly JavaScriptSemanticResourceLimit[]; } | { readonly status: "failed"; readonly omittedCount: null; + readonly resourceLimits?: readonly JavaScriptSemanticResourceLimit[]; }; -/** Classify whole-file semantic coverage from parser recovery. */ +/** Classify whole-file semantic coverage from parsing and value bounds. */ export const semanticCoverage = ( parserPartial: boolean, -): JavaScriptSemanticCoverage => ({ - status: parserPartial ? "partial" : "complete", - omittedCount: 0, -}); + resourceLimits: readonly JavaScriptSemanticResourceLimit[] = [], +): JavaScriptSemanticCoverage => + resourceLimits.length > 0 + ? { status: "partial", omittedCount: null, resourceLimits } + : { status: parserPartial ? "partial" : "complete", omittedCount: 0 }; + +/** Read typed resource-limit classifications from any coverage state. */ +export const semanticCoverageResourceLimits = ( + coverage: JavaScriptSemanticCoverage, +): readonly JavaScriptSemanticResourceLimit[] => coverage.resourceLimits ?? []; /** Classify one callable's direct-return coverage. */ export const semanticReturnCoverage = ( diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 3a79755353..3fa7776100 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -11,6 +11,7 @@ import { } from "./javascriptSemanticState.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { + readExactJavaScriptLiteral, semanticStaticPropertyKey, semanticStaticPropertyName, } from "./javascriptAstValues.js"; @@ -262,10 +263,12 @@ export const dataEffectLiteralString = ( | t.ArgumentPlaceholder | undefined, ): string | null => { - if (t.isStringLiteral(node)) return node.value; - if (t.isTemplateLiteral(node) && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? null; - return null; + if (node === undefined) return null; + const literal = readExactJavaScriptLiteral(node); + if (literal.found && typeof literal.value === "string") return literal.value; + return t.isTemplateLiteral(node) && node.expressions.length === 0 + ? (node.quasis[0]?.value.raw ?? null) + : null; }; /** Read a member callee from call or construction syntax. */ diff --git a/src/domain/javascript/javascriptSemanticGraphSchemas.ts b/src/domain/javascript/javascriptSemanticGraphSchemas.ts index 945bc05de5..c5374ebe4a 100644 --- a/src/domain/javascript/javascriptSemanticGraphSchemas.ts +++ b/src/domain/javascript/javascriptSemanticGraphSchemas.ts @@ -250,6 +250,7 @@ export const JAVASCRIPT_SEMANTIC_UNKNOWN_REASONS = [ "eval-or-generated-code", "incomplete-module", "missing-source", + "resource-limit", "unsupported-syntax", ] as const; diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index 98c34ac5b0..38a55ef414 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -8,6 +8,7 @@ import type { export type { JavaScriptSemanticPrimitive, JavaScriptSemanticProperty, + JavaScriptSemanticResourceLimit, JavaScriptSemanticValue, } from "./javascriptSemanticValueTypes.js"; diff --git a/src/domain/javascript/javascriptSemanticPrimitives.ts b/src/domain/javascript/javascriptSemanticPrimitives.ts index 9015ec2346..04f5110946 100644 --- a/src/domain/javascript/javascriptSemanticPrimitives.ts +++ b/src/domain/javascript/javascriptSemanticPrimitives.ts @@ -6,23 +6,35 @@ import type { } from "./javascriptSemanticIr.js"; import { compareCodePoints } from "../canonicalOrdering.js"; import { semanticPrimitiveKey } from "./javascriptSemanticProvenance.js"; +import { readExactJavaScriptLiteral } from "./javascriptAstValues.js"; +import { + SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, + semanticResourceLimitUnknown, +} from "./javascriptSemanticResourceLimits.js"; + +/** + * Keep eight independent binary choices exact, while bounding the next + * exponential expansion before it allocates hundreds more alternatives. + */ +export const MAX_SEMANTIC_PRIMITIVE_CANDIDATES = + SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT; /** Normalize one bounded collection of possible primitive values. */ export const semanticPrimitiveSet = ( values: readonly JavaScriptSemanticPrimitive[], ): JavaScriptSemanticValue => { - if ( - values.some((value) => typeof value === "number" && !Number.isFinite(value)) - ) - return { - status: "unknown", - reason: "Nonfinite numbers are outside the JSON primitive lattice.", - }; - const unique = [ - ...new Map( - values.map((value) => [semanticPrimitiveKey(value), value]), - ).values(), - ].sort((left, right) => + const byKey = new Map(); + for (const value of values) { + if (typeof value === "number" && !Number.isFinite(value)) + return { + status: "unknown", + reason: "Nonfinite numbers are outside the JSON primitive lattice.", + }; + byKey.set(semanticPrimitiveKey(value), value); + if (byKey.size > MAX_SEMANTIC_PRIMITIVE_CANDIDATES) + return semanticResourceLimitUnknown("primitive-candidates"); + } + const unique = [...byKey.values()].sort((left, right) => compareCodePoints(semanticPrimitiveKey(left), semanticPrimitiveKey(right)), ); const only = unique[0]; @@ -47,10 +59,5 @@ export const semanticPrimitiveValue = ( ): | { readonly found: true; readonly value: JavaScriptSemanticPrimitive } | { readonly found: false } => { - if (t.isStringLiteral(node)) return { found: true, value: node.value }; - if (t.isNumericLiteral(node) && Number.isFinite(node.value)) - return { found: true, value: node.value }; - if (t.isBooleanLiteral(node)) return { found: true, value: node.value }; - if (t.isNullLiteral(node)) return { found: true, value: null }; - return { found: false }; + return readExactJavaScriptLiteral(node); }; diff --git a/src/domain/javascript/javascriptSemanticResourceLimits.ts b/src/domain/javascript/javascriptSemanticResourceLimits.ts new file mode 100644 index 0000000000..88a0f019b3 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticResourceLimits.ts @@ -0,0 +1,47 @@ +import type { + JavaScriptSemanticResourceLimit, + JavaScriptSemanticValue, +} from "./javascriptSemanticValueTypes.js"; + +export const SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT = 256; +export const SEMANTIC_EXPRESSION_DEPTH_LIMIT = 256; + +const RESOURCE_LIMIT_REASON: Record = { + "primitive-candidates": + "Primitive candidate budget exceeded (maximum 256 alternatives).", + "expression-depth": + "Semantic expression depth budget exceeded (maximum 256 nested expressions).", +}; + +/** Make an explicit unknown value tagged with the semantic resource bound. */ +export const semanticResourceLimitUnknown = ( + resourceLimit: JavaScriptSemanticResourceLimit, +): JavaScriptSemanticValue => ({ + status: "unknown", + reason: RESOURCE_LIMIT_REASON[resourceLimit], + resourceLimit, +}); + +/** Collect resource-limit classifications from nested semantic values. */ +export const semanticResourceLimitsIn = ( + values: readonly JavaScriptSemanticValue[], +): readonly JavaScriptSemanticResourceLimit[] => { + const found = new Set(); + const pending = [...values]; + while (pending.length > 0) { + const value = pending.pop(); + if (value === undefined) continue; + if (value.status === "unknown" && value.resourceLimit !== undefined) + found.add(value.resourceLimit); + else if (value.status === "object") + for (const property of value.properties) pending.push(property.value); + else if (value.status === "array") + for (const item of value.items) pending.push(item); + } + return [...found].sort(); +}; + +/** Display label for one stable resource classification. */ +export const semanticResourceLimitReason = ( + resourceLimit: JavaScriptSemanticResourceLimit, +): string => RESOURCE_LIMIT_REASON[resourceLimit]; diff --git a/src/domain/javascript/javascriptSemanticValueTypes.ts b/src/domain/javascript/javascriptSemanticValueTypes.ts index 99ec2f6fd1..657c780e0f 100644 --- a/src/domain/javascript/javascriptSemanticValueTypes.ts +++ b/src/domain/javascript/javascriptSemanticValueTypes.ts @@ -1,6 +1,11 @@ /** Primitive values admitted into the constant lattice. */ export type JavaScriptSemanticPrimitive = string | number | boolean | null; +/** Resource bound that prevented an exact semantic value. */ +export type JavaScriptSemanticResourceLimit = + | "primitive-candidates" + | "expression-depth"; + type JavaScriptSemanticObjectValue = { readonly status: "object"; readonly properties: readonly JavaScriptSemanticProperty[]; @@ -44,6 +49,7 @@ export type JavaScriptSemanticValue = | { readonly status: "unknown" | "ambiguous" | "cycle"; readonly reason: string; + readonly resourceLimit?: JavaScriptSemanticResourceLimit; }; /** One statically named object-literal property. */ diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 034297db55..a7e78dd3a8 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -5,6 +5,7 @@ import { invalidateSemanticMutationPath } from "./javascriptSemanticMutationValu import type { JavaScriptBindingProvenance, JavaScriptSemanticProperty, + JavaScriptSemanticResourceLimit, JavaScriptSemanticValue, } from "./javascriptSemanticIr.js"; import { @@ -23,36 +24,57 @@ import { uniqueSemanticOrigins, } from "./javascriptSemanticProvenance.js"; import { + MAX_SEMANTIC_PRIMITIVE_CANDIDATES, semanticPrimitiveCandidates as primitiveCandidates, semanticPrimitiveSet as primitiveSet, semanticPrimitiveValue as primitiveValue, } from "./javascriptSemanticPrimitives.js"; +import { + SEMANTIC_EXPRESSION_DEPTH_LIMIT, + SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, + semanticResourceLimitUnknown, + semanticResourceLimitReason, +} from "./javascriptSemanticResourceLimits.js"; interface EvaluationContext { readonly state: JavaScriptSemanticAnalysisState; readonly bindings: ReadonlySet; + readonly expressionDepth: number; } +// Cap expression recursion independently from graph nodes and primitive unions. +const isSemanticResourceLimit = ( + value: JavaScriptSemanticValue, +): value is Extract< + JavaScriptSemanticValue, + { readonly status: "unknown" | "ambiguous" | "cycle" } +> & { readonly resourceLimit: JavaScriptSemanticResourceLimit } => + value.status === "unknown" && value.resourceLimit !== undefined; + /** Evaluate one binding in the bounded constant-value lattice. */ export const evaluateSemanticBinding = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticValue => - evaluateBinding(binding, { state, bindings: new Set() }); + evaluateBinding(binding, { state, bindings: new Set(), expressionDepth: 0 }); /** Evaluate one arbitrary inert expression in the established lexical state. */ export const evaluateSemanticExpression = ( node: t.Node, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticValue => - evaluateExpression(node, { state, bindings: new Set() }); + evaluateExpression(node, { state, bindings: new Set(), expressionDepth: 0 }); /** Follow module provenance through destructuring, members, and aliases. */ export const evaluateSemanticProvenance = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, ): JavaScriptBindingProvenance => - provenanceForBinding(binding, { state, bindings: new Set() }); + provenanceForBinding(binding, { + state, + bindings: new Set(), + expressionDepth: 0, + }); const evaluateBinding = ( binding: JavaScriptSemanticBindingState, @@ -85,6 +107,8 @@ const evaluateExpression = ( node: t.Node, context: EvaluationContext, ): JavaScriptSemanticValue => { + if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) + return semanticResourceLimitUnknown("expression-depth"); const literal = primitiveValue(node); if (literal.found) return { status: "literal", value: literal.value }; if (t.isIdentifier(node)) { @@ -128,18 +152,23 @@ const evaluateTemplate = ( let candidates = [""]; for (let index = 0; index < node.quasis.length; index += 1) { const quasi = node.quasis[index]; + // Semantic template evaluation retains its established raw fallback for + // synthetic AST inputs; exact atom readers remain cooked-only. const text = quasi?.value.cooked ?? quasi?.value.raw ?? ""; candidates = candidates.map((prefix) => `${prefix}${text}`); const expression = node.expressions[index]; if (expression === undefined) continue; - const values = primitiveCandidates( - evaluateExpression(expression, nestedContext(context)), - ); - if (values === null) + const evaluated = evaluateExpression(expression, nestedContext(context)); + const values = primitiveCandidates(evaluated); + if (values === null) { + if (isSemanticResourceLimit(evaluated)) return evaluated; return { status: "unknown", reason: "Template expression is not a bounded primitive.", }; + } + if (candidates.length > SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT / values.length) + return semanticResourceLimitUnknown("primitive-candidates"); candidates = candidates.flatMap((prefix) => values.map((value) => `${prefix}${String(value)}`), ); @@ -311,8 +340,13 @@ const addPrimitiveValues = ( ): JavaScriptSemanticValue => { const left = primitiveCandidates(leftValue); const right = primitiveCandidates(rightValue); - if (left === null || right === null) + if (left === null || right === null) { + if (isSemanticResourceLimit(leftValue)) return leftValue; + if (isSemanticResourceLimit(rightValue)) return rightValue; return { status: "unknown", reason: "Non-primitive addition." }; + } + if (left.length > MAX_SEMANTIC_PRIMITIVE_CANDIDATES / right.length) + return semanticResourceLimitUnknown("primitive-candidates"); const values = left.flatMap((leftValue) => right.map((rightValue) => typeof leftValue === "string" || typeof rightValue === "string" @@ -327,11 +361,12 @@ const evaluateUnary = ( node: t.UnaryExpression, context: EvaluationContext, ): JavaScriptSemanticValue => { - const argument = primitiveCandidates( - evaluateExpression(node.argument, nestedContext(context)), - ); - if (argument === null) + const evaluated = evaluateExpression(node.argument, nestedContext(context)); + const argument = primitiveCandidates(evaluated); + if (argument === null) { + if (isSemanticResourceLimit(evaluated)) return evaluated; return { status: "unknown", reason: "Non-primitive unary operand." }; + } if (node.operator === "!") return primitiveSet(argument.map((value) => !value)); if (node.operator === "+") @@ -436,6 +471,11 @@ const provenanceForExpression = ( node: t.Node, context: EvaluationContext, ): JavaScriptBindingProvenance => { + if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) + return semanticUnresolvedProvenance( + "unknown", + semanticResourceLimitReason("expression-depth"), + ); const required = semanticRequireOrigin(node, context.state); if (required !== undefined) return semanticOriginsProvenance([required]); if (t.isIdentifier(node)) { @@ -506,6 +546,8 @@ const provenanceForExpression = ( const mergeValues = ( values: readonly JavaScriptSemanticValue[], ): JavaScriptSemanticValue => { + const resourceLimit = values.find(isSemanticResourceLimit); + if (resourceLimit !== undefined) return resourceLimit; const primitives = values.flatMap( (value) => primitiveCandidates(value) ?? [], ); @@ -521,6 +563,7 @@ const nestedContext = ( bindingId?: string, ): EvaluationContext => ({ state: context.state, + expressionDepth: context.expressionDepth + 1, bindings: bindingId === undefined ? context.bindings diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 72a462ba87..0a18ceb7db 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -27,6 +27,10 @@ import { type JavaScriptAnalysisAccumulator as AnalysisAccumulator, } from "./javascriptStaticAnalysisState.js"; import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js"; +import { + classifyParsedJavaScriptOpenReceivers, + type JavaScriptOpenReceiverFact, +} from "./javascriptSemanticAnalysis.js"; import { parseJavaScriptSource, type ParsedJavaScriptSource, @@ -39,16 +43,21 @@ export const analyzeJavaScriptStaticSource = ( const file = parseJavaScriptSource(source); return file === null ? failedJavaScriptStaticAnalysis() - : analyzeParsedJavaScriptStaticSource(source, file); + : analyzeParsedJavaScriptStaticSource( + source, + file, + classifyParsedJavaScriptOpenReceivers(file), + ); }; /** Recover static structure from an already parsed JavaScript artifact. */ export const analyzeParsedJavaScriptStaticSource = ( source: string, file: ParsedJavaScriptSource, + openReceiverFacts?: ReadonlyMap, ): JavaScriptStaticAnalysis => { const accumulator = createJavaScriptAnalysisAccumulator(); - traverseStaticSource(source, file, accumulator); + traverseStaticSource(source, file, accumulator, openReceiverFacts); addSourceMapDirectives(source, file.comments ?? [], accumulator); return finalizeStaticAnalysis(source, file, accumulator); }; @@ -57,11 +66,12 @@ const traverseStaticSource = ( source: string, file: ParsedJavaScriptSource, accumulator: AnalysisAccumulator, + openReceiverFacts?: ReadonlyMap, ): void => { traverseJavaScriptAst(file, { enter: (node) => { accumulator.visitedNodes += 1; - inspectNode(source, node, accumulator); + inspectNode(source, node, accumulator, openReceiverFacts); return undefined; }, }); @@ -140,16 +150,20 @@ const inspectNode = ( source: string, node: t.Node, accumulator: AnalysisAccumulator, + openReceiverFacts?: ReadonlyMap, ): void => { const findings = { source, accumulator, + ...(openReceiverFacts === undefined ? {} : { openReceiverFacts }), }; inspectElectronStaticNode(node, findings); if (t.isCallExpression(node)) { inspectBundlerRegistration(source, node, accumulator); inspectEsbuildWrapper(source, node, accumulator); inspectCall(source, node, findings); + } else if (t.isOptionalCallExpression(node)) { + inspectCall(source, node, findings); } else if (t.isNewExpression(node)) inspectCall(source, node, findings); if ( (t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) && diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 984be08406..8addcb7cbb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -28,10 +28,15 @@ import type { JavaScriptReferenceInput as ReferenceInput, } from "./javascriptStaticAnalysisState.js"; +type JavaScriptCallLike = + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression; + /** Inspect one call or new expression for references, endpoints, storage, and roles. */ export const inspectCall = ( source: string, - node: t.CallExpression | t.NewExpression, + node: JavaScriptCallLike, context: FindingContext, ): void => { const { accumulator } = context; @@ -92,7 +97,7 @@ export const inspectCall = ( }; const requireCallSpecifier = ( - node: t.CallExpression | t.NewExpression, + node: JavaScriptCallLike, name: string, moduleRequireName: string | null, ): { readonly specifier: string | undefined } | null => { @@ -109,7 +114,7 @@ const requireCallSpecifier = ( }; const chunkLoadSpecifier = ( - node: t.CallExpression | t.NewExpression, + node: JavaScriptCallLike, name: string, moduleRequireName: string | null, ): { readonly specifier: string | undefined } | null => { @@ -123,7 +128,7 @@ const chunkLoadSpecifier = ( const vitePreloadDependencySpecifiers = ( source: string, - node: t.CallExpression | t.NewExpression, + node: JavaScriptCallLike, name: string, ): readonly string[] => { if (name !== "__vite__mapDeps" || !t.isArrayExpression(node.arguments[0])) @@ -147,7 +152,7 @@ const vitePreloadDependencyTable = (source: string): readonly string[] => { }; const inspectEndpointCall = ( - node: t.CallExpression | t.NewExpression, + node: JavaScriptCallLike, name: string, first: string | undefined, context: FindingContext, @@ -162,7 +167,13 @@ const inspectEndpointCall = ( value: first, mechanism: `call:${name}`, }); - const endpoint = endpointArgumentHelper(name, node.arguments, node.callee); + const receiverFact = context.openReceiverFacts?.get(node.start ?? -1); + const endpoint = endpointArgumentHelper( + name, + node.arguments, + node.callee, + receiverFact, + ); if (endpoint !== undefined) addEndpoint(context, { node, @@ -209,12 +220,22 @@ export const inspectRoleProperty = ( }; const inspectStorageCall = ( - node: t.CallExpression | t.NewExpression, + node: JavaScriptCallLike, name: string, first: string | undefined, context: FindingContext, ): void => { - const storage = storageKind(name); + const receiverFact = context.openReceiverFacts?.get(node.start ?? -1); + const storage = + receiverFact === "local" || + receiverFact === "local-indexed-db" || + receiverFact === "local-cache-storage" + ? undefined + : receiverFact === "indexed-db" + ? "indexed-db" + : receiverFact === "cache-storage" + ? "cache-storage" + : storageKind(name); if (storage === undefined) return; addLocatedFinding(context, { collection: context.accumulator.storage, diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index a84d1dd03d..007dc23f21 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -437,22 +437,38 @@ const xhrOpenUrl = ( name: string, methodNode: t.Node | null | undefined, urlNode: t.Node | null | undefined, + receiverFact?: + | "window" + | "indexed-db" + | "cache-storage" + | "local" + | "local-indexed-db" + | "local-cache-storage", ): string | undefined => { const url = stringValue(urlNode); if (url === undefined) return undefined; const method = stringValue(methodNode); // IndexedDB and Cache Storage `open` take a name and a version, so a // receiver spelled as storage needs a standard HTTP method literal. - const storage = storageKind(name) !== undefined; + const storage = + receiverFact === "local-indexed-db" || + receiverFact === "local-cache-storage" || + receiverFact === "indexed-db" || + receiverFact === "cache-storage" || + storageKind(name) !== undefined; if (method === undefined) return storage || + receiverFact === "window" || WINDOW_OPEN_CALL.test(name) || FS_OPEN_FLAGS.has(url) || BROWSING_CONTEXT_KEYWORDS.has(url.toLowerCase()) ? undefined : url; return XHR_METHODS.has(method.toUpperCase()) || - (!storage && /^[A-Z][A-Z-]*$/u.test(method)) + ((!storage || + receiverFact === "local-indexed-db" || + receiverFact === "local-cache-storage") && + /^[A-Z][A-Z-]*$/u.test(method)) ? url : undefined; }; @@ -504,10 +520,19 @@ export const endpointArgument = ( | t.ArgumentPlaceholder )[], callee: t.Node, + receiverFact?: + | "window" + | "indexed-db" + | "cache-storage" + | "local" + | "local-indexed-db" + | "local-cache-storage", ): string | undefined => { + if (receiverFact === "window") return undefined; if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket") return stringValue(args[0]); - if (name.endsWith(".open")) return xhrOpenUrl(name, args[0], args[1]); + if (name.endsWith(".open")) + return xhrOpenUrl(name, args[0], args[1], receiverFact); const method = ["get", "post", "put", "patch", "delete", "request"].find( (candidate) => name === candidate || name.endsWith(`.${candidate}`), ); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index a137a704c6..b830b1bc4d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -55,6 +55,15 @@ export interface JavaScriptAnalysisAccumulator { export interface JavaScriptFindingContext { readonly source: string; readonly accumulator: JavaScriptAnalysisAccumulator; + readonly openReceiverFacts?: ReadonlyMap< + number, + | "window" + | "indexed-db" + | "cache-storage" + | "local" + | "local-indexed-db" + | "local-cache-storage" + >; } /** Candidate import, require, worker, or service-worker reference. */ diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 681694667d..affb2c78f9 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -233,6 +233,47 @@ describe("web bundle static-analysis parity", () => { ]); }); + it("uses lexical receiver facts for overloaded open calls", () => { + const result = analyzeCapturedWebBundle( + inspection(` + const popup = window; + popup.open(url, "/preview"); + { + const indexedDB = { open() {} }; + indexedDB.open("PROPFIND", "/dav"); + indexedDB.open(databaseName, "2"); + } + const xhr = new XMLHttpRequest(); + xhr.open(method, "/api"); + `), + ); + expect( + result.observations.endpoints.map(({ value }) => value).sort(), + ).toEqual(["/api", "/dav"]); + }); + + it("classifies optional and one-hop aliased open calls", () => { + const source = ` + const popup = window; + popup?.open(url, "/preview"); + const idb = indexedDB; + idb.open(databaseName, "2"); + const xhr = new XMLHttpRequest(); + xhr?.open(method, "/api"); + `; + const bundle = analyzeCapturedWebBundle(inspection(source)); + expect(bundle.observations.endpoints.map(({ value }) => value)).toEqual([ + "/api", + ]); + const staticAnalysis = analyzeJavaScriptStaticSource(source); + expect(staticAnalysis.endpoints.map(({ value }) => value)).toEqual([ + "/api", + ]); + expect(staticAnalysis.storage.map(({ kind }) => kind)).toEqual([ + "indexed-db", + ]); + }); + it("keeps HTTP-method-named reads of provable keyed collections out of endpoints", () => { const result = analyzeCapturedWebBundle( inspection(` diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 253885bc0b..5c5f2b698e 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -5,6 +5,8 @@ import { sanitizeEndpointCandidate } from "./browserObservation.js"; import type { WebPageInspection } from "./browserObservation.js"; import type { WebBundleAnalysis } from "./webBundleAnalysis.js"; import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; +import { classifyParsedJavaScriptOpenReceivers } from "./javascript/javascriptSemanticAnalysis.js"; +import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js"; import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js"; import { calleeName, @@ -62,10 +64,11 @@ export const analyzeScript = ( } accumulator.parsedScripts += 1; detectVendorFingerprints(script, accumulator); + const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file); traverseJavaScriptAst(file, { enter: (node) => { accumulator.visitedNodes += 1; - inspectNode(script, node, accumulator); + inspectNode(script, node, accumulator, openReceiverFacts); }, }); }; @@ -74,6 +77,7 @@ const inspectNode = ( script: IncludedScript, node: t.Node, accumulator: AnalysisAccumulator, + openReceiverFacts: ReadonlyMap, ): void => { if ( (t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) && @@ -103,14 +107,19 @@ const inspectNode = ( accumulator, }); if (t.isObjectProperty(node)) inspectRouteProperty(script, node, accumulator); - if (t.isCallExpression(node) || t.isNewExpression(node)) - inspectCall(script, node, accumulator); + if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) + ) + inspectCall(script, node, accumulator, openReceiverFacts); }; const inspectCall = ( script: IncludedScript, - node: t.CallExpression | t.NewExpression, + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, accumulator: AnalysisAccumulator, + openReceiverFacts: ReadonlyMap, ): void => { const name = calleeName(node.callee); const first = stringValue(node.arguments[0]); @@ -149,7 +158,12 @@ const inspectCall = ( accumulator, }); } - const endpoint = endpointArgument(name, node.arguments, node.callee); + const endpoint = endpointArgument( + name, + node.arguments, + node.callee, + openReceiverFacts.get(node.start ?? -1), + ); if (endpoint !== undefined) addFinding({ collection: accumulator.endpoints, @@ -160,8 +174,9 @@ const inspectCall = ( accumulator, }); if ( - name.endsWith("modelContext.registerTool") || - name === "modelContext.registerTool" + (t.isCallExpression(node) || t.isNewExpression(node)) && + (name.endsWith("modelContext.registerTool") || + name === "modelContext.registerTool") ) addWebMcpDeclaration(script, node, accumulator); }; diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts index ec9d47a077..882cb364b1 100644 --- a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -104,6 +104,42 @@ it("preserves graph commitments and export shapes when consuming file-local IR", } }); +it("reports semantic value resource limits in application graph coverage", async () => { + const root = await createTestTempDirectory("rea-javascript-semantic-limit-"); + const expression = Array.from( + { length: 20 }, + () => '(true ? "a" : "b")', + ).join(" + "); + await writeFile( + join(root, "app.js"), + `const answer = { nested: ${expression} };`, + ); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis); + + expect(graph.coverage).toMatchObject({ + status: "partial", + omitted_count: null, + limits: [ + { + name: "javascript_semantic_primitive_candidates", + value: 256, + unit: "items", + }, + ], + }); + expect(graph.limitations).toContain( + "Primitive candidate budget exceeded (maximum 256 alternatives).", + ); + } finally { + await reader.close(); + } +}); + it("reconstructs package, Electron roles, Webpack/Rspack modules, and cross-layer facts without execution", async () => { const root = await fixtureDirectory(); Reflect.deleteProperty(globalThis, "__rea_bundle_executed");