From 4df630e16a99f7d2438ddb982503246f51269d29 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Mon, 9 Nov 2026 17:40:57 +0800 Subject: [PATCH] fix(javascript): keep unisolated program-var route values unknown (#6778) * fix(javascript): mark unisolated program-var route values unproven `JavaScriptRouteScan.#resolve` now records the identifier whenever a value proof depends on a program-scope `var` in a script without a proven isolated runtime. That binding is a global-object property, so `globalThis.NAME` or an alias can replace it after the source initializer. The mark lives in the shared resolver, so every consumer degrades from one fact instead of each one opting in: - path segments and navigation destinations stop asserting the source value and report an unknown interpretation; - an index flag stays unproven, as before; - a route container keeps its source route set and reports an incomplete interpretation, so `var routes=[...]` no longer loses every route. The source initializer is still followed, which is what lets a container consumer keep its facts while disclosing the gap. * fix(javascript): keep literal child routes behind an unisolated children array Rejecting the whole children configuration marked its records as claimed by the interpretation, so the automatic-root loop skipped them and the literal child paths disappeared even though their absolute paths need no parent proof. Keep the source records reachable and let the walker report the unproven ancestry as an interpretation gap. * test(javascript): bound the index-route CLI acceptance matrix The flag matrix runs sixteen CLI invocations in one test and passed at 20.7 s against a 30 s default timeout, so it timed out on a loaded runner. The same matrix is already asserted per case in javascriptRouteScan.test.ts, where each case is an in-process parse; this file keeps one resolved case and one unresolved case to prove both branches of the public CLI view. * fix(javascript): retain ownership of unproven route literals Keep source ownership separate from runtime value proof so rejected program-var route segments cannot reappear through the generic constant pass. Cover path, route, typed and alias forms, retain navigation controls and independent literals, and verify inventory and public CLI projections. Run the existing CLI index cases independently so repeated process startup does not consume one combined test deadline. Preserve both cases and all assertions. * fix(javascript): keep unisolated navigation data out of route literals An unisolated program-var slot holds data, not a route. Refusing its runtime identity released the object back to the independent route scan, which then reported a query payload as a nested route. Report the object as consumed navigation data and stop following it as a path. * fix(javascript): follow rejected navigation data without trusting it Claiming the whole object for a rejected value also claimed its sibling properties, so an independent route in the same object disappeared. Keep the property selection: ownership follows the slot, and only the route consumers refuse an unproven runtime identity. --- .../javascriptRouteInventory.test.ts | 78 +++++++ .../javascript/javascriptRouteScan.test.ts | 194 +++++++++++++++++- src/domain/javascript/javascriptRouteScan.ts | 125 +++++++++-- tests/acceptance/javascriptRoutesCli.test.ts | 155 ++++++++++++++ 4 files changed, 528 insertions(+), 24 deletions(-) diff --git a/src/domain/javascript/javascriptRouteInventory.test.ts b/src/domain/javascript/javascriptRouteInventory.test.ts index de9653feef..543a614897 100644 --- a/src/domain/javascript/javascriptRouteInventory.test.ts +++ b/src/domain/javascript/javascriptRouteInventory.test.ts @@ -39,6 +39,29 @@ const build = (inputs: JavaScriptRouteInventoryInput[], bytes?: number) => completeSemanticSteps(buildJavaScriptRouteInventorySteps(inputs, bytes)), ); +it("keeps rejected program-var paths out of independent route inventory", () => { + const result = build([ + source( + "routes.js", + "var PATH='/reports/stale';globalThis.PATH='/other';createBrowserRouter([{path:PATH},{path:'/reports'}]);const independent='/reports/help';", + ), + ]); + expect(result.routes.map(({ path }) => path)).toEqual([ + "/reports", + "/reports/help", + ]); + expect(result.unknowns).toEqual([ + expect.objectContaining({ + kind: "interpretation", + reason: expect.stringContaining("parent segment is unknown"), + }), + ]); + expect(result.coverage).toMatchObject({ + source_parsing: "complete", + candidate_enumeration: "partial", + }); +}); + it.each([ ["hello world", "hello%20world"], ["team?admin", "team%3Fadmin"], @@ -183,6 +206,61 @@ it("coalesces exact patterns while retaining independent occurrences and stable expect(result.inventory_id).toBe(repeated.inventory_id); expect(result.coverage.application_routes).toBe("unknown"); }); +it("retains resolved index routes with source provenance and complete enumeration", () => { + const result = build([ + source( + "routes.ts", + `const INDEX=true; createBrowserRouter([{index:(INDEX as boolean),Component:Home},{path:'/parent',children:[{index:INDEX,Component:Home}]}]);`, + ), + ]); + expect(result.routes.map(({ path }) => path)).toEqual(["/", "/parent"]); + expect(result.occurrences).toHaveLength(3); + expect(result.occurrences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + route_id: result.routes[0]!.id, + unprefixed_path: "/", + origin: "source-syntax", + mechanism: "nested-route-object", + source_range: expect.objectContaining({ start: expect.any(Object) }), + }), + ]), + ); + expect(result.unknowns).toEqual([]); + expect(result.coverage).toMatchObject({ + source_parsing: "complete", + candidate_enumeration: "complete", + application_routes: "unknown", + }); +}); +it.each([ + "const INDEX=choose();", + "var INDEX=true;globalThis.INDEX=false;", + "var INDEX=true;const host=globalThis;host.INDEX=false;", +])( + "marks unproven index interpretation as partial with child paths: %s", + (prefix) => { + const result = build([ + source( + "routes.js", + `${prefix}createBrowserRouter([{index:INDEX,children:[{path:'child'}]}]);`, + ), + ]); + expect(result.routes.map(({ path }) => path)).toEqual(["/child"]); + expect(result.unknowns).toEqual([ + expect.objectContaining({ + kind: "interpretation", + reason: expect.stringContaining("index-route flag"), + source_range: expect.objectContaining({ start: expect.any(Object) }), + }), + ]); + expect(result.coverage).toMatchObject({ + source_parsing: "complete", + candidate_enumeration: "partial", + application_routes: "unknown", + }); + }, +); it("chooses deterministic primary provenance when sources have equal confidence", () => { const inputs = [ source("one.js", `router.push('/reports');`), diff --git a/src/domain/javascript/javascriptRouteScan.test.ts b/src/domain/javascript/javascriptRouteScan.test.ts index 82c8127c5e..315650997c 100644 --- a/src/domain/javascript/javascriptRouteScan.test.ts +++ b/src/domain/javascript/javascriptRouteScan.test.ts @@ -13,8 +13,8 @@ import { routePath, } from "./javascriptRoutePaths.js"; -const analyze = (source: string, budget?: number) => { - const file = parseJavaScriptSource(source); +const analyze = (source: string, budget?: number, sourcePath?: string) => { + const file = parseJavaScriptSource(source, sourcePath); if (file === null) throw new Error("fixture must parse"); const index = complete( indexJavaScriptBindingsWithReceiversSteps(file.program), @@ -119,6 +119,196 @@ it.each([ ]); }); +it.each([ + ["var INDEX=true;globalThis.INDEX=false;", "INDEX"], + ['var INDEX=true;window["INDEX"]=false;', "INDEX"], + ["var INDEX=true;const host=globalThis;host.INDEX=false;", "INDEX"], + ["var INDEX=false;globalThis.INDEX=true;", "INDEX"], + ["var INDEX=true;const FLAG=INDEX;", "FLAG"], + ["var INDEX=true;", "(INDEX as boolean)"], + [ + "export type Marker = string;var INDEX=true;globalThis.INDEX=false;", + "INDEX", + ], +] as const)( + "keeps script program-var index proofs unknown: %s %s", + (prefix, flag) => { + const facts = analyze( + `${prefix}createBrowserRouter([{index:${flag},children:[{path:'child'}]},{path:'/known'}]);`, + ); + expect(facts.candidates.map(({ path }) => path)).toEqual([ + "/child", + "/known", + ]); + expect(facts.unresolved).toEqual([ + expect.objectContaining({ + reason: expect.stringContaining("index-route flag"), + }), + ]); + }, +); + +it.each([ + "var routes=[{path:'/reports'}];createBrowserRouter(routes);", + "var ROUTES=[{path:'/reports',children:[{path:'child'}]}];createBrowserRouter(ROUTES);", +] as const)( + "keeps a program-var route configuration readable while disclosing the gap: %s", + (source) => { + const facts = analyze(source); + expect(facts.candidates.map(({ path }) => path)).toEqual( + expect.arrayContaining(["/reports"]), + ); + expect(facts.unresolved).toEqual([ + expect.objectContaining({ + reason: expect.stringContaining("unisolated program variable"), + }), + ]); + }, +); + +it.each([ + "var PATH='/a';globalThis.PATH='/b';createBrowserRouter([{path:PATH}]);", + "var PATH='/a';const host=globalThis;host.PATH='/b';createBrowserRouter([{path:PATH}]);", + "var PATH='/a';createBrowserRouter([{path:PATH}]);", +] as const)( + "never asserts an unisolated program-var parent segment: %s", + (source) => { + const facts = analyze(source); + expect(facts.candidates).toEqual([]); + expect(facts.unresolved).toEqual([ + expect.objectContaining({ + reason: expect.stringContaining("parent segment is unknown"), + }), + ]); + }, +); + +it.each([ + "var PATH='/reports/stale';globalThis.PATH='/other';createBrowserRouter([{path:PATH},{path:'/reports'}]);", + "var PATH='/reports/stale';const ALIAS=(PATH as string);createBrowserRouter([{path:ALIAS},{path:'/reports'}]);", + "var PATH='/reports/stale';createBrowserRouter([{route:PATH,component:View},{path:'/reports'}]);", + "var PATH='/reports/stale';defineRoutes({stale:{_$:PATH},known:{_$:'/reports'}});", + "var TARGET='/reports/stale';router.push(TARGET);createBrowserRouter([{path:'/reports'}]);", + "var TARGET='/reports/stale';const NAV={pathname:TARGET};router.push(NAV);createBrowserRouter([{path:'/reports'}]);", +] as const)( + "does not re-admit an unisolated route value as an independent constant: %s", + (source) => { + const facts = analyze(source + "const independent='/reports/help';"); + expect(facts.candidates.map(({ path }) => path).sort()).toEqual([ + "/reports", + "/reports/help", + ]); + expect( + facts.candidates.find(({ path }) => path === "/reports/help"), + ).toMatchObject({ + mechanism: "route-string-constant", + }); + expect(facts.unresolved).not.toEqual([]); + }, +); + +it.each([ + "var SHARED={query:{path:'/data',Component:View}};const HOLD=SHARED;router.push({pathname:'/reports',query:HOLD.query});", + "var SHARED={query:{path:'/data'}};router.push({pathname:'/reports',query:SHARED.query});", +] as const)( + "never promotes unisolated navigation data to a route: %s", + (source) => { + // The slot is data, not a route record. Rejecting its runtime identity must + // not release the object back to the independent route-literal scan. + const facts = analyze(source); + expect(facts.candidates.map(({ path }) => path)).toEqual(["/reports"]); + expect(facts.candidates.some(({ path }) => path === "/data")).toBe(false); + }, +); + +it("keeps unrelated routes that share the unisolated data object", () => { + // Rejecting the runtime identity must not claim the whole source object: + // the sibling route property is independent data and stays reported. + const facts = analyze( + "var SHARED={query:{path:'/data',Component:View},route:{path:'/independent',Component:View}};const HOLD=SHARED;router.push({pathname:'/reports',query:HOLD.query});", + ); + expect(facts.candidates.map(({ path }) => path).sort()).toEqual([ + "/independent", + "/reports", + ]); +}); + +it("keeps literal child routes that an unisolated children array carries", () => { + // The parent proof is unknown, but the child records are still source-owned. + // Only the ancestry is in doubt, so the literal paths must stay reported next + // to the interpretation gap instead of disappearing with the parent. + const facts = analyze( + "var children=[{path:'/kept',Component:View}];const config={path:'/parent',children};", + ); + expect(facts.candidates.map(({ path }) => path)).toEqual([ + "/parent", + "/kept", + ]); + expect(facts.unresolved.map(({ reason }) => reason)).toEqual( + expect.arrayContaining([ + expect.stringContaining("unisolated program variable"), + ]), + ); +}); + +it.each([ + "var TARGET='/t';globalThis.TARGET='/u';router.push(TARGET);", + "var NAV={pathname:'/x'};globalThis.NAV={pathname:'/y'};router.push(NAV);", +] as const)( + "never asserts an unisolated program-var navigation destination: %s", + (source) => { + const facts = analyze(source); + expect(facts.candidates).toEqual([]); + expect(facts.unresolved).toEqual([ + expect.objectContaining({ + reason: expect.stringContaining("unisolated program variable"), + }), + ]); + }, +); + +it.each([ + ["const PATH='/a';globalThis.PATH='/b';", "routes.js"], + ["var PATH='/a';globalThis.PATH='/b';", "routes.mjs"], +] as const)("keeps a proven segment proof: %s %s", (prefix, path) => { + const facts = analyze( + `${prefix}createBrowserRouter([{path:PATH}]);`, + undefined, + path, + ); + expect(facts.candidates.map(({ path }) => path)).toEqual(["/a"]); + expect(facts.unresolved).toEqual([]); +}); + +it.each([ + ["const INDEX=true;globalThis.INDEX=false;", ""], + ["let INDEX=true;globalThis.INDEX=false;", ""], + ["export {};var INDEX=true;globalThis.INDEX=false;", ""], + ["function make(){var INDEX=true;globalThis.INDEX=false;", "}make();"], +] as const)( + "preserves lexical, module and function-local index proofs: %s", + (prefix, suffix) => { + const facts = analyze( + `${prefix}createBrowserRouter([{index:INDEX,children:[{path:'child'}]}]);${suffix}`, + ); + expect(facts.candidates.map(({ path }) => path)).toEqual(["/", "/child"]); + expect(facts.unresolved).toEqual([]); + }, +); + +it.each(["routes.cjs", "routes.cts", "routes.mjs", "routes.mts"])( + "preserves parser-proven runtime isolation for %s", + (path) => { + const facts = analyze( + "var INDEX=true;globalThis.INDEX=false;createBrowserRouter([{index:INDEX}]);", + undefined, + path, + ); + expect(facts.candidates.map(({ path }) => path)).toEqual(["/"]); + expect(facts.unresolved).toEqual([]); + }, +); + it("keeps a known false index flag absent", () => { const facts = analyze( "const INDEX=false;createBrowserRouter([{index:INDEX,Component:Home}]);", diff --git a/src/domain/javascript/javascriptRouteScan.ts b/src/domain/javascript/javascriptRouteScan.ts index 137b772fe9..175ea164b7 100644 --- a/src/domain/javascript/javascriptRouteScan.ts +++ b/src/domain/javascript/javascriptRouteScan.ts @@ -31,6 +31,7 @@ import { } from "./javascriptSemanticAliasSelections.js"; import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; import { semanticObjectPropertyReferences, semanticArrayPropertyReferences, @@ -50,6 +51,16 @@ const range = (node: t.Node): JavaScriptRouteSegment["location"] => { }; type Mechanism = JavaScriptRouteCandidate["mechanism"]; +/** + * A source value plus the identifier whose runtime identity stays unproven. + * A program-scope `var` in a script without a proven isolated runtime is a + * global-object property, so a write through `globalThis` or an alias can + * replace it. Consumers degrade from this shared fact; none opts in to it. + */ +interface RouteValueProof { + readonly node: t.Node; + readonly unproven: t.Identifier | undefined; +} type LegacyRoute = { node: t.Node; value: string; @@ -239,6 +250,7 @@ export class JavaScriptRouteScan { readonly value: t.Node | undefined; readonly pathname: t.Node | undefined; readonly occurrence: t.Node | undefined; + readonly unproven: t.Identifier | undefined; } >(); readonly #automaticRoots = new Map(); @@ -344,21 +356,27 @@ export class JavaScriptRouteScan { if (this.#result.capacity !== null) return; const value = this.#resolve(node, site, this.index); const field = - value !== undefined && t.isObjectExpression(value) - ? this.#fields(value).values.get("pathname")?.value + value !== undefined && t.isObjectExpression(value.node) + ? this.#fields(value.node).values.get("pathname")?.value : undefined; const pathname = field === undefined ? undefined : this.#resolve(field, site, this.index); + // A replaced destination cannot establish a complete navigation path, so + // both the object and its selected `pathname` share the same degrade. + const unproven = value?.unproven ?? pathname?.unproven; this.#navigationValues.set(node, { - value, - pathname, + value: unproven === undefined ? value?.node : undefined, + pathname: unproven === undefined ? pathname?.node : undefined, + unproven, occurrence: - value !== unwrapJavaScriptExpression(node).node + value !== undefined && + value.node !== unwrapJavaScriptExpression(node).node ? node : field !== undefined && - pathname !== unwrapJavaScriptExpression(field).node + pathname !== undefined && + pathname.node !== unwrapJavaScriptExpression(field).node ? field : undefined, }); @@ -652,7 +670,9 @@ export class JavaScriptRouteScan { else if (recognized === false) { // Method spelling alone does not make array/string data a destination. const value = this.#resolve(first, undefined, this.index); - if (value !== undefined) this.#propertyValues.add(value); + // Ownership of a property value does not assert its runtime identity, so + // the source node stays registered even when the proof is unisolated. + if (value !== undefined) this.#propertyValues.add(value.node); } if (name === "addRoute" && node.arguments.length === 2) { // This overload can name a parent rather than a URL. Resolve its record @@ -766,7 +786,11 @@ export class JavaScriptRouteScan { } if (call.namedParent === true) { const record = this.#resolve(first, call.node, index); - if (record !== undefined && t.isFunction(record)) { + if ( + record !== undefined && + record.unproven === undefined && + t.isFunction(record.node) + ) { this.#registeredPath(call.node.arguments[0]!); } else { this.#claimConfiguration(first, index, false); @@ -857,8 +881,15 @@ export class JavaScriptRouteScan { seen = new Set(); while (pending.length > 0 && this.#result.capacity === null) { yield; - const raw = pending.pop()!, - node = this.#resolve(raw, parent, index); + const raw = pending.pop()!; + const resolved = this.#resolve(raw, parent, index); + // The shared resolver keeps the source value when an unisolated program + // variable makes its runtime identity unknown. Route ancestry is + // unproven in that case, but the child records are still source-owned: + // the literal paths inside them do not need the parent proof, and the + // walker already reports the incomplete interpretation. Dropping them + // here would lose routes that the previous behaviour reported. + const node = resolved?.node; if ( node === undefined || (!t.isArrayExpression(node) && !t.isObjectExpression(node)) @@ -904,6 +935,14 @@ export class JavaScriptRouteScan { #navigation(node: t.Node): void { const prepared = this.#navigationValues.get(node); + if (prepared?.unproven !== undefined) { + this.#unknown( + "navigation-target", + node, + "The navigation destination is an unisolated program variable, so a runtime replacement cannot be excluded.", + ); + return; + } const unwrapped = prepared === undefined ? unwrapJavaScriptExpression(node).node @@ -1009,15 +1048,29 @@ export class JavaScriptRouteScan { node: t.Node, site: t.Node | undefined, index: JavaScriptBindingIndex, - ): t.Node | undefined { + ): RouteValueProof | undefined { const seen = new Set(); + let unproven: t.Identifier | undefined; for (;;) { node = unwrapJavaScriptExpression(node).node; - if (!t.isIdentifier(node)) return node; + if (!t.isIdentifier(node)) return { node, unproven }; if (seen.has(node) || !this.#queue(node)) return; seen.add(node); const binding = index.bindingForReferenceAt(node); if (binding === undefined) return; + // A program-scope `var` in a script without a proven isolated runtime is + // a global-object property, so `globalThis.NAME` or an alias can replace + // it. Mark the value unproven at the shared resolver: every consumer + // degrades from the same fact, instead of each one opting in. The source + // initializer is still followed so a container consumer can keep source + // facts and disclose the gap instead of dropping them. + if ( + unproven === undefined && + binding.declarationKinds.has("var") && + index.bindings.scopesById.get(binding.scopeId)?.kind === "program" && + javaScriptRuntimeContext(index.bindings.program) === "unknown" + ) + unproven = node; const read = semanticBindingAtRead(binding, node, index.bindings); if (!read.hasDefiniteInitializer) return; const initializer = semanticStableInitializer( @@ -1130,8 +1183,20 @@ export class JavaScriptRouteScan { } const selected = this.#resolve(node, undefined, this.index); if (selected === undefined) continue; - if (!t.isObjectExpression(selected) && !t.isArrayExpression(selected)) { - for (const branch of this.#selectionBranches(selected)) + // A rejected runtime identity does not stop the property selection: the + // slot is navigation data and the caller needs its ownership. It only + // stops when the selected value would be trusted as a route, which is + // every consumer except the navigation-target data walk. + if ( + selected.unproven !== undefined && + mechanism !== "navigation-target" + ) + continue; + if ( + !t.isObjectExpression(selected.node) && + !t.isArrayExpression(selected.node) + ) { + for (const branch of this.#selectionBranches(selected.node)) if ( !this.#retainSelection(pending, branch, projection, depth, target) ) @@ -1143,7 +1208,7 @@ export class JavaScriptRouteScan { pending.push({ kind: "lookup", node, - receiver: selected, + receiver: selected.node, key, projection, depth, @@ -1364,9 +1429,9 @@ export class JavaScriptRouteScan { }); while (frames.length > 0 && this.#result.capacity === null) { yield; - const frame = frames.pop()!, - node = this.#resolve(frame.node, site, index); - if (node === undefined) { + const frame = frames.pop()!; + const proof = this.#resolve(frame.node, site, index); + if (proof === undefined) { this.#claimConfiguration(frame.node, index, typed, true); this.#unknown( mechanism, @@ -1375,6 +1440,16 @@ export class JavaScriptRouteScan { ); continue; } + // Keep the source route set for a container whose identity is unproven, + // and report the incomplete interpretation instead of silently dropping + // every child under it. + if (proof.unproven !== undefined) + this.#unknown( + mechanism, + frame.node, + "The route configuration comes from an unisolated program variable, so a runtime replacement cannot be excluded and this route set may be incomplete.", + ); + const node = proof.node; let cyclic = false; for ( let parent = frame.ancestry; @@ -1446,8 +1521,13 @@ export class JavaScriptRouteScan { emit = property !== undefined; if (property !== undefined) { const literal = this.#resolve(property.value, site, index); - if (literal !== undefined) this.#propertyValues.add(literal); - const value = literal === undefined ? undefined : stringValue(literal); + // A segment read from an unisolated program variable can be replaced at + // runtime, so it cannot establish a confident parent segment. + const proven = literal?.unproven === undefined ? literal : undefined; + // Source ownership survives rejection of the runtime value proof. + if (literal !== undefined) this.#propertyValues.add(literal.node); + const value = + proven === undefined ? undefined : stringValue(proven.node); if ( value === undefined || (value !== "" && routePath(value, { relative: true }) === undefined) @@ -1464,7 +1544,7 @@ export class JavaScriptRouteScan { previous: value.startsWith("/") ? null : link!, segment: { value, - location: range(literal!), + location: range(proven!.node), origin: "literal", }, inferred: false, @@ -1488,8 +1568,9 @@ export class JavaScriptRouteScan { emit = true; } else if (!typed) { const flag = own.get("index")?.value; - const value = + const proof = flag === undefined ? undefined : this.#resolve(flag, site, index); + const value = proof?.unproven === undefined ? proof?.node : undefined; emit = t.isBooleanLiteral(value) && value.value; if (flag !== undefined && !t.isBooleanLiteral(value)) this.#unknown( diff --git a/tests/acceptance/javascriptRoutesCli.test.ts b/tests/acceptance/javascriptRoutesCli.test.ts index 982d06a2b0..550d418102 100644 --- a/tests/acceptance/javascriptRoutesCli.test.ts +++ b/tests/acceptance/javascriptRoutesCli.test.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { expect, it } from "vitest"; +import { z } from "zod"; import { readFile, writeFile, rm, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -13,6 +14,160 @@ const routeViewFromEvidence = (evidence: unknown) => parseEvidence(evidence).normalized_result, ); +// The full flag matrix (literal, alias, wrapper, dynamic, script/module/commonjs +// program vars, ...) is asserted in javascriptRouteScan.test.ts, where each case +// costs an in-process parse. This file only proves that both branches of the +// public CLI view survive the round trip - a resolved route set and an unresolved +// flag reported through the unknowns collection - so every extra case here pays a +// process spawn and pushes the test into its own timeout. +const indexRouteCases = [ + ["literal", "", "true", "js", ["/"], "module", false], + [ + "script-global", + "var INDEX=true;globalThis.INDEX=false;", + "INDEX", + "js", + [], + "script", + true, + ], +] as const; + +cliTest.for(indexRouteCases)( + "retains resolved index routes and reports unresolved flags through the public CLI: %s", + async ( + [name, prefix, flag, extension, paths, sourceType, partial], + { cli }, + ) => { + const root = await createTestTempDirectory("rea-index-routes-"); + const path = join(root, name); + await mkdir(path); + await writeFile( + join(path, "package.json"), + JSON.stringify({ + name: "index-fixture", + private: true, + type: sourceType === "module" ? "module" : undefined, + }), + ); + const route = `createBrowserRouter([{index:${flag},Component:Home}])`; + await writeFile( + join(path, `routes.${extension}`), + sourceType === "module" + ? `import {createBrowserRouter} from 'react-router';${prefix}export const router=${route};function Home(){return null;}` + : `${prefix}${route};function Home(){return null;}`, + ); + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + path, + "--evidence_output", + join(root, `${name}-evidence.json`), + "--detail", + "summary", + "--view", + JSON.stringify({ + kind: "javascript-routes", + collection: "routes", + fields: ["path"], + }), + "--json", + ], + }); + expect( + response.exitCode, + `${name}: ${response.stdout}\n${response.stderr}`, + ).toBe(0); + const envelope = z.object({ summary: z.unknown() }).parse(response.json); + const view = routeViewFromEvidence(envelope.summary); + expect(view.items, name).toEqual( + paths.map((path) => expect.objectContaining({ path })), + ); + expect(view.source_coverage, name).toMatchObject({ + source_parsing: "complete", + candidate_enumeration: partial ? "partial" : "complete", + application_routes: "unknown", + }); + if (partial) { + const unknowns = await cli.run({ + arguments: [ + "analyze-javascript-application", + path, + "--evidence_output", + join(root, `${name}-unknowns-evidence.json`), + "--detail", + "summary", + "--view", + JSON.stringify({ + kind: "javascript-routes", + collection: "unknowns", + }), + "--json", + ], + }); + expect(unknowns.exitCode, unknowns.stderr).toBe(0); + const unknownEnvelope = z + .object({ summary: z.unknown() }) + .parse(unknowns.json); + expect(routeViewFromEvidence(unknownEnvelope.summary).items).toEqual([ + expect.objectContaining({ + kind: "interpretation", + reason: expect.stringContaining("index-route flag"), + }), + ]); + } + }, +); +cliTest( + "keeps rejected program-var paths unknown through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-program-var-routes-"); + const path = join(root, "application"); + await mkdir(path); + await writeFile( + join(path, "package.json"), + JSON.stringify({ name: "program-var-fixture", private: true }), + ); + await writeFile( + join(path, "routes.js"), + "var PATH='/reports/stale';globalThis.PATH='/other';createBrowserRouter([{path:PATH},{path:'/reports'}]);const independent='/reports/help';", + ); + for (const collection of ["routes", "unknowns"] as const) { + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + path, + "--evidence_output", + join(root, collection + "-evidence.json"), + "--detail", + "summary", + "--view", + JSON.stringify({ kind: "javascript-routes", collection }), + "--json", + ], + }); + expect(response.exitCode, response.stdout + response.stderr).toBe(0); + const envelope = z.object({ summary: z.unknown() }).parse(response.json); + const view = routeViewFromEvidence(envelope.summary); + expect(view.source_coverage).toMatchObject({ + source_parsing: "complete", + candidate_enumeration: "partial", + }); + if (collection === "routes") + expect(view.items).toEqual([ + expect.objectContaining({ path: "/reports" }), + expect.objectContaining({ path: "/reports/help" }), + ]); + else + expect(view.items).toEqual([ + expect.objectContaining({ + kind: "interpretation", + reason: expect.stringContaining("parent segment is unknown"), + }), + ]); + } + }, +); const expectAccountRoutes = (items: unknown[]) => { expect(items).toHaveLength(2); expect(items).toEqual(