fix(javascript): retain endpoint candidates through graph projection

This commit is contained in:
morluto 2026-10-14 09:47:30 +08:00
parent 6e344302cb
commit 88afcc738b
9 changed files with 226 additions and 27 deletions

View File

@ -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 }),

View File

@ -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],
}
: {}),
});
};

View File

@ -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,
},
]);
});

View File

@ -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}`,
});
};

View File

@ -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(`

View File

@ -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();
});

View File

@ -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. */

View File

@ -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<typeof parse>;
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,

View File

@ -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 });
}
});