mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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"}`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
+47
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user