@skyramp/mcp 0.3.4 → 0.3.6-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 (159) hide show
  1. package/build/playwright/registerPlaywrightTools.js +92 -30
  2. package/build/playwright/traceRecordingPrompt.d.ts +6 -0
  3. package/build/playwright/traceRecordingPrompt.js +6 -2
  4. package/build/prompts/code-reuse.d.ts +1 -2
  5. package/build/prompts/code-reuse.js +182 -77
  6. package/build/prompts/modularization/integration-test-modularization.d.ts +2 -0
  7. package/build/prompts/modularization/integration-test-modularization.js +83 -41
  8. package/build/prompts/modularization/render.d.ts +18 -0
  9. package/build/prompts/modularization/render.js +12 -0
  10. package/build/prompts/modularization/ui-test-modularization.d.ts +3 -1
  11. package/build/prompts/modularization/ui-test-modularization.js +89 -47
  12. package/build/prompts/pom-aware-code-reuse.js +3 -1
  13. package/build/prompts/shared-helper-policy.d.ts +57 -0
  14. package/build/prompts/shared-helper-policy.js +135 -0
  15. package/build/prompts/test-recommendation/diffExecutionPlan.js +62 -56
  16. package/build/prompts/test-recommendation/fullRepoCatalog.js +19 -8
  17. package/build/prompts/test-recommendation/recommendationShared.d.ts +28 -6
  18. package/build/prompts/test-recommendation/recommendationShared.js +90 -16
  19. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +22 -0
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -2
  21. package/build/prompts/test-recommendation/test-recommendation-prompt.js +3 -3
  22. package/build/prompts/testbot/testbot-prompts.js +88 -33
  23. package/build/recommendation/budgeters/shared.js +105 -27
  24. package/build/recommendation/discriminators.js +13 -2
  25. package/build/recommendation/planRanker.d.ts +6 -6
  26. package/build/recommendation/planRanker.js +6 -61
  27. package/build/services/AnalyticsService.d.ts +7 -0
  28. package/build/services/AnalyticsService.js +7 -1
  29. package/build/services/ModularizationService.js +1 -3
  30. package/build/services/TestDiscoveryService.d.ts +0 -2
  31. package/build/services/TestDiscoveryService.js +2 -37
  32. package/build/services/TestGenerationService.d.ts +16 -0
  33. package/build/services/TestGenerationService.js +86 -10
  34. package/build/services/containerEnv.js +13 -12
  35. package/build/tools/code-refactor/codeReuseTool.js +279 -93
  36. package/build/tools/code-refactor/enhance-state.d.ts +49 -0
  37. package/build/tools/code-refactor/enhance-state.js +109 -0
  38. package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -1
  39. package/build/tools/code-refactor/modularizationTool.js +9 -2
  40. package/build/tools/code-refactor/reuse-outcome.d.ts +23 -1
  41. package/build/tools/code-refactor/reuse-outcome.js +14 -4
  42. package/build/tools/code-refactor/reuse-state.d.ts +127 -5
  43. package/build/tools/code-refactor/reuse-state.js +628 -16
  44. package/build/tools/code-refactor/utils-verify-gates.d.ts +26 -0
  45. package/build/tools/code-refactor/utils-verify-gates.js +100 -0
  46. package/build/tools/code-refactor/verify-gates.d.ts +2 -1
  47. package/build/tools/code-refactor/verify-gates.js +90 -25
  48. package/build/tools/executeSkyrampTestTool.d.ts +19 -0
  49. package/build/tools/executeSkyrampTestTool.js +158 -8
  50. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -2
  51. package/build/tools/generate-tests/generateE2ERestTool.js +16 -0
  52. package/build/tools/generate-tests/generateUIRestTool.d.ts +1 -0
  53. package/build/tools/generate-tests/generateUIRestTool.js +22 -0
  54. package/build/tools/generate-tests/scenarioLint.d.ts +2 -0
  55. package/build/tools/generate-tests/scenarioLint.js +127 -19
  56. package/build/tools/generate-tests/trace-reuse-guard.d.ts +20 -0
  57. package/build/tools/generate-tests/trace-reuse-guard.js +93 -0
  58. package/build/tools/submitReportTool.d.ts +38 -38
  59. package/build/tools/submitReportTool.js +487 -104
  60. package/build/tools/test-management/analyzeChangesTool.d.ts +24 -1
  61. package/build/tools/test-management/analyzeChangesTool.js +75 -12
  62. package/build/tools/test-management/analyzeTestHealthTool.js +7 -7
  63. package/build/tools/test-management/registerTestPlanTool.d.ts +203 -0
  64. package/build/tools/test-management/registerTestPlanTool.js +149 -23
  65. package/build/types/Recommendation.d.ts +34 -5
  66. package/build/types/RepositoryAnalysis.d.ts +133 -114
  67. package/build/types/RepositoryAnalysis.js +1 -1
  68. package/build/types/ReuseOutcome.d.ts +102 -6
  69. package/build/types/ReuseOutcome.js +16 -2
  70. package/build/types/TestRecommendation.js +21 -3
  71. package/build/types/TestTypes.js +14 -8
  72. package/build/types/TestbotReport.d.ts +25 -3
  73. package/build/types/index.d.ts +2 -2
  74. package/build/types/index.js +1 -1
  75. package/build/utils/AnalysisStateManager.d.ts +69 -1
  76. package/build/utils/AnalysisStateManager.js +69 -5
  77. package/build/utils/branchDiff.d.ts +10 -0
  78. package/build/utils/branchDiff.js +28 -0
  79. package/build/utils/changedRoutes.d.ts +29 -0
  80. package/build/utils/changedRoutes.js +87 -0
  81. package/build/utils/featureFlags.d.ts +21 -0
  82. package/build/utils/featureFlags.js +23 -0
  83. package/build/utils/frontendIntegration.js +34 -4
  84. package/build/utils/importerHop.d.ts +2 -8
  85. package/build/utils/importerHop.js +15 -53
  86. package/build/utils/pathMatching.d.ts +38 -0
  87. package/build/utils/pathMatching.js +71 -0
  88. package/build/utils/pathSignatures.d.ts +22 -0
  89. package/build/utils/pathSignatures.js +57 -0
  90. package/build/utils/planMatchKeys.d.ts +16 -3
  91. package/build/utils/planMatchKeys.js +26 -10
  92. package/build/utils/pluralization.d.ts +10 -0
  93. package/build/utils/pluralization.js +18 -0
  94. package/build/utils/pom-catalog-parse.d.ts +52 -0
  95. package/build/utils/pom-catalog-parse.js +141 -0
  96. package/build/utils/pom-scope/selector-extractor.d.ts +12 -0
  97. package/build/utils/pom-scope/selector-extractor.js +34 -8
  98. package/build/utils/pom-verify/verify.d.ts +6 -5
  99. package/build/utils/pom-verify/verify.js +8 -6
  100. package/build/utils/reportLanguage.d.ts +43 -0
  101. package/build/utils/reportLanguage.js +125 -0
  102. package/build/utils/reportVerification.d.ts +74 -4
  103. package/build/utils/reportVerification.js +259 -3
  104. package/build/utils/reuseRouting.d.ts +3 -0
  105. package/build/utils/reuseRouting.js +50 -0
  106. package/build/utils/routeParsers.d.ts +2 -0
  107. package/build/utils/routeParsers.js +65 -8
  108. package/build/utils/scenarioDrafting.d.ts +1 -1
  109. package/build/utils/scenarioDrafting.js +57 -45
  110. package/build/utils/subjectEndpoints.d.ts +19 -0
  111. package/build/utils/subjectEndpoints.js +98 -0
  112. package/build/utils/testFileClassification.d.ts +11 -0
  113. package/build/utils/testFileClassification.js +47 -0
  114. package/build/utils/uiPageEnumerator.d.ts +45 -19
  115. package/build/utils/uiPageEnumerator.js +95 -51
  116. package/build/utils/utils-verify/allow.d.ts +16 -0
  117. package/build/utils/utils-verify/allow.js +68 -0
  118. package/build/utils/utils-verify/call-sites.d.ts +34 -0
  119. package/build/utils/utils-verify/call-sites.js +154 -0
  120. package/build/utils/utils-verify/index.d.ts +7 -0
  121. package/build/utils/utils-verify/index.js +7 -0
  122. package/build/utils/utils-verify/language-spec.d.ts +91 -0
  123. package/build/utils/utils-verify/language-spec.js +210 -0
  124. package/build/utils/utils-verify/locate.d.ts +39 -0
  125. package/build/utils/utils-verify/locate.js +199 -0
  126. package/build/utils/utils-verify/parse.d.ts +34 -0
  127. package/build/utils/utils-verify/parse.js +177 -0
  128. package/build/utils/utils-verify/stage.d.ts +24 -0
  129. package/build/utils/utils-verify/stage.js +107 -0
  130. package/build/utils/utils-verify/verify.d.ts +63 -0
  131. package/build/utils/utils-verify/verify.js +168 -0
  132. package/build/utils/utils.d.ts +3 -1
  133. package/build/utils/utils.js +3 -1
  134. package/build/workspace/workspace.d.ts +32 -32
  135. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +9 -5
  136. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +16 -0
  137. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -0
  138. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +115 -14
  139. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +13 -1
  140. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
  141. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -0
  142. package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +27 -253
  143. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-DtudTj_v.js → codeMirrorModule-DJMC4zNo.js} +1 -1
  144. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BW82eAUI.js +196 -0
  145. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  146. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-FNMuBzX1.js → codeMirrorModule-CZfp96qZ.js} +1 -1
  147. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-gpLo02E0.js +809 -0
  148. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.Bq1r1URj.js +2 -0
  149. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  150. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.VEfqi1qN.js +5 -0
  151. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  152. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  153. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  154. package/node_modules/playwright/package.json +1 -1
  155. package/package.json +2 -2
  156. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +0 -422
  157. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +0 -1035
  158. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +0 -2
  159. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +0 -5
