From 5fbea61e5ab0e2a83cccf0fdbd9f8843644dd681 Mon Sep 17 00:00:00 2001 From: NIKHIL Date: Thu, 22 Oct 2026 23:07:28 +0530 Subject: [PATCH] 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`. --- .../JavaScriptArtifactGraphFindings.ts | 4 +- src/domain/javascript/javascriptRouteScan.ts | 81 ++++++++++++++----- ...riptStaticAnalysis.routeProperties.test.ts | 75 ++++++++++++++--- .../javascriptStaticAnalysisTypes.ts | 5 +- .../javascriptArtifactGraphEndpoints.test.ts | 47 +++++++++++ 5 files changed, 177 insertions(+), 35 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 7794a7768c..8ba22c030c 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -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"}`; diff --git a/src/domain/javascript/javascriptRouteScan.ts b/src/domain/javascript/javascriptRouteScan.ts index 9e8200b4b6..a51a7c455f 100644 --- a/src/domain/javascript/javascriptRouteScan.ts +++ b/src/domain/javascript/javascriptRouteScan.ts @@ -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(); readonly #expressionParents = new WeakMap(); readonly #fieldsCache = new WeakMap(); + // One key scan per object, however many path or route properties it repeats. + readonly #shapeCache = new WeakMap(); 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)) diff --git a/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts b/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts index cc8d60a623..96dfbe86a6 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts @@ -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); diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 33eca1ef36..5dd08ebb2b 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -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"; diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts index 769c96ed0e..16538d92d6 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts @@ -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 }); + } +});