@skyramp/mcp 0.3.8 → 0.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -1
  2. package/build/commands/commandLibrary.js +3 -3
  3. package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
  4. package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
  5. package/build/commands/testThisEndpointCommand.js +35 -19
  6. package/build/index.js +9 -3
  7. package/build/playwright/blueprintDigest.d.ts +15 -0
  8. package/build/playwright/blueprintDigest.js +152 -0
  9. package/build/playwright/blueprintDigestStore.d.ts +31 -0
  10. package/build/playwright/blueprintDigestStore.js +117 -0
  11. package/build/playwright/registerPlaywrightTools.js +60 -12
  12. package/build/playwright/traceRecordingPrompt.js +8 -7
  13. package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
  14. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
  15. package/build/prompts/promptAssets.d.ts +20 -0
  16. package/build/prompts/promptAssets.js +55 -0
  17. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
  18. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  19. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
  23. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
  24. package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
  25. package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
  26. package/build/prompts/test-recommendation/recommendationSections.js +67 -309
  27. package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
  28. package/build/prompts/test-recommendation/recommendationShared.js +49 -155
  29. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
  30. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
  31. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
  32. package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
  33. package/build/prompts/testbot/planDeclarations.d.ts +6 -0
  34. package/build/prompts/testbot/planDeclarations.js +9 -0
  35. package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
  36. package/build/prompts/testbot/testbot-prompts.js +256 -381
  37. package/build/recommendation/answers.d.ts +35 -0
  38. package/build/recommendation/answers.js +96 -0
  39. package/build/recommendation/registerPlan.d.ts +49 -0
  40. package/build/recommendation/registerPlan.js +117 -0
  41. package/build/recommendation/runVerifiers.d.ts +10 -0
  42. package/build/recommendation/runVerifiers.js +49 -0
  43. package/build/recommendation/subjectStep.d.ts +42 -0
  44. package/build/recommendation/subjectStep.js +86 -0
  45. package/build/recommendation/types.d.ts +163 -0
  46. package/build/recommendation/types.js +20 -0
  47. package/build/recommendation/verifierContracts.d.ts +382 -0
  48. package/build/recommendation/verifierContracts.js +263 -0
  49. package/build/recommendation/verifiers/changedFile.d.ts +2 -0
  50. package/build/recommendation/verifiers/changedFile.js +82 -0
  51. package/build/recommendation/verifiers/citedPath.d.ts +12 -0
  52. package/build/recommendation/verifiers/citedPath.js +35 -0
  53. package/build/recommendation/verifiers/coverage.d.ts +7 -0
  54. package/build/recommendation/verifiers/coverage.js +617 -0
  55. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
  56. package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
  57. package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
  58. package/build/recommendation/verifiers/endpointGrounded.js +128 -0
  59. package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
  60. package/build/recommendation/verifiers/existingCoverage.js +51 -0
  61. package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
  62. package/build/recommendation/verifiers/expectedOutcome.js +105 -0
  63. package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
  64. package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
  65. package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
  66. package/build/recommendation/verifiers/reportedCategory.js +84 -0
  67. package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
  68. package/build/recommendation/verifiers/screenRoute.js +118 -0
  69. package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
  70. package/build/recommendation/verifiers/statedDifference.js +140 -0
  71. package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
  72. package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
  73. package/build/resources/analysisResources.js +1 -114
  74. package/build/resources/testbotResource.js +23 -13
  75. package/build/services/ModularizationService.js +2 -1
  76. package/build/services/TestDiscoveryService.d.ts +3 -72
  77. package/build/services/TestDiscoveryService.js +10 -303
  78. package/build/services/containerEnv.d.ts +1 -1
  79. package/build/services/containerEnv.js +12 -0
  80. package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
  81. package/build/skills/fixTestImportErrorsSkill.js +20 -0
  82. package/build/toolNames.d.ts +1 -0
  83. package/build/toolNames.js +1 -0
  84. package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
  85. package/build/tools/code-refactor/modularizationTool.js +2 -1
  86. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  87. package/build/tools/executeSkyrampTestTool.js +246 -19
  88. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
  89. package/build/tools/generate-tests/generateContractRestTool.js +3 -3
  90. package/build/tools/generate-tests/planGuard.d.ts +2 -2
  91. package/build/tools/generate-tests/planGuard.js +78 -18
  92. package/build/tools/one-click/oneClickTool.d.ts +0 -1
  93. package/build/tools/one-click/oneClickTool.js +0 -5
  94. package/build/tools/submitReportTool.d.ts +48 -42
  95. package/build/tools/submitReportTool.js +576 -193
  96. package/build/tools/test-management/actionsTool.js +72 -4
  97. package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
  98. package/build/tools/test-management/analyzeChangesTool.js +212 -1219
  99. package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
  100. package/build/tools/test-management/index.d.ts +1 -0
  101. package/build/tools/test-management/index.js +1 -0
  102. package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
  103. package/build/tools/test-management/registerTestPlanTool.js +609 -542
  104. package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
  105. package/build/tools/test-management/resolveScreenTool.js +289 -0
  106. package/build/types/BlueprintDigest.d.ts +34 -0
  107. package/build/types/BlueprintDigest.js +1 -0
  108. package/build/types/RepositoryAnalysis.d.ts +20 -1559
  109. package/build/types/RepositoryAnalysis.js +2 -58
  110. package/build/types/StepMethod.d.ts +40 -0
  111. package/build/types/StepMethod.js +77 -0
  112. package/build/types/TestAnalysis.d.ts +12 -0
  113. package/build/types/TestExecution.d.ts +4 -0
  114. package/build/types/TestRecommendation.d.ts +24 -24
  115. package/build/types/TestRecommendation.js +91 -89
  116. package/build/types/TestbotPromptOptions.d.ts +0 -4
  117. package/build/types/TestbotReport.d.ts +64 -2
  118. package/build/utils/AnalysisStateManager.d.ts +79 -113
  119. package/build/utils/AnalysisStateManager.js +147 -57
  120. package/build/utils/assertion-verify/api-shared-lints.js +1 -1
  121. package/build/utils/assertion-verify/metrics.js +85 -36
  122. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  123. package/build/utils/assertion-verify/ui-lints.js +32 -0
  124. package/build/utils/branchDiff.d.ts +63 -31
  125. package/build/utils/branchDiff.js +242 -94
  126. package/build/utils/containedPath.d.ts +18 -0
  127. package/build/utils/containedPath.js +73 -0
  128. package/build/utils/dartRouteExtractor.d.ts +18 -34
  129. package/build/utils/dartRouteExtractor.js +101 -173
  130. package/build/utils/featureFlags.d.ts +12 -0
  131. package/build/utils/featureFlags.js +14 -0
  132. package/build/utils/frontendSelectors.d.ts +48 -27
  133. package/build/utils/frontendSelectors.js +241 -80
  134. package/build/utils/pathMatching.d.ts +2 -4
  135. package/build/utils/pathMatching.js +2 -4
  136. package/build/utils/planMatchKeys.d.ts +38 -47
  137. package/build/utils/planMatchKeys.js +143 -81
  138. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  139. package/build/utils/rebaselineSnapshots.js +65 -0
  140. package/build/utils/removedUiElements.d.ts +22 -0
  141. package/build/utils/removedUiElements.js +106 -0
  142. package/build/utils/reportVerification.d.ts +2 -6
  143. package/build/utils/reportVerification.js +61 -2
  144. package/build/utils/screenRoutes.d.ts +66 -0
  145. package/build/utils/screenRoutes.js +727 -0
  146. package/build/utils/sourceRouteExtractor.js +320 -112
  147. package/build/utils/testFileClassification.d.ts +11 -2
  148. package/build/utils/testFileClassification.js +44 -2
  149. package/build/utils/testFixtures.d.ts +5 -0
  150. package/build/utils/testFixtures.js +13 -0
  151. package/build/utils/utils.d.ts +0 -1
  152. package/build/utils/utils.js +0 -11
  153. package/build/utils/versions.d.ts +3 -3
  154. package/build/utils/versions.js +1 -1
  155. package/build/workspace/workspace.d.ts +12 -12
  156. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  157. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  158. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  160. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  161. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  162. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  168. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  171. package/node_modules/playwright/package.json +1 -1
  172. package/package.json +4 -3
  173. package/plugin/.claude-plugin/plugin.json +8 -0
  174. package/plugin/plugin.json +6 -0
  175. package/plugin/prompts/declaring-a-plan.md +20 -0
  176. package/plugin/prompts/generate-tests/context-fetching.md +4 -0
  177. package/plugin/prompts/generate-tests/execution-plan.md +63 -0
  178. package/plugin/prompts/generate-tests/generation.md +108 -0
  179. package/plugin/prompts/generate-tests/path-parameters.md +1 -0
  180. package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
  181. package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
  182. package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
  183. package/plugin/prompts/plan-tests.md +42 -0
  184. package/plugin/prompts/testbot-task1.md +82 -0
  185. package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
  186. package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
  187. package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
  188. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
  189. package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
  190. package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
  191. package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
  192. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
  193. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
  194. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
  195. package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
  196. package/build/recommendation/budgeters/shared.d.ts +0 -32
  197. package/build/recommendation/budgeters/shared.js +0 -246
  198. package/build/recommendation/discriminators.d.ts +0 -37
  199. package/build/recommendation/discriminators.js +0 -379
  200. package/build/recommendation/diversity.d.ts +0 -47
  201. package/build/recommendation/diversity.js +0 -101
  202. package/build/recommendation/planRanker.d.ts +0 -65
  203. package/build/recommendation/planRanker.js +0 -83
  204. package/build/recommendation/testFixtures.d.ts +0 -25
  205. package/build/recommendation/testFixtures.js +0 -45
  206. package/build/types/FrontendIntegration.d.ts +0 -28
  207. package/build/types/FrontendIntegration.js +0 -22
  208. package/build/types/Recommendation.d.ts +0 -146
  209. package/build/types/Recommendation.js +0 -74
  210. package/build/utils/changedRoutes.d.ts +0 -29
  211. package/build/utils/changedRoutes.js +0 -87
  212. package/build/utils/frontendIntegration.d.ts +0 -9
  213. package/build/utils/frontendIntegration.js +0 -243
  214. package/build/utils/importerHop.d.ts +0 -135
  215. package/build/utils/importerHop.js +0 -489
  216. package/build/utils/pathAffinityClassification.d.ts +0 -49
  217. package/build/utils/pathAffinityClassification.js +0 -180
  218. package/build/utils/pythonMountPrefixes.d.ts +0 -25
  219. package/build/utils/pythonMountPrefixes.js +0 -347
  220. package/build/utils/repoScanner.d.ts +0 -34
  221. package/build/utils/repoScanner.js +0 -300
  222. package/build/utils/routeParsers.d.ts +0 -95
  223. package/build/utils/routeParsers.js +0 -951
  224. package/build/utils/scenarioDrafting.d.ts +0 -92
  225. package/build/utils/scenarioDrafting.js +0 -951
  226. package/build/utils/subjectEndpoints.d.ts +0 -19
  227. package/build/utils/subjectEndpoints.js +0 -98
  228. package/build/utils/uiPageEnumerator.d.ts +0 -172
  229. package/build/utils/uiPageEnumerator.js +0 -474