@@ -0,0 +1,29 @@
1
+ import { ScenarioStep } from "../types/RepositoryAnalysis.js";
2
+ /** A method+path pair extracted from an actually-changed (`+`/`-`) diff line. */
3
+ export interface ChangedRoute {
4
+ method: string;
5
+ path: string;
6
+ }
7
+ /**
8
+ * Extract method+path from every changed (`+`/`-`, non-header) line of a raw
9
+ * unified diff. Reuses `parseRouteLine`, which already strips the leading
10
+ * `+`/`-` marker and matches the same route-decorator patterns the endpoint
11
+ * scanner does.
12
+ */
13
+ export declare function collectChangedRouteLines(diffText: string): ChangedRoute[];
14
+ /**
15
+ * The first step of `steps` that targets a method+path on the changed hunk, or
16
+ * undefined. Returns the STEP, not a boolean, because two callers need
17
+ * different things from the same comparison: the ranker asks whether any step
18
+ * matches, and the subject resolver needs which ones do.
19
+ *
20
+ * Diff-extracted paths are local to the file's own router declaration (e.g.
21
+ * "/suggestions"); scenario step paths are fully mounted (e.g.
22
+ * "/api/recipes/suggestions"). A local path matches when the step path ends
23
+ * with it at a segment boundary (`pathSuffixMatches`), so the cross-file mount
24
+ * prefix difference (see recoverRemovedEndpointsFromBase, SKYR-4026) does not
25
+ * prevent the match — while a plain `endsWith` would also wrongly match an
26
+ * unrelated path that merely shares a trailing substring (e.g. "/preorders"
27
+ * against "orders").
28
+ */
29
+ export declare function findStepOnChangedRoute(steps: ScenarioStep[] | undefined, changedRoutes: ChangedRoute[]): ScenarioStep | undefined;
@@ -0,0 +1,87 @@
1
+ import { parseRouteLine, normalizeDiffPath } from "./routeParsers.js";
2
+ import { isBareIdPath, pathSuffixMatches } from "./pathMatching.js";
3
+ /**
4
+ * Extract method+path from every changed (`+`/`-`, non-header) line of a raw
5
+ * unified diff. Reuses `parseRouteLine`, which already strips the leading
6
+ * `+`/`-` marker and matches the same route-decorator patterns the endpoint
7
+ * scanner does.
8
+ */
9
+ export function collectChangedRouteLines(diffText) {
10
+ const routes = [];
11
+ let currentFile = "";
12
+ let removedFile = "";
13
+ for (const line of diffText.split("\n")) {
14
+ // Track the current file from the unified-diff header so parseRouteLine
15
+ // gets the real path — its UI-component guard (UI_COMPONENT_EXT) depends
16
+ // on it, or a route-shaped line inside a .tsx/.jsx file (e.g. a client
17
+ // router registration) gets misparsed as a changed backend route.
18
+ // A deleted file has "+++ /dev/null", so the old side is the only name it
19
+ // has. Without it parseRouteLine gets an empty path and every
20
+ // extension-gated framework (Go, Rails, PHP) rejects the line, losing the
21
+ // routes the deletion removed.
22
+ if (line.startsWith("--- ")) {
23
+ const spec = line.slice(4).trim().split("\t")[0];
24
+ removedFile = spec === "/dev/null" ? "" : normalizeDiffPath(spec);
25
+ continue;
26
+ }
27
+ if (line.startsWith("+++ ")) {
28
+ const spec = line.slice(4).trim().split("\t")[0];
29
+ currentFile = spec === "/dev/null" ? removedFile : normalizeDiffPath(spec);
30
+ continue;
31
+ }
32
+ if (line.startsWith("diff --git") || line.startsWith("index "))
33
+ continue;
34
+ if (!(line.startsWith("+") || line.startsWith("-")))
35
+ continue;
36
+ const parsed = parseRouteLine(line, currentFile);
37
+ if (parsed)
38
+ routes.push({ method: parsed.method, path: parsed.path });
39
+ }
40
+ return routes;
41
+ }
42
+ /**
43
+ * The first step of `steps` that targets a method+path on the changed hunk, or
44
+ * undefined. Returns the STEP, not a boolean, because two callers need
45
+ * different things from the same comparison: the ranker asks whether any step
46
+ * matches, and the subject resolver needs which ones do.
47
+ *
48
+ * Diff-extracted paths are local to the file's own router declaration (e.g.
49
+ * "/suggestions"); scenario step paths are fully mounted (e.g.
50
+ * "/api/recipes/suggestions"). A local path matches when the step path ends
51
+ * with it at a segment boundary (`pathSuffixMatches`), so the cross-file mount
52
+ * prefix difference (see recoverRemovedEndpointsFromBase, SKYR-4026) does not
53
+ * prevent the match — while a plain `endsWith` would also wrongly match an
54
+ * unrelated path that merely shares a trailing substring (e.g. "/preorders"
55
+ * against "orders").
56
+ */
57
+ export function findStepOnChangedRoute(steps, changedRoutes) {
58
+ if (changedRoutes.length === 0)
59
+ return undefined;
60
+ for (const step of steps ?? []) {
61
+ const stepMethod = (step.method ?? "").toUpperCase();
62
+ const stepPath = (step.path ?? "").replace(/\/+$/, "");
63
+ for (const route of changedRoutes) {
64
+ if (route.method.toUpperCase() !== stepMethod)
65
+ continue;
66
+ const routePath = route.path.replace(/\/+$/, "");
67
+ // A diff line declaring nothing but a bare id — `router.delete("/:id")`
68
+ // from a mounted Express router, `@Delete(":id")` from NestJS — names no
69
+ // resource, so it suffix-matches every `DELETE …/{id}` step. Both callers
70
+ // ask "does this changed route touch this step", and the answer there is
71
+ // no: for the ranker it lifted an unrelated candidate over a business-rule
72
+ // one, and for subject resolution it would make every step a subject.
73
+ // A NAMED parameter (`/:order_id`) still matches only its own resource,
74
+ // because the helper unifies a parameter's spelling and not its name.
75
+ if (isBareIdPath(routePath))
76
+ continue;
77
+ if (routePath === "") {
78
+ if (stepPath === "" || stepPath === "/")
79
+ return step;
80
+ continue;
81
+ }
82
+ if (pathSuffixMatches(stepPath, routePath))
83
+ return step;
84
+ }
85
+ }
86
+ return undefined;
87
+ }
@@ -66,3 +66,24 @@ export declare function isOneClickEnabled(): boolean;
66
66
  * the verification gates apply.
