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(