From 88afcc738bff8ea2023b504e9ce2254d8bc8ba5a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:30 +0800 Subject: [PATCH] fix(javascript): retain endpoint candidates through graph projection --- .../JavaScriptArtifactGraphContext.ts | 4 + .../JavaScriptArtifactGraphFindings.ts | 47 +++++++++--- ...riptStaticAnalysis.routeProperties.test.ts | 32 ++++++++ .../javascriptStaticAnalysisCalls.ts | 5 +- src/domain/webBundleAnalyzer.test.ts | 32 ++++++++ src/domain/webBundleAnalyzerAst.test.ts | 27 +++++++ src/domain/webBundleAnalyzerAst.ts | 11 ++- src/domain/webBundleAnalyzerInspection.ts | 19 ++--- .../javascriptArtifactGraphEndpoints.test.ts | 76 +++++++++++++++++++ 9 files changed, 226 insertions(+), 27 deletions(-) create mode 100644 src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts create mode 100644 src/domain/webBundleAnalyzerAst.test.ts create mode 100644 tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index 1748d9bc6..7bafe0069 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -77,6 +77,7 @@ export interface AstEdgeInput { /** Inferred static relationship edge values. */ export interface InferenceEdgeInput extends AstEdgeInput { readonly limitations?: readonly string[]; + readonly confidence?: "high" | "medium" | "low"; readonly relation: | "imports" | "loads" @@ -194,6 +195,9 @@ export const addStaticInferenceEdge = ( range: input.range, operation: "map-static-relationship", coverage: input.coverage, + ...(input.confidence === undefined + ? {} + : { confidence: input.confidence }), ...(input.limitations === undefined ? {} : { limitations: input.limitations }), diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 9d003c251..57c3961ca 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -201,10 +201,17 @@ const addEndpoint = ( input.value.module_runtime, ) ?? input.asset; const key = compactStringIdentityKey(input.value.value); - const label = `${input.value.kind} endpoint`; + const candidate = input.value.interpretation === "candidate"; + const candidateLimitation = + "The unbound method-like argument is only a candidate; its receiver does not establish a network call."; + const label = `${input.value.kind} ${candidate ? "endpoint candidate" : "endpoint"}`; const node = context.accumulator.addNode({ kind: "endpoint", - identity: artifactLocalIdentity(input.file.sha256, input.value.kind, key), + identity: artifactLocalIdentity( + input.file.sha256, + input.value.kind, + JSON.stringify([input.value.interpretation ?? "literal", key]), + ), observations: [ { label: @@ -213,14 +220,25 @@ const addEndpoint = ( endpoint_kind: input.value.kind, value: input.value.value, mechanism: input.value.mechanism, + ...(candidate ? { interpretation: input.value.interpretation } : {}), }, - evidence: astObservationEvidence({ - sha256: input.file.sha256, - path: input.file.path, - range: input.value.location, - operation: "discover-static-endpoint", - coverage: input.coverage, - }), + evidence: candidate + ? staticInferenceEvidence({ + sha256: input.file.sha256, + path: input.file.path, + range: input.value.location, + operation: "classify-static-endpoint-candidate", + coverage: input.coverage, + confidence: "low", + limitations: [candidateLimitation], + }) + : astObservationEvidence({ + sha256: input.file.sha256, + path: input.file.path, + range: input.value.location, + operation: "discover-static-endpoint", + coverage: input.coverage, + }), }, ], }); @@ -231,7 +249,16 @@ const addEndpoint = ( range: input.value.location, coverage: input.coverage, relation: input.value.kind === "route" ? "exposes" : "calls", - properties: { mechanism: input.value.mechanism }, + properties: { + mechanism: input.value.mechanism, + ...(candidate ? { interpretation: input.value.interpretation } : {}), + }, + ...(candidate + ? { + confidence: "low" as const, + limitations: [candidateLimitation], + } + : {}), }); }; diff --git a/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts b/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts new file mode 100644 index 000000000..cc8d60a62 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it("reads exact route-property templates and leaves interpolated values unknown", () => { + const analysis = analyzeJavaScriptStaticSource(` + const routes = [ + { path: \`settings\` }, + { route: \`users/\${userId}\` }, + { path: "/login" }, + ]; + `); + + expect( + analysis.endpoints.map(({ value, mechanism, interpretation }) => ({ + value, + mechanism, + interpretation, + })), + ).toEqual([ + { + value: "settings", + mechanism: "property:path", + interpretation: undefined, + }, + { + value: "/login", + mechanism: "property:path", + interpretation: undefined, + }, + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index b9bc3c96e..a37896589 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -420,11 +420,12 @@ export const inspectRouteProperty = ( context: FindingContext, ): void => { const key = semanticStaticPropertyName(node.key, node.computed); - if ((key === "path" || key === "route") && t.isStringLiteral(node.value)) + const value = stringValue(node.value); + if ((key === "path" || key === "route") && value !== undefined) addEndpoint(context, { node, kind: "route", - value: node.value.value, + value, mechanism: `property:${key}`, }); }; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 0450bc774..bed9cb6f3 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -902,6 +902,38 @@ const inspection = (source: string) => limitations: [], }); +it("recognizes expression-free template literals as route properties", () => { + const result = analyzeCapturedWebBundle( + inspection(` + const routes = [ + { path: \`score-board\`, component: ScoreBoard }, + { route: \`administration\`, component: Admin }, + { path: \`/user/\${name}\`, component: Dynamic }, + ]; + `), + ); + + expect(result.observations.routes.map(({ value }) => value)).toEqual([ + "score-board", + "administration", + ]); +}); + +it("uses the shared parser for decorated captured scripts", () => { + const result = analyzeCapturedWebBundle( + inspection(`@sealed class Widget {} fetch('/decorated');`), + ); + + expect(result.completeness).toMatchObject({ + status: "complete", + parsed_scripts: 1, + parse_failures: 0, + }); + expect(result.observations.endpoints).toEqual([ + expect.objectContaining({ value: "/decorated" }), + ]); +}); + it("keeps bare module locations unresolved without inventing URL-relative paths", () => { const result = analyzeCapturedWebBundle( inspection(` diff --git a/src/domain/webBundleAnalyzerAst.test.ts b/src/domain/webBundleAnalyzerAst.test.ts new file mode 100644 index 000000000..b3210d94f --- /dev/null +++ b/src/domain/webBundleAnalyzerAst.test.ts @@ -0,0 +1,27 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js"; +import { objectString } from "./webBundleAnalyzerAst.js"; + +it("reads WebMCP declaration strings through the exact-literal owner", () => { + const file = parseJavaScriptSource( + "const declaration = { name: `catalog`, description: `Browse catalog` }; const dynamic = { name: `catalog ${scope}` };", + ); + const declaration = file?.program.body[0]; + if (!t.isVariableDeclaration(declaration)) + throw new Error("Expected declaration fixture"); + const object = declaration.declarations[0]?.init; + if (!t.isObjectExpression(object)) + throw new Error("Expected object expression fixture"); + const dynamicDeclaration = file?.program.body[1]; + if (!t.isVariableDeclaration(dynamicDeclaration)) + throw new Error("Expected dynamic declaration fixture"); + const dynamic = dynamicDeclaration.declarations[0]?.init; + if (!t.isObjectExpression(dynamic)) + throw new Error("Expected dynamic object expression fixture"); + + expect(objectString(object, "name")).toBe("catalog"); + expect(objectString(object, "description")).toBe("Browse catalog"); + expect(objectString(dynamic, "name")).toBeUndefined(); +}); diff --git a/src/domain/webBundleAnalyzerAst.ts b/src/domain/webBundleAnalyzerAst.ts index 0decf4370..ce98e1b95 100644 --- a/src/domain/webBundleAnalyzerAst.ts +++ b/src/domain/webBundleAnalyzerAst.ts @@ -1,7 +1,10 @@ import * as t from "@babel/types"; import { sanitizeBrowserUrl } from "./browserObservation.js"; -import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js"; +import { + readExactJavaScriptLiteral, + semanticStaticPropertyName, +} from "./javascript/javascriptAstValues.js"; /** Pick the endpoint argument for common network-call patterns. */ @@ -28,7 +31,11 @@ export const objectString = ( name: string, ): string | undefined => { const value = objectValue(object, name); - return t.isStringLiteral(value) ? value.value : undefined; + if (value === undefined) return undefined; + const literal = readExactJavaScriptLiteral(value); + return literal.found && typeof literal.value === "string" + ? literal.value + : undefined; }; /** Whether module syntax supplies a URL location without a package/import-map resolver. */ diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 2c6b8c7ed..2271d2166 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -1,4 +1,3 @@ -import { parse } from "@babel/parser"; import * as t from "@babel/types"; import { sanitizeEndpointCandidate } from "./browserObservation.js"; @@ -29,6 +28,7 @@ import { isUrlLikeModuleSpecifier, } from "./webBundleAnalyzerAst.js"; import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js"; +import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js"; type BundleObservations = WebBundleAnalysis["observations"]; type ChunkEdge = BundleObservations["chunks"]["edges"][number]; @@ -58,16 +58,8 @@ export const analyzeScript = ( script: IncludedScript, accumulator: AnalysisAccumulator, ): void => { - let file: ReturnType; - try { - file = parse(script.source.artifact.text, { - sourceType: "unambiguous", - errorRecovery: true, - plugins: ["jsx", "typescript"], - }); - } catch (cause: unknown) { - // Unparseable scripts are counted; the failure needs no extra detail. - void cause; + const file = parseJavaScriptSource(script.source.artifact.text); + if (file === null) { accumulator.failedScripts.push(script.script_key); return; } @@ -216,11 +208,12 @@ const inspectRouteProperty = ( accumulator: AnalysisAccumulator, ): void => { const key = semanticStaticPropertyName(node.key, node.computed); - if ((key === "path" || key === "route") && t.isStringLiteral(node.value)) + const route = stringValue(node.value); + if ((key === "path" || key === "route") && route !== undefined) addFinding({ collection: accumulator.routes, script, - rawValue: node.value.value, + rawValue: route, mechanism: `property:${key}`, node, accumulator, diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts new file mode 100644 index 000000000..2976625e4 --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts @@ -0,0 +1,76 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { scanCanonicalArtifactInventory } from "../../../fixtures/artifactInventory.js"; +import { readJavaScriptArtifactFiles } from "../../../support/javascriptApplicationScope.js"; +import { createJavaScriptArtifactReader } from "../../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; +import { analyzeJavaScriptArtifactFiles } from "../../../../src/application/javascript/JavaScriptArtifactAnalysis.js"; +import { buildJavaScriptArtifactGraph } from "../../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; + +it("keeps ambiguous endpoint candidates inferred through artifact graph projection", async () => { + const root = await createTestTempDirectory("rea-js-endpoint-certainty-"); + await writeFile(join(root, "app.js"), 'form.get("shared"); fetch("shared");'); + 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); + const endpoints = graph.nodes.filter(({ kind }) => kind === "endpoint"); + const candidate = endpoints.find(({ observations }) => + observations.some( + ({ properties }) => properties.interpretation === "candidate", + ), + ); + const observed = endpoints.find(({ observations }) => + observations.some( + ({ properties }) => + properties.value === "shared" && + properties.interpretation === undefined, + ), + ); + + expect(candidate).toBeDefined(); + expect(observed).toBeDefined(); + expect(candidate?.node_id).not.toBe(observed?.node_id); + const candidateObservation = candidate?.observations[0]; + expect(candidateObservation?.properties).toMatchObject({ + value: "shared", + interpretation: "candidate", + }); + expect(candidateObservation?.evidence).toMatchObject({ + authority: "static-relationship-inference", + state: "inferred", + confidence: "low", + location: { + available: true, + value: { kind: "source-range", source: "app.js" }, + }, + }); + expect(candidateObservation?.evidence.limitations).toEqual( + expect.arrayContaining([expect.stringContaining("only a candidate")]), + ); + expect(observed?.observations[0]?.evidence).toMatchObject({ + authority: "ast-static-analysis", + state: "observed", + confidence: "exact", + }); + const candidateEdge = graph.edges.find( + ({ target_node_id }) => target_node_id === candidate?.node_id, + ); + expect(candidateEdge?.properties.interpretation).toBe("candidate"); + expect(candidateEdge?.evidence).toMatchObject({ + state: "inferred", + confidence: "low", + }); + expect(candidateEdge?.evidence.limitations).toEqual( + expect.arrayContaining([expect.stringContaining("only a candidate")]), + ); + } finally { + await reader.close(); + await rm(root, { recursive: true, force: true }); + } +});