fix(javascript): retain endpoint candidates through graph projection
This commit is contained in:
parent
6e344302cb
commit
88afcc738b
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -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}`,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue