fix(javascript): report path data records as route candidates (#6360)

* fix(javascript): report path data records as route candidates

A string `path` or `route` property was reported as an exact, observed
route endpoint whatever object held it, so data records such as
`{ type: "set", path: "updatedAt" }` became routes while a real
`app.get("/x")` stayed a low-confidence candidate.

Keep the property finding, but mark it as a candidate unless its object
has router fields (`component`, `element`, `Component`, `children`, or a
typed `_$` record), using the same shape check that already selects
automatic route roots. Route candidates get a route-specific limitation.

Fixes #6349

* fix(javascript): scan each route record's keys once

`#property` read the enclosing object's keys for every `path` or `route`
property, so an object repeating those keys was scanned quadratically.
Cache the record shape per object and share it with `#object`.
This commit is contained in:
NIKHIL
2026-10-22 23:07:28 +05:30
committed by GitHub
parent 791f6328b0
commit 5fbea61e5a
5 changed files with 177 additions and 35 deletions
@@ -338,7 +338,9 @@ const addEndpoint = (
const candidate = input.value.interpretation === "candidate";
const partial = input.value.resolution === "partial";
const candidateLimitation =
"The unbound method-like argument is only a candidate; its receiver does not establish a network call.";
input.value.kind === "route"
? "Only the property name suggests a route; the enclosing object has no router fields."
: "The unbound method-like argument is only a candidate; its receiver does not establish a network call.";
const partialLimitation =
"The request target is not an exact literal; the endpoint value retains the unresolved source expression.";
const label = `${input.value.kind} ${candidate ? "endpoint candidate" : "endpoint"}`;
+60 -21
View File
@@ -52,7 +52,13 @@ const range = (node: t.Node): JavaScriptRouteSegment["location"] => {
};
type Mechanism = JavaScriptRouteCandidate["mechanism"];
type LegacyRoute = { node: t.Node; value: string; mechanism: string };
type LegacyRoute = {
node: t.Node;
value: string;
mechanism: string;
/** Set when only the property name, not a router record, suggests a route. */
interpretation?: "candidate";
};
interface SegmentLink {
readonly previous: SegmentLink | null;
readonly segment: JavaScriptRouteSegment;
@@ -151,6 +157,34 @@ const isRouteChildKey = (key: string, typed: boolean): boolean =>
typed ? !key.startsWith("_") && !key.startsWith("$") : key === "children";
const isRouteSegmentKey = (key: string | undefined): boolean =>
key === "path" || key === "route" || key === "_$";
interface RouteRecordShape {
readonly typed: boolean;
readonly hasPath: boolean;
readonly hasChildren: boolean;
readonly hasView: boolean;
readonly hasIndex: boolean;
}
/** Read the static keys that identify a router record. */
const routeRecordShape = (node: t.ObjectExpression): RouteRecordShape => {
let typed = false,
hasPath = false,
hasChildren = false,
hasView = false,
hasIndex = false;
for (const property of node.properties) {
if (!t.isObjectProperty(property)) continue;
const key = semanticStaticPropertyName(property.key, property.computed);
typed ||= key === "_$";
hasPath ||= key === "path" || key === "route";
hasChildren ||= key === "children";
hasView ||= key === "element" || key === "Component" || key === "component";
hasIndex ||= key === "index";
}
return { typed, hasPath, hasChildren, hasView, hasIndex };
};
/** A path-bearing record that also names a view, children, or typed route. */
const isRouterRecord = (shape: RouteRecordShape): boolean =>
shape.typed || shape.hasView || shape.hasChildren;
/** Follow data-bearing edges, not references used only to select or describe it. */
const isNavigationDataEdge = (node: t.Node, parent: t.Node | null): boolean => {
if (parent === null) return true;
@@ -221,6 +255,8 @@ export class JavaScriptRouteScan {
readonly #dynamicValues = new WeakMap<t.Node, DynamicRouteValueOwner>();
readonly #expressionParents = new WeakMap<t.Node, t.Node>();
readonly #fieldsCache = new WeakMap<t.ObjectExpression, Fields>();
// One key scan per object, however many path or route properties it repeats.
readonly #shapeCache = new WeakMap<t.ObjectExpression, RouteRecordShape>();
readonly #routeParents = new WeakMap<
t.ObjectExpression,
t.ObjectExpression | null
@@ -445,7 +481,7 @@ export class JavaScriptRouteScan {
this.#dynamicValues.set(node, owner);
// Legacy projections receive the same owned literal observations even when the new facet is full.
if (t.isObjectProperty(node)) {
this.#property(node);
this.#property(node, parent);
return;
}
if (
@@ -533,12 +569,21 @@ export class JavaScriptRouteScan {
} else if (t.isObjectExpression(node)) this.#object(node);
}
#property(node: t.ObjectProperty): void {
#property(node: t.ObjectProperty, parent: t.Node | null): void {
const name = semanticStaticPropertyName(node.key, node.computed);
if (name === "path" || name === "route") {
const value = stringValue(node.value);
if (value !== undefined)
this.onLegacyRoute?.({ node, value, mechanism: `property:${name}` });
this.onLegacyRoute?.({
node,
value,
mechanism: `property:${name}`,
// A path key alone also names data records such as patch operations.
...(t.isObjectExpression(parent) &&
isRouterRecord(this.#shape(parent))
? {}
: { interpretation: "candidate" }),
});
}
if (this.#result.capacity === null && isRouteSegmentKey(name))
this.#propertyValues.add(unwrapJavaScriptExpression(node.value).node);
@@ -629,27 +674,21 @@ export class JavaScriptRouteScan {
);
}
#shape(node: t.ObjectExpression): RouteRecordShape {
const cached = this.#shapeCache.get(node);
if (cached !== undefined) return cached;
const shape = routeRecordShape(node);
this.#shapeCache.set(node, shape);
return shape;
}
#object(node: t.ObjectExpression): void {
// Structured navigation owns these data containers before either automatic
// roots or parent edges are discovered. Callable bodies are already outside
// that ownership at the traversal boundary.
if (this.#dynamicValues.has(node)) return;
let typed = false,
hasPath = false,
hasChildren = false,
hasView = false,
hasIndex = false;
for (const property of node.properties) {
if (this.#result.capacity !== null) break;
if (!t.isObjectProperty(property)) continue;
const key = semanticStaticPropertyName(property.key, property.computed);
typed ||= key === "_$";
hasPath ||= key === "path" || key === "route";
hasChildren ||= key === "children";
hasView ||=
key === "element" || key === "Component" || key === "component";
hasIndex ||= key === "index";
}
if (this.#dynamicValues.has(node) || this.#result.capacity !== null) return;
const { typed, hasPath, hasChildren, hasView, hasIndex } =
this.#shape(node);
// Only router children establish path ancestry. Ordinary object wrappers
// can contain routes without contributing URL segments.
if (!typed && hasChildren && this.#queue(node))
@@ -2,21 +2,24 @@ 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 }) => ({
const routeEndpoints = (source: string) =>
analyzeJavaScriptStaticSource(source)
.endpoints.filter(({ kind }) => kind === "route")
.map(({ value, mechanism, interpretation }) => ({
value,
mechanism,
interpretation,
})),
}));
it("reads exact route-property templates and leaves interpolated values unknown", () => {
expect(
routeEndpoints(`
const routes = [
{ path: \`settings\`, component: Settings },
{ route: \`users/\${userId}\`, component: User },
{ path: "/login", element: login },
];
`),
).toEqual([
{
value: "settings",
@@ -30,3 +33,51 @@ it("reads exact route-property templates and leaves interpolated values unknown"
},
]);
});
it("keeps route properties on router records exact", () => {
expect(
routeEndpoints(`
const routes = [
{ path: "admin", children: [] },
{ _$: "route", path: "typed" },
];
`).map(({ value, interpretation }) => [value, interpretation]),
).toEqual([
["admin", undefined],
["typed", undefined],
]);
});
it("reports path properties outside router records only as candidates", () => {
expect(
routeEndpoints(`
const patch = [{ type: "set", path: "updatedAt", value: 1 }];
const worktree = { path: "", head: "" };
const routes = [{ path: "/bare" }];
`),
).toEqual([
{
value: "updatedAt",
mechanism: "property:path",
interpretation: "candidate",
},
{ value: "", mechanism: "property:path", interpretation: "candidate" },
{
value: "/bare",
mechanism: "property:path",
interpretation: "candidate",
},
]);
});
it("scans an object's route fields once for repeated path keys", () => {
const start = performance.now();
const endpoints = routeEndpoints(
`const record = { ${Array.from({ length: 20_000 }, (_, i) => `path: "p${String(i)}"`).join(", ")} };`,
);
expect(endpoints).toHaveLength(20_000);
expect(
endpoints.every(({ interpretation }) => interpretation === "candidate"),
).toBe(true);
expect(performance.now() - start).toBeLessThan(2000);
}, 30000);
@@ -46,7 +46,10 @@ export interface JavaScriptStaticEndpoint {
readonly kind: "route" | "network";
readonly value: string;
readonly mechanism: string;
/** An overloaded callee name suggests this value; its network receiver is unproven. */
/**
* Only a name suggests this value: an overloaded callee whose network receiver
* is unproven, or a `path`/`route` property outside a router record.
*/
readonly interpretation?: "candidate";
/** The request target is unresolved source text rather than an exact value. */
readonly resolution?: "partial";
@@ -125,3 +125,50 @@ it("preserves partial and exact endpoint identity through graph projection", asy
await rm(root, { recursive: true, force: true });
}
});
it("projects path data records as inferred route candidates", async () => {
const root = await createTestTempDirectory("rea-js-route-property-");
await writeFile(
join(root, "app.js"),
'const ops = [{ type: "set", path: "updatedAt", value: 1 }];\n' +
'const routes = [{ path: "/settings", component: Settings }];',
);
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 route = (value: string) =>
graph.nodes
.find(
({ kind, observations }) =>
kind === "endpoint" &&
observations.some(({ properties }) => properties.value === value),
)
?.observations.find(({ properties }) => properties.value === value);
expect(route("updatedAt")?.properties).toMatchObject({
endpoint_kind: "route",
mechanism: "property:path",
interpretation: "candidate",
});
expect(route("updatedAt")?.evidence).toMatchObject({
authority: "static-relationship-inference",
state: "inferred",
confidence: "low",
});
expect(route("updatedAt")?.evidence.limitations).toEqual(
expect.arrayContaining([expect.stringContaining("no router fields")]),
);
expect(route("/settings")?.properties.interpretation).toBeUndefined();
expect(route("/settings")?.evidence).toMatchObject({
authority: "ast-static-analysis",
state: "observed",
confidence: "exact",
});
} finally {
await reader.close();
await rm(root, { recursive: true, force: true });
}
});