67
67
  */
68
68
  export declare function isPomReuseEnabled(): boolean;
69
+ /**
70
+ * Gates SkyrampUtils code reuse for integration tests on the testbot path, and
71
+ * for UI tests whenever the POM path is off (SKYRAMP_FEATURE_UTILS_REUSE=1).
72
+ *
73
+ * When OFF (default), the testbot prompt does not set `codeReuse` on
74
+ * `skyramp_integration_test_generation` and its post-generation
75
+ * modularize→reuse step is disabled — integration tests are generated exactly
76
+ * as before, with no file-rewriting steps. UI tests keep their pre-existing
77
+ * single `skyramp_reuse_code` pass, which finds no helpers in inline Playwright
78
+ * output and no-ops.
79
+ *
80
+ * When ON, generated tests on modularize-first flows (see
81
+ * `isModularizeFirstTarget` in utils/reuseRouting.ts for which test types and
82
+ * flag combinations qualify) are modularized and their helpers consolidated
83
+ * into a shared SkyrampUtils file across sibling tests; the modularization
84
+ * prompts add canonical helper naming and the shared-helper assertion policy.
85
+ * On the NL path callers opt in per call via the `codeReuse` parameter, and the
86
+ * modularize-first routing still requires this flag — with it off, `codeReuse`
87
+ * yields the single reuse pass it did before the feature existed.
88
+ */
89
+ export declare function isUtilsReuseEnabled(): boolean;
@@ -76,3 +76,26 @@ export function isOneClickEnabled() {
76
76
  export function isPomReuseEnabled() {
77
77
  return process.env.SKYRAMP_FEATURE_POM_REUSE === "1";
78
78
  }
79
+ /**
80
+ * Gates SkyrampUtils code reuse for integration tests on the testbot path, and
81
+ * for UI tests whenever the POM path is off (SKYRAMP_FEATURE_UTILS_REUSE=1).
82
+ *
83
+ * When OFF (default), the testbot prompt does not set `codeReuse` on
84
+ * `skyramp_integration_test_generation` and its post-generation
85
+ * modularize→reuse step is disabled — integration tests are generated exactly
86
+ * as before, with no file-rewriting steps. UI tests keep their pre-existing
87
+ * single `skyramp_reuse_code` pass, which finds no helpers in inline Playwright
88
+ * output and no-ops.
89
+ *
90
+ * When ON, generated tests on modularize-first flows (see
91
+ * `isModularizeFirstTarget` in utils/reuseRouting.ts for which test types and
92
+ * flag combinations qualify) are modularized and their helpers consolidated
93
+ * into a shared SkyrampUtils file across sibling tests; the modularization
94
+ * prompts add canonical helper naming and the shared-helper assertion policy.
95
+ * On the NL path callers opt in per call via the `codeReuse` parameter, and the
96
+ * modularize-first routing still requires this flag — with it off, `codeReuse`
97
+ * yields the single reuse pass it did before the feature existed.
98
+ */
99
+ export function isUtilsReuseEnabled() {
100
+ return process.env.SKYRAMP_FEATURE_UTILS_REUSE === "1";
101
+ }
@@ -29,7 +29,7 @@ import * as path from "path";
29
29
  import { isTestFile, isI18nLocaleFile } from "../prompts/test-recommendation/scopeAssessment.js";
30
30
  import { logger } from "./logger.js";
31
31
  import { walkDir } from "./fileWalk.js";
32
- import { buildPathSignatures } from "./pathSignatures.js";
32
+ import { buildPathSignatures, importsIndexModule, isIndexModule } from "./pathSignatures.js";
33
33
  import { IntegrationReason } from "../types/FrontendIntegration.js";
34
34
  const MAX_IMPORTERS = 10;
35
35
  const MAX_FILES_WALKED = 3000;
@@ -184,9 +184,11 @@ function checkOneFile(repositoryPath, file) {
184
184
  }
185
185
  try {
186
186
  const signatures = buildPathSignatures(file);
187
- const importers = searchProductionImporters(repositoryPath, signatures)
188
- .filter((candidate) => candidate !== file && !isExcludedImporterPath(candidate))
189
- .slice(0, MAX_IMPORTERS);
187
+ const candidates = searchProductionImporters(repositoryPath, signatures)
188
+ .filter((candidate) => candidate !== file && !isExcludedImporterPath(candidate));
189
+ const importers = isIndexModule(file)
190
+ ? selectIndexModuleImporters(repositoryPath, file, candidates)
191
+ : candidates.slice(0, MAX_IMPORTERS);
190
192
  return {
191
193
  file,
192
194
  integrated: importers.length > 0,
@@ -202,6 +204,34 @@ function checkOneFile(repositoryPath, file) {
202
204
  return { file, integrated: true, importers: [], reason: IntegrationReason.ScanError };
203
205
  }
204
206
  }
207
+ /**
208
+ * An index module's directory stem (`/_constants'`) is a plain string match, so a
209
+ * file importing its own unrelated `./_constants` also hits. Keep only candidates
210
+ * whose specifier resolves to this module's directory. Reads stop once
211
+ * MAX_IMPORTERS are accepted; files over MAX_FILE_SIZE_BYTES are skipped like in
212
+ * manualWalkGrep; an unreadable candidate is kept (fail-open, like the module).
213
+ */
214
+ function selectIndexModuleImporters(repositoryPath, file, candidates) {
215
+ const accepted = [];
216
+ for (const candidate of candidates) {
217
+ if (accepted.length >= MAX_IMPORTERS)
218
+ break;
219
+ const abs = path.join(repositoryPath, candidate);
220
+ let content;
221
+ try {
222
+ if (fs.statSync(abs).size > MAX_FILE_SIZE_BYTES)
223
+ continue;
224
+ content = fs.readFileSync(abs, "utf8");
225
+ }
226
+ catch {
227
+ accepted.push(candidate);
228
+ continue;
229
+ }
230
+ if (importsIndexModule(candidate, content, file))
231
+ accepted.push(candidate);
232
+ }
233
+ return accepted;
234
+ }
205
235
  /**
206
236
  * Checks each changed frontend file for a production importer — the
207
237
  * deterministic version of the grep the testbot prompt used to ask the
@@ -1,3 +1,4 @@
1
+ import { sliceAddedLinesByFile } from "./branchDiff.js";
1
2
  import type { ScannedEndpoint } from "./repoScanner.js";
2
3
  /**
3
4
  * Derives a singular, lowercase "resource token" from a backend source file
@@ -114,14 +115,7 @@ export declare function buildHopResponseShapeInputs(hopResult: ImporterHopResult
114
115
  * to be exhaustive.
115
116
  */
116
117
  export declare function extractFieldNamesFromAddedLines(lines: string[]): string[];
117
- /**
118
- * Splits a unified diff into per-file added-line arrays, keyed by the `b/`
119
- * (post-change) path. Exported so other file->resource-affinity callers
120
- * (e.g. `pathAffinityClassification.ts`'s fallback, SKYR-3857) can derive the
121
- * same per-file diff hunks this module uses for `deriveResourceTokensForFile`,
122
- * without re-implementing the diff-header walk.
123
- */
124
- export declare function sliceAddedLinesByFile(diffContent: string): Map<string, string[]>;
118
+ export { sliceAddedLinesByFile };
125
119
  /**
126
120
  * Splits a unified diff into per-file raw sections — every line after a file's
127
121
  * `diff --git` header up to the next one — keyed by the `b/` (post-change) path.
@@ -56,8 +56,10 @@
56
56
  import * as fs from "fs";
57
57
  import * as path from "path";
58
58
  import { logger } from "./logger.js";
59
- import { extractResourceFromPath, isVersionLikeSegment, SKIP_PATH_SEGMENTS } from "./routeParsers.js";
60
- import { buildPathSignatures } from "./pathSignatures.js";
59
+ import { extractResourceFromPath, isParamSegment, isVersionLikeSegment, SKIP_PATH_SEGMENTS } from "./routeParsers.js";
60
+ import { sliceAddedLinesByFile } from "./branchDiff.js";
61
+ import { buildPathSignatures, importsIndexModule, isIndexModule } from "./pathSignatures.js";
62
+ import { singularize } from "./pluralization.js";
61
63
  import { isFrontendFile, isTestFile } from "../prompts/test-recommendation/scopeAssessment.js";
62
64
  const MAX_FILE_SIZE_BYTES = 512 * 1024;
63
65
  // Mirrors the small `SOURCE_EXTS` copies already living in routeParsers.ts and
@@ -165,19 +167,6 @@ function tokensFromSymbol(symbol) {
165
167
  !GENERIC_SYMBOL_TOKENS.has(token) &&
166
168
  !GENERIC_RESOURCE_TOKENS.has(token));
167
169
  }
168
- /**
169
- * Mirrors the private `singularize` in scenarioDrafting.ts (irregular-plural
170
- * rules for -ies/-ses/trailing-s). Duplicated rather than imported to avoid a
171
- * cross-module coupling for a 6-line pure function; keep the two in sync if
172
- * the pluralization rules ever change.
173
- */
174
- function singularize(word) {
175
- if (word.endsWith("ies") && word.length > 3)
176
- return word.slice(0, -3) + "y";
177
- if (word.endsWith("ses") && word.length > 4)
178
- return word.slice(0, -2);
179
- return word.endsWith("s") && word.length > 1 ? word.slice(0, -1) : word;
180
- }
181
170
  /**
182
171
  * Derives a singular, lowercase "resource token" from a backend source file
183
172
  * path — e.g. "src/dtos/user.dto.ts" -> "user",
@@ -257,17 +246,6 @@ export function deriveResourceTokensForFile(file, diffHunk) {
257
246
  }
258
247
  return tokens.slice(0, MAX_RESOURCE_TOKENS);
259
248
  }
260
- // Mirrors the param-segment forms `isPathParam` in scenarioDrafting.ts
261
- // recognizes — OpenAPI-style "{id}", Express-style ":id", and Next.js-style
262
- // "[id]" / catch-all "[...id]" / optional catch-all "[[...id]]" (which needs
263
- // its own alternative: it contains an inner "]", so the single-bracket form's
264
- // [^\]]+ can never span it) — so the "primary" segment picked below never
265
- // lands on a parameter regardless of which framework's route syntax produced
266
- // the path.
267
- const PARAM_SEGMENT_RE = /^\{[^}]+\}$|^:[A-Za-z_]\w*$|^\[\[[^\]]+\]\]$|^\[[^\]]+\]$/;
268
- function isParamSegment(segment) {
269
- return PARAM_SEGMENT_RE.test(segment);
270
- }
271
249
  /**
272
250
  * All singular resource tokens implied by an endpoint path, used for affinity
273
251
  * matching against a `deriveResourceToken` result. Shared by the importer-hop
@@ -353,9 +331,14 @@ export function mapUnmatchedFilesToImporterEndpoints(repositoryPath, unmatchedFi
353
331
  if (resourceTokens.length === 0)
354
332
  continue;
355
333
  const signatures = buildPathSignatures(file);
334
+ // For an index module the directory stem is a plain string match, so also
335
+ // require the specifier to resolve to this module's directory.
336
+ const indexModule = isIndexModule(file);
356
337
  const rawCandidates = scannedEndpoints.filter((ep) => {
357
338
  const content = readCached(ep.sourceFile);
358
- return content !== null && signatures.some((sig) => content.includes(sig));
339
+ if (content === null || !signatures.some((sig) => content.includes(sig)))
340
+ return false;
341
+ return !indexModule || importsIndexModule(ep.sourceFile, content, file);
359
342
  });
360
343
  if (rawCandidates.length === 0)
361
344
  continue;
@@ -451,32 +434,11 @@ export function extractFieldNamesFromAddedLines(lines) {
451
434
  }
452
435
  return [...fields];
453
436
  }
454
- /**
455
- * Splits a unified diff into per-file added-line arrays, keyed by the `b/`
456
- * (post-change) path. Exported so other file->resource-affinity callers
457
- * (e.g. `pathAffinityClassification.ts`'s fallback, SKYR-3857) can derive the
458
- * same per-file diff hunks this module uses for `deriveResourceTokensForFile`,
459
- * without re-implementing the diff-header walk.
460
- */
461
- export function sliceAddedLinesByFile(diffContent) {
462
- const sections = new Map();
463
- let currentFile = null;
464
- for (const line of diffContent.split("\n")) {
465
- const headerMatch = /^diff --git a\/.+ b\/(.+)$/.exec(line);
466
- if (headerMatch) {
467
- currentFile = headerMatch[1];
468
- continue;
469
- }
470
- if (!currentFile)
471
- continue;
472
- if (line.startsWith("+") && !line.startsWith("+++")) {
473
- const arr = sections.get(currentFile) ?? [];
474
- arr.push(line);
475
- sections.set(currentFile, arr);
476
- }
477
- }
478
- return sections;
479
- }
437
+ // `sliceAddedLinesByFile` now lives in `branchDiff.ts` (imported above) — moved
438
+ // there so modules this one already imports can reuse it without an import
439
+ // cycle. Re-exported here so existing importers of it from this module
440
+ // (pathAffinityClassification.ts) don't need to change their import path.
441
+ export { sliceAddedLinesByFile };
480
442
  /**
481
443
  * Splits a unified diff into per-file raw sections — every line after a file's
482
444
  * `diff --git` header up to the next one — keyed by the `b/` (post-change) path.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Segment-aware path comparisons shared by endpoint/route matching across the
3
+ * codebase. Plain `startsWith`/`endsWith` are not sufficient for API paths:
4
+ * "/orders-archive".startsWith("/orders") and "/preorders".endsWith("orders")
5
+ * are both true even though the paths name different resources. These
6
+ * helpers require the match to land on a "/" (or path-end) boundary.
7
+ *
8
+ * Lives in its own module, independent of `routeParsers.ts` and
9
+ * `importerHop.ts`, so callers in either direction (`planRanker.ts` imports
10
+ * `routeParsers.ts`; `scenarioDrafting.ts` imports `importerHop.ts`, which
11
+ * imports `routeParsers.ts`) can both use it without risking an import cycle.
12
+ */
13
+ /**
14
+ * Returns true when `a` starts with `b` at a path-segment boundary.
15
+ * Plain `startsWith` is not sufficient — "/orders-archive".startsWith("/orders") is true
16
+ * even though they are different resources. Requiring the next character to be "/" or
17
+ * end-of-string ensures only genuine path prefixes are matched.
18
+ */
19
+ export declare const isSegmentPrefix: (a: string, b: string) => boolean;
20
+ /**
21
+ * True when a path is nothing but a bare `id` parameter — `/:id`, `/{id}`.
22
+ *
23
+ * `canonicalSegment` unifies the SPELLING of a parameter, never its name, so
24
+ * `/{order_id}` still matches only a path ending in `{order_id}` — the name
25
+ * carries the resource and the match stays specific. A bare `id` carries
26
+ * nothing, so `/:id` ends `/api/users/{id}` and `/api/orders/{id}` alike. That
27
+ * is the shape a mounted Express router (`router.delete("/:id")`) and a NestJS
28
+ * handler (`@Delete(":id")`) declare, so it is common rather than exotic.
29
+ *
30
+ * Exported rather than folded into `pathSuffixMatches` because the two callers
31
+ * want opposite answers. `scenarioDrafting` resolves a router-relative path
32
+ * against the resource groups it knows, and its own test uses `/{order_id}` —
33
+ * a named parameter, which this predicate deliberately leaves alone.
34
+ * `planRanker` and subject resolution ask whether a diff line touches a given
35
+ * step, and there a bare `id` match is a false positive.
36
+ */
37
+ export declare const isBareIdPath: (path: string) => boolean;
38
+ export declare function pathSuffixMatches(fullPath: string, suffix: string): boolean;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Segment-aware path comparisons shared by endpoint/route matching across the
3
+ * codebase. Plain `startsWith`/`endsWith` are not sufficient for API paths:
4
+ * "/orders-archive".startsWith("/orders") and "/preorders".endsWith("orders")
5
+ * are both true even though the paths name different resources. These
6
+ * helpers require the match to land on a "/" (or path-end) boundary.
7
+ *
8
+ * Lives in its own module, independent of `routeParsers.ts` and
9
+ * `importerHop.ts`, so callers in either direction (`planRanker.ts` imports
10
+ * `routeParsers.ts`; `scenarioDrafting.ts` imports `importerHop.ts`, which
11
+ * imports `routeParsers.ts`) can both use it without risking an import cycle.
12
+ */
13
+ /**
14
+ * Returns true when `a` starts with `b` at a path-segment boundary.
15
+ * Plain `startsWith` is not sufficient — "/orders-archive".startsWith("/orders") is true
16
+ * even though they are different resources. Requiring the next character to be "/" or
17
+ * end-of-string ensures only genuine path prefixes are matched.
18
+ */
19
+ export const isSegmentPrefix = (a, b) => a.startsWith(b) && (a[b.length] === "/" || a[b.length] === undefined);
20
+ /**
21
+ * One spelling for a route parameter. Express and NestJS declare a parameter as
22
+ * `:uid`, so that is what a diff line carries; OpenAPI and every candidate path
23
+ * write `{uid}`. The two name the same segment, so a comparison that reads them
24
+ * as different text rejects a genuine match — `/v2/org/{orgId}/members/{uid}`
25
+ * against a changed `@Delete("/members/:uid")` is the measured case.
26
+ *
27
+ * Only the spelling is unified, not the parameter NAME: on the recorded pairs
28
+ * the names always agree, and treating any parameter as a wildcard would let
29
+ * unrelated routes match.
30
+ */
31
+ const canonicalSegment = (segment) => segment.startsWith(":") ? `{${segment.slice(1)}}` : segment;
32
+ /**
33
+ * True when a path is nothing but a bare `id` parameter — `/:id`, `/{id}`.
34
+ *
35
+ * `canonicalSegment` unifies the SPELLING of a parameter, never its name, so
36
+ * `/{order_id}` still matches only a path ending in `{order_id}` — the name
37
+ * carries the resource and the match stays specific. A bare `id` carries
38
+ * nothing, so `/:id` ends `/api/users/{id}` and `/api/orders/{id}` alike. That
39
+ * is the shape a mounted Express router (`router.delete("/:id")`) and a NestJS
40
+ * handler (`@Delete(":id")`) declare, so it is common rather than exotic.
41
+ *
42
+ * Exported rather than folded into `pathSuffixMatches` because the two callers
43
+ * want opposite answers. `scenarioDrafting` resolves a router-relative path
44
+ * against the resource groups it knows, and its own test uses `/{order_id}` —
45
+ * a named parameter, which this predicate deliberately leaves alone.
46
+ * `planRanker` and subject resolution ask whether a diff line touches a given
47
+ * step, and there a bare `id` match is a false positive.
48
+ */
49
+ export const isBareIdPath = (path) => {
50
+ const segs = path.split("/").filter(Boolean);
51
+ return (segs.length > 0 &&
52
+ segs.every((seg) => {
53
+ const canonical = canonicalSegment(seg);
54
+ return canonical.startsWith("{") && canonical.slice(1, -1).toLowerCase() === "id";
55
+ }));
56
+ };
57
+ // Segment-boundary suffix match: avoids "/orders" matching "/preorders".
58
+ export function pathSuffixMatches(fullPath, suffix) {
59
+ if (fullPath === suffix)
60
+ return true;
61
+ const fullSegs = fullPath.split("/").filter(Boolean);
62
+ const suffixSegs = suffix.split("/").filter(Boolean);
63
+ if (suffixSegs.length === 0 || suffixSegs.length > fullSegs.length)
64
+ return false;
65
+ const offset = fullSegs.length - suffixSegs.length;
66
+ for (let i = 0; i < suffixSegs.length; i++) {
67
+ if (canonicalSegment(fullSegs[offset + i]) !== canonicalSegment(suffixSegs[i]))
68
+ return false;
69
+ }
70
+ return true;
71
+ }
@@ -1,6 +1,28 @@
1
1
  /**
2
2
  * Fixed-string signatures that identify an import of `file` by module path.
3
+ * For an `index.*` module the parent directory name is a stem too (`/Button'`).
3
4
  * Extensionless (`/Foo'`) covers TS/JS convention; extension-included
4
5
  * (`/Foo.vue'`) covers Vue/Svelte, where imports keep the extension.
5
6
  */
6
7
  export declare function buildPathSignatures(file: string): string[];
8
+ /** True for `index.<ext>` modules, including `index.d.ts` (the same test buildPathSignatures uses). */
9
+ export declare function isIndexModule(file: string): boolean;
10
+ /**
11
+ * True when `importerContent` (the source of `importerFile`) contains an import
12
+ * specifier that targets the directory of `indexFile`. Companion to the
13
+ * parent-directory stem above: that stem is matched as a plain string, so
14
+ * `/_constants'` also hits a file importing its own, unrelated `./_constants`.
15
+ * Callers use this to keep only importers whose specifier targets this module.
16
+ *
17
+ * Specifier forms: `from '<spec>'`, `import '<spec>'` (side-effect), `import('<spec>')`,
18
+ * `require('<spec>')`. A trailing `/index` with any extension (`index.vue`,
19
+ * `index.d.ts`) is ignored, so extension-included imports (Vue/Svelte) match.
20
+ *
21
+ * Relative specifiers resolve against the importer's directory. Alias specifiers
22
+ * (`@/x`, `~/x`, `~~/x`, `#/x`, `src/x`) are accepted when the module directory
23
+ * ends with the aliased path AND the importer lives under the same root that the
24
+ * alias maps to — `packages/store/Page.tsx` importing `@/app/shop/_constants` does
25
+ * not match `packages/admin/app/shop/_constants/index.ts`. All paths are
26
+ * repo-relative with forward slashes.
27
+ */
28
+ export declare function importsIndexModule(importerFile: string, importerContent: string, indexFile: string): boolean;
@@ -17,6 +17,7 @@
17
17
  import * as path from "path";
18
18
  /**
19
19
  * Fixed-string signatures that identify an import of `file` by module path.
20
+ * For an `index.*` module the parent directory name is a stem too (`/Button'`).
20
21
  * Extensionless (`/Foo'`) covers TS/JS convention; extension-included
21
22
  * (`/Foo.vue'`) covers Vue/Svelte, where imports keep the extension.
22
23
  */
@@ -29,5 +30,61 @@ export function buildPathSignatures(file) {
29
30
  const basename = isDts ? path.basename(file, ".d.ts") : path.basename(file, ext);
30
31
  const quotes = [`'`, `"`, "`"];
31
32
  const stems = [`/${basename}`, `/${basename}${ext}`];
33
+ // An index module is normally imported by its directory (`from './_constants'`,
34
+ // `from '@/components/Button'`); that import string never contains "index",
35
+ // so the basename stems alone miss every such importer.
36
+ if (isIndexModule(file)) {
37
+ const dir = path.basename(path.dirname(file));
38
+ if (dir && dir !== "." && dir !== "/")
39
+ stems.push(`/${dir}`);
40
+ }
32
41
  return stems.flatMap((stem) => quotes.map((q) => `${stem}${q}`));
33
42
  }
43
+ /** True for `index.<ext>` modules, including `index.d.ts` (the same test buildPathSignatures uses). */
44
+ export function isIndexModule(file) {
45
+ const isDts = /\.d\.ts$/i.test(file);
46
+ return (isDts ? path.basename(file, ".d.ts") : path.basename(file, path.extname(file))) === "index";
47
+ }
48
+ /**
49
+ * True when `importerContent` (the source of `importerFile`) contains an import
50
+ * specifier that targets the directory of `indexFile`. Companion to the
51
+ * parent-directory stem above: that stem is matched as a plain string, so
52
+ * `/_constants'` also hits a file importing its own, unrelated `./_constants`.
53
+ * Callers use this to keep only importers whose specifier targets this module.
54
+ *
55
+ * Specifier forms: `from '<spec>'`, `import '<spec>'` (side-effect), `import('<spec>')`,
56
+ * `require('<spec>')`. A trailing `/index` with any extension (`index.vue`,
57
+ * `index.d.ts`) is ignored, so extension-included imports (Vue/Svelte) match.
58
+ *
59
+ * Relative specifiers resolve against the importer's directory. Alias specifiers
60
+ * (`@/x`, `~/x`, `~~/x`, `#/x`, `src/x`) are accepted when the module directory
61
+ * ends with the aliased path AND the importer lives under the same root that the
62
+ * alias maps to — `packages/store/Page.tsx` importing `@/app/shop/_constants` does
63
+ * not match `packages/admin/app/shop/_constants/index.ts`. All paths are
64
+ * repo-relative with forward slashes.
65
+ */
66
+ export function importsIndexModule(importerFile, importerContent, indexFile) {
67
+ const moduleDir = path.posix.dirname(indexFile.replace(/\\/g, "/"));
68
+ const importerPosix = importerFile.replace(/\\/g, "/");
69
+ const importerDir = path.posix.dirname(importerPosix);
70
+ const specRe = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s+)(['"`])([^'"`]+)\1/g;
71
+ for (const m of importerContent.matchAll(specRe)) {
72
+ const spec = m[2].replace(/\/index(\.[\w]+)*$/, "").replace(/\/$/, "");
73
+ if (spec.startsWith(".")) {
74
+ if (path.posix.normalize(path.posix.join(importerDir, spec)) === moduleDir)
75
+ return true;
76
+ continue;
77
+ }
78
+ const aliased = spec.replace(/^(@\/|~~\/|~\/|#\/|src\/)/, "");
79
+ if (aliased === spec)
80
+ continue; // bare package specifier — not a repo path
81
+ if (moduleDir === aliased)
82
+ return true; // alias maps to the repo root
83
+ if (moduleDir.endsWith("/" + aliased)) {
84
+ const root = moduleDir.slice(0, -(aliased.length + 1));
85
+ if (importerPosix.startsWith(root + "/"))
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
@@ -26,9 +26,22 @@ export interface PlanMatchQuery {
26
26
  /** Lowercase, collapse non-alphanumeric runs to '-', trim leading/trailing '-'. */
27
27
  export declare function slugifyName(name: string | undefined): string;
28
28
  /**
29
- * Normalize a path so param segments collapse: numeric ids, `{brace}` params,
30
- * `:colon` params, and long hex/uuid segments all become `:p`. Mirrors
31
- * discriminators.ts's normalizePath (duplicated here rather than imported —
29
+ * Normalize a path so param/id segments collapse: `{brace}`, `:colon`,
30
+ * `[bracket]` params in any of routeParsers.ts's syntaxes, plus numeric,
31
+ * UUID, and long-hex id VALUES, all become `:p`. Delegates to
32
+ * routeParsers.ts's `isParamSegment`/`isOpaqueIdSegment` (SKYR-4214 task 5)
33
+ * rather than a second inline copy — this module previously had its own
34
+ * looser hex rule (`/^[0-9a-fA-F-]{16,}$/`, accepting `-`) and no
35
+ * bracket-syntax rule at all, so a Next.js `[id]` segment never collapsed
36
+ * and kept a match key from matching its `{id}`-spelled counterpart (the
37
+ * SKYR-4123 failure mode: a generated test's key stops matching its plan
38
+ * item). The characterization test in
39
+ * `pathSegmentClassification.characterization.test.ts` confirms every
40
+ * previously-agreeing input (UUID, numeric, 16+/24-char hex) still
41
+ * collapses to `:p` after this change — safe within one run because the
42
+ * register-time key and the generation-time query are computed by the same
43
+ * build; a plan file is never persisted across builds. Mirrors
44
+ * discriminators.ts's normalizePath (duplicated rather than imported —
32
45
  * that function verifies discriminator claims against a diff, a different
33
46
  * concern from plan matching, and keeping them independent avoids coupling
34
47
  * this module's stability to phase 1's internal helper).