@@ -0,0 +1,727 @@
1
+ /**
2
+ * Which route patterns render a given frontend file — SKYR-4341.
3
+ *
4
+ * A UI test names the file it is about and the page it opens. Nothing used to
5
+ * compare the two, so a run could name `SecretDetailSidebar.tsx`, open
6
+ * `/overview`, and spend its budget hunting an element that page never renders.
7
+ * This module answers the missing question: what URL does that file actually
8
+ * appear on?
9
+ *
10
+ * THREE ANSWERS, and the difference between them is the whole design:
11
+ * - a non-empty list — these route patterns mount the file, spelled the way the
12
+ * framework spells them (`/secrets/$envSlug`, `/orders/:id`);
13
+ * - an empty list — this scan understands this repository and nothing mounts it;
14
+ * - `undefined` — this scan cannot speak for this repository or this file.
15
+ * It NEVER guesses. There is no root-URL fallback, because both the plan check and
16
+ * the agent treat the answer as fact, and a wrong route is worse than no route:
17
+ * it sends the browser to a page with the server's authority behind it.
18
+ *
19
+ * The walk is TRANSITIVE, and that is the new part. The route entry file rarely
20
+ * renders the changed file directly — the motivating case is four hops
21
+ * (`route.tsx` -> `SecretDashboardPage.tsx` -> a barrel -> `SecretListView.tsx` ->
22
+ * `SecretDetailSidebar.tsx`). A per-file `git grep` per hop does not scale to that,
23
+ * so the reverse import index is built ONCE per repository and then walked
24
+ * breadth-first from the changed file up to the route entry files.
25
+ *
26
+ * Route tables come from two readers: TanStack Router's generated
27
+ * `routeTree.gen.ts`, which holds both halves of the mapping and needs no parser,
28
+ * and `sourceRouteExtractor` for React Router / Vue Router. A repository neither
29
+ * reader recognizes yields `undefined`, not an empty list — and so does any file
30
+ * that answers nothing in a repository whose table is knowably missing an entry,
31
+ * see {@link collectRouteEntries}.
32
+ */
33
+ import * as fs from "fs";
34
+ import * as path from "path";
35
+ import { extractDartRoutes } from "./dartRouteExtractor.js";
36
+ import { collectFiles } from "./fileWalk.js";
37
+ import { buildPathSignatures, isIndexModule } from "./pathSignatures.js";
38
+ import { extractSourceRoutes } from "./sourceRouteExtractor.js";
39
+ /** Files that can import, and therefore render, a frontend module. */
40
+ const FRONTEND_SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte|dart)$/i;
41
+ const SKIP_DIRS = new Set([
42
+ "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", "coverage", "vendor",
43
+ ]);
44
+ /** Walk cap. Hitting it means the index is incomplete, and an incomplete index
45
+ * cannot honestly say "nothing mounts this" — the whole repository answers
46
+ * `undefined` instead. Sized well above the largest frontend we scan. */
47
+ const MAX_FILES = 20000;
48
+ /** A file larger than this is a bundle or a generated blob, not a component that
49
+ * imports one. `routeTree.gen.ts` is exempt: it IS the generated route table. */
50
+ const MAX_FILE_BYTES = 512 * 1024;
51
+ const MAX_ROUTE_TREE_BYTES = 8 * 1024 * 1024;
52
+ /** Import hops walked upward from the changed file. Beyond this a component is
53
+ * shared infrastructure rather than part of one screen. */
54
+ const MAX_DEPTH = 8;
55
+ /** Files visited by one BFS. Truncation, like a truncated walk, forces `undefined`. */
56
+ const MAX_VISITED = 4000;
57
+ /** Above this a file is a shell, a layout or a design-system primitive: it renders
58
+ * on so many screens that "the test opened the wrong one" is not a claim this
59
+ * check can make. Saying `undefined` keeps it silent instead of wrong. */
60
+ const MAX_ROUTES = 25;
61
+ /** Import specifiers, in the four forms a frontend module writes them. */
62
+ const SPECIFIER_RE = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s+)(['"`])([^'"`]+)\1/g;
63
+ // ── Path helpers ────────────────────────────────────────────────────────────
64
+ function toPosix(value) {
65
+ return value.replace(/\\/g, "/");
66
+ }
67
+ function stripExtension(file) {
68
+ return /\.d\.ts$/i.test(file) ? file.slice(0, -5) : file.slice(0, file.length - path.posix.extname(file).length);
69
+ }
70
+ /** The strings an import specifier can use to name this file: the file itself, the
71
+ * file without its extension, and for an `index.*` module its directory. */
72
+ function modulePaths(file) {
73
+ const paths = [file, stripExtension(file)];
74
+ if (isIndexModule(file))
75
+ paths.push(path.posix.dirname(file));
76
+ return paths;
77
+ }
78
+ /** Repository-relative posix path, or undefined when the input escapes the
79
+ * repository. The caller hands us agent-written and scanner-written paths alike. */
80
+ function normalizeRelative(relativePath) {
81
+ if (typeof relativePath !== "string")
82
+ return undefined;
83
+ const raw = toPosix(relativePath.trim()).replace(/^\.\//, "");
84
+ if (!raw || path.posix.isAbsolute(raw))
85
+ return undefined;
86
+ const normalized = path.posix.normalize(raw);
87
+ if (normalized.startsWith(".."))
88
+ return undefined;
89
+ return normalized;
90
+ }
91
+ // ── Import-edge resolution ──────────────────────────────────────────────────
92
+ /** The lookup key `buildPathSignatures` produces, minus the quote character. A
93
+ * signature is `<stem><quote>`; the quote is what makes it precise inside a grep,
94
+ * and this index is keyed by the stem instead because the specifier is already
95
+ * isolated by the time we get here. */
96
+ function stemKeys(file) {
97
+ return [...new Set(buildPathSignatures(file).map((signature) => signature.slice(0, -1)))];
98
+ }
99
+ /**
100
+ * The candidate repository paths an import specifier could name.
101
+ *
102
+ * A relative specifier resolves against the importer's directory and is exact. A
103
+ * prefixed one (`@app/x`, `~/x`, `src/x`) is a tsconfig/bundler alias whose target
104
+ * root this module deliberately does not read: instead the alias is stripped and
105
+ * the remainder is matched as a path SUFFIX, with the importer required to live
106
+ * under the same root — so `packages/admin/...` importing `@app/components/Row`
107
+ * cannot bind to `packages/store/components/Row.tsx`.
108
+ *
109
+ * `@scope/name` is also the shape of a scoped npm package, and stripping the scope
110
+ * off one leaves a single bare segment (`themes` from `@radix-ui/themes`) that the
111
+ * suffix match will happily bind to a top-level source file of that name. So the
112
+ * scope is only stripped when what remains is itself a path — two segments or more.
113
+ * `@/x` is exempt: no package can be named that, so it is always an alias.
114
+ */
115
+ function specifierCandidates(spec) {
116
+ const clean = spec.replace(/\/+$/, "");
117
+ if (!clean)
118
+ return { relative: false, paths: [] };
119
+ if (clean.startsWith("."))
120
+ return { relative: true, paths: [clean] };
121
+ const segments = clean.split("/");
122
+ // A single-segment specifier with no prefix is a package name (`react`), never a
123
+ // repository path. Multi-segment ones can be either, and the shared-root check
124
+ // below is what keeps `lodash/get` from binding to a source file.
125
+ if (segments.length === 1)
126
+ return { relative: false, paths: [] };
127
+ const paths = [clean];
128
+ const scopedPackageShape = segments[0].length > 1 && segments[0].startsWith("@") && segments.length < 3;
129
+ if (!scopedPackageShape && (/^[@~#]/.test(segments[0]) || segments[0] === "src")) {
130
+ paths.push(segments.slice(1).join("/"));
131
+ }
132
+ return { relative: false, paths };
133
+ }
134
+ /** Whether `importer`'s specifier names `candidate`. */
135
+ function specifierNames(importer, spec, candidate) {
136
+ const { relative, paths } = specifierCandidates(spec);
137
+ if (paths.length === 0)
138
+ return false;
139
+ const targets = modulePaths(candidate);
140
+ if (relative) {
141
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(importer), paths[0]));
142
+ return targets.includes(resolved);
143
+ }
144
+ for (const wanted of paths) {
145
+ for (const target of targets) {
146
+ if (target === wanted)
147
+ return true;
148
+ if (target.endsWith("/" + wanted)) {
149
+ const root = target.slice(0, target.length - wanted.length - 1);
150
+ if (importer.startsWith(root + "/"))
151
+ return true;
152
+ }
153
+ }
154
+ }
155
+ return false;
156
+ }
157
+ // ── Index construction ──────────────────────────────────────────────────────
158
+ function readFileSafely(absolute, maxBytes) {
159
+ try {
160
+ if (fs.statSync(absolute).size > maxBytes)
161
+ return undefined;
162
+ return fs.readFileSync(absolute, "utf8");
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
168
+ function collectFrontendFiles(repositoryPath) {
169
+ const absolute = collectFiles(repositoryPath, {
170
+ match: (entry) => FRONTEND_SOURCE_EXT.test(entry.name),
171
+ limit: MAX_FILES,
172
+ shouldSkipDir: (entry) => SKIP_DIRS.has(entry.name) || entry.name.startsWith("."),
173
+ });
174
+ // Exactly `MAX_FILES` means the walk was cut short (`collectFiles` stops at the
175
+ // cap), so the index would be missing files that might be importers.
176
+ if (absolute.length === 0 || absolute.length >= MAX_FILES)
177
+ return undefined;
178
+ return absolute.map((file) => toPosix(path.relative(repositoryPath, file)));
179
+ }
180
+ /**
181
+ * TanStack Router's generated route tree, read rather than parsed.
182
+ *
183
+ * The generator writes both halves of the mapping into one file: an `import
184
+ * { Route as <ident> } from '<module>'` line per entry file, and one block per
185
+ * route carrying `fullPath` (already stripped of pathless `_layout` segments) and
186
+ * `preLoaderRoute: typeof <ident>`. Route pattern -> entry file is therefore two
187
+ * lookups, with no need to interpret `createFileRoute` calls. Absent the generated
188
+ * file there is no TanStack answer — hand-parsing the route files is a different
189
+ * job than reading a table that is already committed.
190
+ */
191
+ function readTanStackRouteTree(repositoryPath, treeFile, filesByStem) {
192
+ const content = readFileSafely(path.join(repositoryPath, treeFile), MAX_ROUTE_TREE_BYTES);
193
+ if (!content)
194
+ return [];
195
+ const dir = path.posix.dirname(treeFile);
196
+ const entryFileByIdent = new Map();
197
+ const blocks = [];
198
+ let pendingId;
199
+ let pendingFullPath;
200
+ for (const line of content.split("\n")) {
201
+ const imported = /^\s*import\s*\{\s*Route as (\w+)\s*\}\s*from\s*['"]([^'"]+)['"]/.exec(line);
202
+ if (imported) {
203
+ const target = resolveWithin(dir, imported[2], filesByStem);
204
+ if (target)
205
+ entryFileByIdent.set(imported[1], target);
206
+ continue;
207
+ }
208
+ const id = /^\s*id:\s*'([^']*)'/.exec(line);
209
+ if (id) {
210
+ pendingId = id[1];
211
+ continue;
212
+ }
213
+ const fullPath = /^\s*fullPath:\s*'([^']*)'/.exec(line);
214
+ if (fullPath) {
215
+ pendingFullPath = fullPath[1];
216
+ continue;
217
+ }
218
+ const preLoader = /^\s*preLoaderRoute:\s*typeof\s+(\w+)/.exec(line);
219
+ if (preLoader && pendingId !== undefined && pendingFullPath !== undefined) {
220
+ blocks.push({ id: pendingId, fullPath: pendingFullPath, ident: preLoader[1] });
221
+ pendingId = undefined;
222
+ pendingFullPath = undefined;
223
+ }
224
+ }
225
+ const entries = [];
226
+ for (const block of blocks) {
227
+ const entryFile = entryFileByIdent.get(block.ident);
228
+ if (!entryFile)
229
+ continue;
230
+ if (block.fullPath) {
231
+ entries.push({ pattern: block.fullPath, entryFile });
232
+ continue;
233
+ }
234
+ // A pathless layout has no URL of its own; it renders on every route nested
235
+ // under it. Reporting nothing for it would read as "nothing mounts this file",
236
+ // which is the one answer that is definitely wrong for a layout.
237
+ for (const nested of blocks) {
238
+ if (nested.fullPath && nested.id.startsWith(block.id + "/"))
239
+ entries.push({ pattern: nested.fullPath, entryFile });
240
+ }
241
+ }
242
+ return entries;
243
+ }
244
+ /** The config file that proves a framework is configured, rather than that a
245
+ * `pages/` directory happens to exist. A React app with a conventional `pages/`
246
+ * folder routes in code, and reading it as Next.js invents every URL. */
247
+ const FRAMEWORK_BY_CONFIG = new Map([
248
+ ["next.config.js", "next"],
249
+ ["next.config.mjs", "next"],
250
+ ["next.config.cjs", "next"],
251
+ ["next.config.ts", "next"],
252
+ ["nuxt.config.js", "nuxt"],
253
+ ["nuxt.config.mjs", "nuxt"],
254
+ ["nuxt.config.ts", "nuxt"],
255
+ ["svelte.config.js", "sveltekit"],
256
+ ["svelte.config.mjs", "sveltekit"],
257
+ ["svelte.config.ts", "sveltekit"],
258
+ ]);
259
+ /** Page file extensions Next.js compiles. */
260
+ const NEXT_PAGE_EXT = /\.(tsx|jsx|ts|js|mjs)$/;
261
+ /**
262
+ * Which framework routes which part of the repository, as directory -> framework,
263
+ * the directory being repository-relative posix and `""` for the root.
264
+ *
265
+ * Reads the file list the index already walked, so it inherits that walk's skip
266
+ * list and caps and costs no I/O. Locating the config beats a list of likely
267
+ * directories: a frontend under `packages/` is as common as one under `apps/`,
268
+ * and the config's directory is also the only honest bound on which files the
269
+ * framework owns when a repository holds more than one app.
270
+ *
271
+ * Exported for the convention matrix in the tests; nothing else calls it.
272
+ */
273
+ export function detectFrameworkRoots(files) {
274
+ const roots = new Map();
275
+ for (const file of files) {
276
+ const segments = file.split("/");
277
+ const framework = FRAMEWORK_BY_CONFIG.get(segments[segments.length - 1]);
278
+ if (!framework)
279
+ continue;
280
+ // Two frameworks in one directory cannot be read unambiguously; the first in
281
+ // the file list wins, and that list is stable.
282
+ const dir = segments.slice(0, -1).join("/");
283
+ if (!roots.has(dir))
284
+ roots.set(dir, framework);
285
+ }
286
+ return roots;
287
+ }
288
+ /** Directories a Flutter `pubspec.yaml` sits in, plus one level of fan-out under
289
+ * `apps/` and `packages/` for a monorepo. */
290
+ const PUBSPEC_DIRS = [".", "app", "mobile", "frontend", "web", "client"];
291
+ const PUBSPEC_MONOREPO_PARENTS = ["apps", "packages"];
292
+ /**
293
+ * The Flutter apps in the repository, as package name -> the directory holding
294
+ * their pubspec (repository-relative posix, `""` for the root). Empty means no
295
+ * Flutter app, which is what gates reading `.dart` files as routes at all: a
296
+ * pure-Dart project — a CLI tool, a server — has a pubspec with no Flutter SDK
297
+ * line and must not have its files read as screens.
298
+ *
299
+ * Ported from `hasFlutterSdkDep` in `scopeAssessment.ts`, which the v2 branch
300
+ * deleted, and widened to return the package name because `package:` imports
301
+ * cannot be resolved without it. Matching the `sdk: flutter` line beats parsing
302
+ * the YAML: it is the one line unique to a Flutter project.
303
+ */
304
+ function flutterPackages(repositoryPath) {
305
+ const found = new Map();
306
+ const readPubspec = (dir) => {
307
+ const content = readFileSafely(path.join(repositoryPath, dir, "pubspec.yaml"), MAX_FILE_BYTES);
308
+ if (content === undefined || !/^\s+sdk:\s*flutter\s*$/m.test(content))
309
+ return;
310
+ const name = /^name:\s*(\S+)\s*$/m.exec(content);
311
+ if (name)
312
+ found.set(name[1], dir === "." ? "" : toPosix(dir));
313
+ };
314
+ for (const dir of PUBSPEC_DIRS)
315
+ readPubspec(dir);
316
+ for (const parent of PUBSPEC_MONOREPO_PARENTS) {
317
+ let entries;
318
+ try {
319
+ entries = fs.readdirSync(path.join(repositoryPath, parent), { withFileTypes: true });
320
+ }
321
+ catch {
322
+ continue;
323
+ }
324
+ for (const entry of entries) {
325
+ if (entry.isDirectory())
326
+ readPubspec(path.join(parent, entry.name));
327
+ }
328
+ }
329
+ return found;
330
+ }
331
+ /** The repository path a `package:` import names — `package:myapp/x.dart` is
332
+ * `<myapp's directory>/lib/x.dart`. Rewriting it lets the ordinary specifier
333
+ * match find the file; without this a widget imported that way reads as
334
+ * unmounted. Relative Dart imports need no help. */
335
+ function dartPackagePath(spec, packages) {
336
+ if (!spec.startsWith("package:"))
337
+ return undefined;
338
+ const slash = spec.indexOf("/");
339
+ if (slash < 0)
340
+ return undefined;
341
+ const dir = packages.get(spec.slice("package:".length, slash));
342
+ if (dir === undefined)
343
+ return undefined;
344
+ return (dir === "" ? "" : dir + "/") + "lib/" + spec.slice(slash + 1);
345
+ }
346
+ /**
347
+ * The URL `relativePath` serves under `framework`, or undefined when the file is
348
+ * not a page in that framework's convention. `relativePath` is relative to the
349
+ * framework root, not to the repository.
350
+ *
351
+ * The conventions are kept PER FRAMEWORK because they disagree. Route groups
352
+ * `(shop)` vanish from the URL in the Next app router and in SvelteKit, and are a
353
+ * literal directory name in the Next pages router and in Nuxt. An underscore
354
+ * names a parameter in Nuxt 2 and marks a non-route file in Next. Pooling the
355
+ * rules produced URLs no framework serves.
356
+ *
357
+ * Exported for the convention matrix in the tests; `fileSystemRouteEntries` is
358
+ * the only caller in this module.
359
+ */
360
+ export function frameworkRouteFor(relativePath, framework) {
361
+ // Next.js and Nuxt accept their routing directory at the root or under `src/`,
362
+ // and neither spelling appears in the URL.
363
+ const below = relativePath.startsWith("src/") ? relativePath.slice(4) : relativePath;
364
+ switch (framework) {
365
+ case "next":
366
+ return nextRoute(below);
367
+ case "nuxt":
368
+ return nuxtRoute(below);
369
+ case "sveltekit":
370
+ return svelteKitRoute(below);
371
+ }
372
+ }
373
+ /** `[id]`, `[...slug]` and `[[...slug]]` all name one parameter. The optional
374
+ * catch-all has to be matched first, or its outer bracket survives as `:slug]`. */
375
+ function bracketParam(segment) {
376
+ return segment
377
+ .replace(/\[\[\.\.\.(\w+)\]\]/g, ":$1")
378
+ .replace(/\[\.\.\.(\w+)\]/g, ":$1")
379
+ .replace(/\[(\w+)\]/g, ":$1");
380
+ }
381
+ /** Segments the Next app router organises the tree with and leaves out of the
382
+ * URL: route groups `(shop)`, private folders `_internal`, slots `@modal`. */
383
+ function isNextAppOrganisingSegment(segment) {
384
+ return /^\(.+\)$/.test(segment) || segment.startsWith("_") || segment.startsWith("@");
385
+ }
386
+ function nextRoute(below) {
387
+ const app = /^app\/(.*)$/.exec(below);
388
+ if (app) {
389
+ const ext = NEXT_PAGE_EXT.exec(app[1]);
390
+ if (!ext)
391
+ return undefined;
392
+ const segments = app[1].slice(0, app[1].length - ext[0].length).split("/");
393
+ // `page` alone renders a URL: `route` is an HTTP handler, and `layout`,
394
+ // `template`, `loading`, `error` and `not-found` wrap a page without being one.
395
+ if (segments.pop() !== "page")
396
+ return undefined;
397
+ return "/" + segments.filter((s) => !isNextAppOrganisingSegment(s)).map(bracketParam).join("/");
398
+ }
399
+ const pages = /^pages\/(.*)$/.exec(below);
400
+ if (!pages)
401
+ return undefined;
402
+ if (pages[1] === "api" || pages[1].startsWith("api/"))
403
+ return undefined;
404
+ const ext = NEXT_PAGE_EXT.exec(pages[1]);
405
+ if (!ext)
406
+ return undefined;
407
+ const stem = pages[1].slice(0, pages[1].length - ext[0].length);
408
+ // Next reserves a leading underscore for files that are not routes: `_app` and
409
+ // `_document` wrap every page, `_error` replaces one.
410
+ if ((stem.split("/").pop() ?? "").startsWith("_"))
411
+ return undefined;
412
+ // No route groups in this router — `(shop)` is a literal directory name.
413
+ const segments = stem.replace(/(?:^|\/)index$/, "").split("/");
414
+ return "/" + segments.filter((s) => s !== "").map(bracketParam).join("/");
415
+ }
416
+ function nuxtRoute(below) {
417
+ const pages = /^pages\/(.*)\.vue$/.exec(below);
418
+ if (!pages)
419
+ return undefined;
420
+ const segments = pages[1].replace(/(?:^|\/)index$/, "").split("/");
421
+ return "/" + segments.filter((s) => s !== "").map(nuxtParam).join("/");
422
+ }
423
+ /** Nuxt 3 spells a parameter `[id]`, Nuxt 2 spells it `_id`. The underscore names
424
+ * one only as the WHOLE segment: a blanket rule made `order_total` `/order:total`. */
425
+ function nuxtParam(segment) {
426
+ const bracketed = bracketParam(segment);
427
+ if (bracketed !== segment)
428
+ return bracketed;
429
+ return segment.replace(/^_(\w+)$/, ":$1");
430
+ }
431
+ function svelteKitRoute(below) {
432
+ const routes = /^routes\/(.*)$/.exec(below);
433
+ if (!routes)
434
+ return undefined;
435
+ const segments = routes[1].split("/");
436
+ // `+page.svelte` renders the URL; `+layout.svelte` wraps it, and `+page.ts` and
437
+ // `+server.ts` are load and endpoint modules.
438
+ if (segments.pop() !== "+page.svelte")
439
+ return undefined;
440
+ // The one non-Next framework with route groups, spelled the same way.
441
+ return "/" + segments.filter((s) => !/^\(.+\)$/.test(s)).map(svelteParam).join("/");
442
+ }
443
+ /** SvelteKit adds an optional parameter, `[[lang]]`, to the bracket spellings. */
444
+ function svelteParam(segment) {
445
+ return bracketParam(segment.replace(/\[\[(?!\.\.\.)(\w+)\]\]/g, "[$1]"));
446
+ }
447
+ /**
448
+ * Routes a repository declares by where it puts its files rather than in code.
449
+ *
450
+ * The page file IS the route entry, so one path yields both halves of the
451
+ * mapping. Without this reader a Next.js, Nuxt or SvelteKit repository has no
452
+ * table at all, and every file in it answers `undefined` however well the import
453
+ * walk works — the routes are simply never written down for the extractor to find.
454
+ */
455
+ function fileSystemRouteEntries(files) {
456
+ // Most specific root first: with a config at the repository root AND at
457
+ // `apps/web`, a file under `apps/web` belongs to the inner app.
458
+ const roots = [...detectFrameworkRoots(files)].sort((a, b) => b[0].length - a[0].length);
459
+ if (roots.length === 0)
460
+ return [];
461
+ const entries = [];
462
+ for (const file of files) {
463
+ for (const [root, framework] of roots) {
464
+ if (root !== "" && !file.startsWith(root + "/"))
465
+ continue;
466
+ const pattern = frameworkRouteFor(root === "" ? file : file.slice(root.length + 1), framework);
467
+ if (pattern !== undefined)
468
+ entries.push({ pattern, entryFile: file });
469
+ // The owning root is settled whether or not the file is a page in it.
470
+ break;
471
+ }
472
+ }
473
+ return entries;
474
+ }
475
+ /** The repository file a module specifier written in `dir` names, if any. Narrowed
476
+ * through the stem index first: the route tree names hundreds of entry files, and
477
+ * comparing each against every file in the repository is quadratic. */
478
+ function resolveWithin(dir, spec, filesByStem) {
479
+ const base = spec.startsWith(".") ? path.posix.normalize(path.posix.join(dir, spec)) : spec;
480
+ const lastSegment = base.split("/").pop();
481
+ if (!lastSegment)
482
+ return undefined;
483
+ for (const file of filesByStem.get("/" + lastSegment) ?? []) {
484
+ if (modulePaths(file).includes(base))
485
+ return file;
486
+ }
487
+ return undefined;
488
+ }
489
+ /**
490
+ * The route table, and whether it is knowably missing an entry.
491
+ *
492
+ * A source route states a path and the component mounted at it; the extractor
493
+ * resolves that component to a file only when it was imported, so a route
494
+ * declared with a locally defined or library component leaves a path with no
495
+ * entry file. Dropping those quietly leaves a table that still looks like a
496
+ * table — and a file mounted only through a dropped route then answers `[]`,
497
+ * "nothing renders this", which is the one answer that shuts off UI recording.
498
+ *
499
+ * So a dropped route marks the table incomplete rather than discarding it. The
500
+ * routes that did resolve still answer, and it is only the EMPTY answer that
501
+ * the missing entry makes unsafe: a file with no route in an incomplete table
502
+ * answers `undefined` instead of `[]`, see {@link candidatesFor}.
503
+ *
504
+ * A generated `routeTree.gen.ts` is the exception: it carries the table for the
505
+ * whole router, so a source route the extractor could not follow adds nothing
506
+ * and takes nothing away.
507
+ */
508
+ function collectRouteEntries(repositoryPath, files, fileSet, filesByStem, packages) {
509
+ const entries = [];
510
+ let dartIncomplete = false;
511
+ for (const file of files) {
512
+ if (path.posix.basename(file) === "routeTree.gen.ts") {
513
+ entries.push(...readTanStackRouteTree(repositoryPath, file, filesByStem));
514
+ }
515
+ }
516
+ if (packages.size > 0) {
517
+ for (const route of extractDartRoutes(repositoryPath)) {
518
+ const relative = route.componentFile ? toPosix(path.relative(repositoryPath, route.componentFile)) : undefined;
519
+ // A widget this reader could not trace to a file leaves the table short of
520
+ // a route, the same way an unresolved web component does.
521
+ if (relative && fileSet.has(relative))
522
+ entries.push({ pattern: route.path, entryFile: relative });
523
+ else
524
+ dartIncomplete = true;
525
+ }
526
+ }
527
+ const generated = entries.length > 0;
528
+ // Deliberately after `generated`: a page table proves what renders each URL, but
529
+ // it does not prove that no OTHER router also mounts a component, so a source
530
+ // route this scan could not follow still marks the table incomplete.
531
+ entries.push(...fileSystemRouteEntries(files));
532
+ let complete = true;
533
+ for (const route of extractSourceRoutes(repositoryPath)) {
534
+ if (!route.path)
535
+ continue;
536
+ const relative = route.componentFile ? toPosix(path.relative(repositoryPath, route.componentFile)) : undefined;
537
+ if (relative && fileSet.has(relative)) {
538
+ entries.push({ pattern: route.path, entryFile: relative });
539
+ continue;
540
+ }
541
+ if (!generated)
542
+ complete = false;
543
+ }
544
+ return { entries, complete: complete && !dartIncomplete };
545
+ }
546
+ function buildIndex(repositoryPath) {
547
+ const files = collectFrontendFiles(repositoryPath);
548
+ if (!files)
549
+ return undefined;
550
+ const fileSet = new Set(files);
551
+ // Stem index first, so each specifier costs one map lookup and a handful of
552
+ // path comparisons rather than a scan of every file in the repository.
553
+ const filesByStem = new Map();
554
+ for (const file of files) {
555
+ for (const stem of stemKeys(file)) {
556
+ const bucket = filesByStem.get(stem);
557
+ if (bucket)
558
+ bucket.push(file);
559
+ else
560
+ filesByStem.set(stem, [file]);
561
+ }
562
+ }
563
+ const packages = flutterPackages(repositoryPath);
564
+ const table = collectRouteEntries(repositoryPath, files, fileSet, filesByStem, packages);
565
+ // No route table found at all: this scan does not understand the repository,
566
+ // which is a different statement from "nothing mounts this file".
567
+ if (table.entries.length === 0)
568
+ return undefined;
569
+ const patternsByEntry = new Map();
570
+ for (const entry of table.entries) {
571
+ const patterns = patternsByEntry.get(entry.entryFile) ?? [];
572
+ if (!patterns.includes(entry.pattern))
573
+ patterns.push(entry.pattern);
574
+ patternsByEntry.set(entry.entryFile, patterns);
575
+ }
576
+ const importers = new Map();
577
+ let importsComplete = true;
578
+ for (const file of files) {
579
+ const content = readFileSafely(path.join(repositoryPath, file), MAX_FILE_BYTES);
580
+ if (!content) {
581
+ importsComplete = false;
582
+ continue;
583
+ }
584
+ const seen = new Set();
585
+ for (const match of content.matchAll(SPECIFIER_RE)) {
586
+ const spec = dartPackagePath(match[2], packages) ?? match[2];
587
+ const lastSegment = spec.replace(/\/+$/, "").split("/").pop();
588
+ if (!lastSegment)
589
+ continue;
590
+ for (const candidate of filesByStem.get("/" + lastSegment) ?? []) {
591
+ if (candidate === file || seen.has(candidate))
592
+ continue;
593
+ if (!specifierNames(file, spec, candidate))
594
+ continue;
595
+ seen.add(candidate);
596
+ const bucket = importers.get(candidate);
597
+ if (bucket)
598
+ bucket.push(file);
599
+ else
600
+ importers.set(candidate, [file]);
601
+ }
602
+ }
603
+ }
604
+ return { fileSet, importers, patternsByEntry, tableComplete: table.complete, importsComplete };
605
+ }
606
+ // ── Query ───────────────────────────────────────────────────────────────────
607
+ function candidatesFor(index, file) {
608
+ // Where each visited file was reached FROM — the file it imports. Following the
609
+ // links from a route entry back down yields the mount chain.
610
+ const cameFrom = new Map();
611
+ const visited = new Set([file]);
612
+ let frontier = [file];
613
+ const candidates = [];
614
+ const collect = (reached) => {
615
+ for (const pattern of index.patternsByEntry.get(reached) ?? []) {
616
+ const chain = [reached];
617
+ for (let at = reached; cameFrom.has(at); at = cameFrom.get(at))
618
+ chain.push(cameFrom.get(at));
619
+ candidates.push({ routePattern: pattern, mountChain: chain });
620
+ }
621
+ };
622
+ collect(file);
623
+ for (let depth = 0; depth < MAX_DEPTH && frontier.length > 0; depth++) {
624
+ const next = [];
625
+ for (const current of frontier) {
626
+ for (const importer of index.importers.get(current) ?? []) {
627
+ if (visited.has(importer))
628
+ continue;
629
+ if (visited.size >= MAX_VISITED)
630
+ return undefined; // incomplete walk states nothing
631
+ visited.add(importer);
632
+ cameFrom.set(importer, current);
633
+ next.push(importer);
634
+ collect(importer);
635
+ }
636
+ }
637
+ frontier = next;
638
+ }
639
+ // Files were still queued when the depth ran out, so a route above MAX_DEPTH
640
+ // would be missed — the same truncation rule MAX_VISITED already applies.
641
+ if (frontier.length > 0)
642
+ return undefined;
643
+ // One route can be reached by several chains; the first one found is the
644
+ // shortest, because the walk is breadth-first.
645
+ const byPattern = new Map();
646
+ for (const candidate of candidates) {
647
+ if (!byPattern.has(candidate.routePattern))
648
+ byPattern.set(candidate.routePattern, candidate);
649
+ }
650
+ if (byPattern.size > MAX_ROUTES)
651
+ return undefined;
652
+ // Nothing mounts this file — but a route the table is missing, or an importer
653
+ // the index could not read, might, so that is not a claim an incomplete scan
654
+ // can make.
655
+ if (byPattern.size === 0 && !(index.tableComplete && index.importsComplete))
656
+ return undefined;
657
+ return [...byPattern.values()];
658
+ }
659
+ /**
660
+ * A lookup bound to one repository. The index is built on the first question and
661
+ * reused for the rest, so a plan with ten UI tests scans the repository once.
662
+ *
663
+ * Fails closed: any error anywhere becomes `undefined`. A scan is best-effort, and
664
+ * its failure is not evidence about the file it was asked about.
665
+ */
666
+ export function createScreenRouteLookup(repositoryPath) {
667
+ let index;
668
+ let built = false;
669
+ const load = () => {
670
+ if (!built) {
671
+ built = true;
672
+ index = repositoryPath ? buildIndex(repositoryPath) : undefined;
673
+ }
674
+ return index;
675
+ };
676
+ return {
677
+ candidatesFor(relativePath) {
678
+ try {
679
+ const file = normalizeRelative(relativePath);
680
+ if (!file)
681
+ return undefined;
682
+ const loaded = load();
683
+ if (!loaded)
684
+ return undefined;
685
+ // A file the scan never saw is a file it cannot speak for: a backend path,
686
+ // a deleted file, or a misspelling. None of those means "no route mounts it".
687
+ if (!loaded.fileSet.has(file))
688
+ return undefined;
689
+ return candidatesFor(loaded, file);
690
+ }
691
+ catch {
692
+ return undefined;
693
+ }
694
+ },
695
+ routePatterns() {
696
+ try {
697
+ const loaded = load();
698
+ if (!loaded)
699
+ return [];
700
+ return [...new Set([...loaded.patternsByEntry.values()].flat())];
701
+ }
702
+ catch {
703
+ return [];
704
+ }
705
+ },
706
+ };
707
+ }
708
+ /** The routes for a file, without the evidence. */
709
+ export function createScreenRouteResolver(repositoryPath) {
710
+ const lookup = createScreenRouteLookup(repositoryPath);
711
+ return (relativePath) => lookup.candidatesFor(relativePath)?.map((candidate) => candidate.routePattern);
712
+ }
713
+ /** The same answer with its evidence: every route, and the import chain that
714
+ * reaches the file from that route's entry file. */
715
+ export function createScreenRouteCandidateResolver(repositoryPath) {
716
+ const lookup = createScreenRouteLookup(repositoryPath);
717
+ return (relativePath) => lookup.candidatesFor(relativePath);
718
+ }
719
+ /** One question, one scan. Convenience over {@link createScreenRouteResolver} for
720
+ * callers that ask about a single file. */
721
+ export function resolveScreenRoutes(repositoryPath, relativePath) {
722
+ return createScreenRouteResolver(repositoryPath)(relativePath);
723
+ }
724
+ /** One question, one scan, with the mount chains. */
725
+ export function resolveScreenRouteCandidates(repositoryPath, relativePath) {
726
+ return createScreenRouteCandidateResolver(repositoryPath)(relativePath);
727
+ }