@skyramp/mcp 0.3.5 → 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 (157) 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 +80 -34
  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 +411 -114
  60. package/build/tools/test-management/analyzeChangesTool.d.ts +24 -1
  61. package/build/tools/test-management/analyzeChangesTool.js +71 -10
  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 +70 -12
  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 +10 -1
  73. package/build/types/index.d.ts +2 -2
  74. package/build/types/index.js +1 -1
  75. package/build/utils/AnalysisStateManager.d.ts +57 -1
  76. package/build/utils/AnalysisStateManager.js +54 -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/reportVerification.d.ts +64 -4
  101. package/build/utils/reportVerification.js +228 -3
  102. package/build/utils/reuseRouting.d.ts +3 -0
  103. package/build/utils/reuseRouting.js +50 -0
  104. package/build/utils/routeParsers.d.ts +2 -0
  105. package/build/utils/routeParsers.js +65 -8
  106. package/build/utils/scenarioDrafting.d.ts +1 -1
  107. package/build/utils/scenarioDrafting.js +57 -45
  108. package/build/utils/subjectEndpoints.d.ts +19 -0
  109. package/build/utils/subjectEndpoints.js +98 -0
  110. package/build/utils/testFileClassification.d.ts +11 -0
  111. package/build/utils/testFileClassification.js +47 -0
  112. package/build/utils/uiPageEnumerator.d.ts +45 -19
  113. package/build/utils/uiPageEnumerator.js +95 -51
  114. package/build/utils/utils-verify/allow.d.ts +16 -0
  115. package/build/utils/utils-verify/allow.js +68 -0
  116. package/build/utils/utils-verify/call-sites.d.ts +34 -0
  117. package/build/utils/utils-verify/call-sites.js +154 -0
  118. package/build/utils/utils-verify/index.d.ts +7 -0
  119. package/build/utils/utils-verify/index.js +7 -0
  120. package/build/utils/utils-verify/language-spec.d.ts +91 -0
  121. package/build/utils/utils-verify/language-spec.js +210 -0
  122. package/build/utils/utils-verify/locate.d.ts +39 -0
  123. package/build/utils/utils-verify/locate.js +199 -0
  124. package/build/utils/utils-verify/parse.d.ts +34 -0
  125. package/build/utils/utils-verify/parse.js +177 -0
  126. package/build/utils/utils-verify/stage.d.ts +24 -0
  127. package/build/utils/utils-verify/stage.js +107 -0
  128. package/build/utils/utils-verify/verify.d.ts +63 -0
  129. package/build/utils/utils-verify/verify.js +168 -0
  130. package/build/utils/utils.d.ts +3 -1
  131. package/build/utils/utils.js +3 -1
  132. package/build/workspace/workspace.d.ts +32 -32
  133. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +9 -5
  134. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +16 -0
  135. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -0
  136. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +115 -14
  137. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +13 -1
  138. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
  139. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -0
  140. package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +27 -253
  141. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-DtudTj_v.js → codeMirrorModule-DJMC4zNo.js} +1 -1
  142. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BW82eAUI.js +196 -0
  143. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  144. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-FNMuBzX1.js → codeMirrorModule-CZfp96qZ.js} +1 -1
  145. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-gpLo02E0.js +809 -0
  146. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.Bq1r1URj.js +2 -0
  147. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  148. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.VEfqi1qN.js +5 -0
  149. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  150. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  151. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  152. package/node_modules/playwright/package.json +1 -1
  153. package/package.json +2 -2
  154. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +0 -422
  155. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +0 -1035
  156. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +0 -2
  157. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +0 -5
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import * as fs from "fs/promises";
2
3
  import { execFile } from "child_process";
3
4
  import { promisify } from "util";
4
5
  import { testFileMatches } from "./utils.js";
@@ -72,8 +73,14 @@ export async function listChangedFiles(repoRoot) {
72
73
  }
73
74
  /**
74
75
  * Cross-check report claims against files that actually changed in the working
75
- * tree, returning a human-readable description for each claim NOT backed by a
76
- * real change. An empty result means every claim is backed.
76
+ * tree, returning a human-readable description for each claim whose file has no
77
+ * change. An empty result means every claim is backed.
78
+ *
79
+ * Not to be confused with `findInvalidSourceCitations` below. This one is about
80
+ * files the agent says IT wrote — a created test, a maintained test — so the test
81
+ * is "did this file change in this run". That one is about application code the
82
+ * agent POINTS AT as the home of a defect, which it never edits, so the test is
83
+ * "does this file and symbol exist in the checkout at all".
77
84
  *
78
85
  * The delivery path can only ship what the agent actually edited (SKYR-3883), so
79
86
  * a report must never claim file work the tree doesn't reflect. This is the
@@ -96,7 +103,7 @@ export async function listChangedFiles(repoRoot) {
96
103
  * - Claims for files outside this checkout (cross-repo `repository`, or an
97
104
  * absolute path resolving outside `repoRoot`) — a different checkout owns them.
98
105
  */
99
- export function findUnbackedClaims(input) {
106
+ export function findUnchangedFileClaims(input) {
100
107
  const { repoRoot, changedFiles, newTests, verdicts, primaryRepository } = input;
101
108
  const unbacked = [];
102
109
  const isBacked = (claimedPath) => {
@@ -139,3 +146,221 @@ export function findUnbackedClaims(input) {
139
146
  }
140
147
  return unbacked;
141
148
  }
149
+ /** Both ways out of a citation rejection, appended to every message so the agent
150
+ * never has to guess that dropping the citation is allowed. */
151
+ const CITATION_REMEDY = "Read the file you mean, correct sourceFile/sourceSymbol, and resubmit — or remove the citation if you cannot confirm it in source.";
152
+ /** How many same-basename paths to offer for a missing file. Enough to converge in
153
+ * one retry, few enough that the message stays readable. */
154
+ const MAX_PATH_SUGGESTIONS = 3;
155
+ /** True when `abs` names something strictly below `root`. */
156
+ function isInsideRoot(root, abs) {
157
+ const rel = path.relative(root, abs);
158
+ return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
159
+ }
160
+ /**
161
+ * Resolve a cited path under one checkout, or return null when it lands outside.
162
+ * A citation names a file INSIDE the repo, so `../../etc/passwd` and an absolute
163
+ * path from another tree are invalid citations — not files to go looking for.
164
+ */
165
+ function resolveInRoot(root, cited) {
166
+ // The contract is a repository-relative path. An absolute one would slip past
167
+ // the containment check whenever it happens to sit under root, because
168
+ // path.resolve() discards root — and it is unusable as a review anchor.
169
+ if (path.isAbsolute(cited))
170
+ return null;
171
+ const abs = path.resolve(root, cited);
172
+ return isInsideRoot(root, abs) ? abs : null;
173
+ }
174
+ /**
175
+ * Repo-relative paths whose basename equals the cited file's basename, so a
176
+ * rejection can name the file the agent probably meant. `git ls-files` does the
177
+ * filtering (a pathspec, not a full listing) to stay cheap on a large repo; a
178
+ * non-git directory or missing git yields no suggestions rather than an error.
179
+ */
180
+ async function suggestSimilarPaths(roots, cited) {
181
+ const base = path.basename(cited);
182
+ if (!base)
183
+ return [];
184
+ const matches = [];
185
+ for (const root of roots) {
186
+ try {
187
+ const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--", `*${base}`, base], {
188
+ cwd: root,
189
+ });
190
+ for (const p of stdout.split("\0")) {
191
+ // The pathspec also matches "myproducts.py" for "products.py"; keep only
192
+ // an exact basename hit so a suggestion is a real alternative spelling.
193
+ if (p && path.basename(p) === base)
194
+ matches.push(p);
195
+ }
196
+ }
197
+ catch {
198
+ // Not a git checkout, or git unavailable — no suggestions to offer.
199
+ }
200
+ }
201
+ return matches.slice(0, MAX_PATH_SUGGESTIONS);
202
+ }
203
+ /**
204
+ * Check every `sourceFile`/`sourceSymbol` citation on the submitted issuesFound
205
+ * entries against the repository, and say which repo each one belongs to.
206
+ * Returns one message per DEFINITE mismatch — an empty `invalid` means the report
207
+ * may be written — plus, per entry, the owner/repo the citation resolved in.
208
+ *
209
+ * The attribution is the reason this resolves against every checkout instead of
210
+ * the first hit: the consumer must not have to guess a cited file's repo from the
211
+ * path, which is what makes it link the primary repo's copy of a related repo's
212
+ * file. Where the citation resolves is the answer, so it is returned rather than
213
+ * discarded. Only a related repo is stamped: an absent `repository` already means
214
+ * the primary repo downstream, so a primary-repo citation ships as submitted.
215
+ *
216
+ * Rejects four things: a path that no known checkout contains, a symbol absent
217
+ * from the file that was found, an attribution that names a different repo than
218
+ * the one holding the code, and a file that resolves in several named checkouts
219
+ * with no attribution to choose between them. Everything uncertain is ACCEPTED —
220
+ * no checkout, an unreadable file, a checkout with no owner/repo, a directory
221
+ * listing that fails. The check must behave the same on any repo, in any
222
+ * language, at any size, and it must never become a loop the agent cannot exit.
223
+ * Symbol matching is a plain substring search for the same reason: no parser, so
224
+ * no language it silently mishandles.
225
+ */
226
+ export async function findInvalidSourceCitations(input) {
227
+ const invalid = [];
228
+ const repository = [];
229
+ const checkouts = input.checkouts.filter((c) => c.root && c.root !== "unknown");
230
+ if (checkouts.length === 0)
231
+ return { invalid, repository }; // Cannot verify anything — accept.
232
+ const roots = checkouts.map((c) => c.root);
233
+ for (let i = 0; i < input.issues.length; i++) {
234
+ const cited = input.issues[i].sourceFile?.trim();
235
+ if (!cited)
236
+ continue; // No citation on this entry — nothing to check.
237
+ const candidates = [];
238
+ for (const checkout of checkouts) {
239
+ const abs = resolveInRoot(checkout.root, cited);
240
+ if (abs)
241
+ candidates.push({ checkout, abs });
242
+ }
243
+ if (candidates.length === 0) {
244
+ invalid.push(`issuesFound[${i}] cites ${cited}, which resolves outside every repository checkout in this run. ` +
245
+ `Cite a path inside the checkout, relative to the repository root. ${CITATION_REMEDY}`);
246
+ continue;
247
+ }
248
+ // One relative path can exist in several checkouts of a multi-repo run, and
249
+ // the citation does not say which one it means. Keep every hit, so the symbol
250
+ // below is looked for in all of them instead of only the first.
251
+ const found = [];
252
+ let unreadable = false;
253
+ for (const candidate of candidates) {
254
+ try {
255
+ if (!(await fs.stat(candidate.abs)).isFile())
256
+ continue;
257
+ // stat() and readFile() both follow symlinks, so a link out of the tree
258
+ // would let a citation resolve against content outside the checkout.
259
+ // Compare the real paths before the file counts as found.
260
+ if (isInsideRoot(await fs.realpath(candidate.checkout.root), await fs.realpath(candidate.abs))) {
261
+ found.push(candidate);
262
+ }
263
+ }
264
+ catch (err) {
265
+ // "Not there" is the answer we want. Any other error (permissions, I/O)
266
+ // means the check could not run, which is never grounds to reject.
267
+ if (err.code !== "ENOENT")
268
+ unreadable = true;
269
+ }
270
+ }
271
+ if (found.length === 0) {
272
+ if (unreadable)
273
+ continue; // Could not look — accept.
274
+ const suggestions = await suggestSimilarPaths(roots, cited);
275
+ invalid.push(`issuesFound[${i}] cites ${cited} but that file does not exist in the repository` +
276
+ (suggestions.length > 0 ? ` (closest matches: ${suggestions.join(", ")})` : "") +
277
+ `. ${CITATION_REMEDY}`);
278
+ continue;
279
+ }
280
+ // The citation resolves in every checkout of `found`; a symbol narrows that to
281
+ // the copies that actually contain it, which is what the attribution below is
282
+ // taken from.
283
+ let resolved = found;
284
+ const symbol = input.issues[i].sourceSymbol?.trim();
285
+ if (symbol) {
286
+ const withSymbol = [];
287
+ let readAny = false;
288
+ for (const hit of found) {
289
+ try {
290
+ const content = await fs.readFile(hit.abs, "utf8");
291
+ readAny = true;
292
+ if (content.includes(symbol))
293
+ withSymbol.push(hit);
294
+ }
295
+ catch {
296
+ // Could not read this copy — try the next one.
297
+ }
298
+ }
299
+ if (!readAny)
300
+ continue; // Could not read any of them — accept.
301
+ if (withSymbol.length === 0) {
302
+ invalid.push(`issuesFound[${i}] cites ${symbol} in ${cited}, but that text does not appear anywhere in the file. ` +
303
+ CITATION_REMEDY);
304
+ continue;
305
+ }
306
+ resolved = withSymbol;
307
+ }
308
+ const attribution = attributeCitation({
309
+ entry: `issuesFound[${i}]`,
310
+ cited,
311
+ symbol,
312
+ declared: input.issues[i].repository?.trim(),
313
+ resolved: resolved.map((hit) => hit.checkout),
314
+ });
315
+ if (attribution.message)
316
+ invalid.push(attribution.message);
317
+ repository[i] = attribution.repository;
318
+ }
319
+ return { invalid, repository };
320
+ }
321
+ /** Distinct owner/repo names of the checkouts a citation resolved in, in order.
322
+ * A checkout the run could not name contributes nothing: it can be neither
323
+ * stamped nor offered as a candidate. */
324
+ function namesOf(resolved) {
325
+ return [...new Set(resolved.map((c) => c.repository).filter((r) => !!r))];
326
+ }
327
+ /**
328
+ * Reconcile where a citation resolved with what the entry says about it, giving
329
+ * back the owner/repo to stamp, a rejection message, or neither.
330
+ */
331
+ function attributeCitation(args) {
332
+ const { entry, cited, symbol, declared, resolved } = args;
333
+ const names = namesOf(resolved);
334
+ const where = symbol ? `${cited} (${symbol})` : cited;
335
+ if (declared) {
336
+ // The entry names its repo, so the citation is checked against THAT checkout.
337
+ // owner/repo is case-insensitive on GitHub, so a differently-cased attribution
338
+ // is the same repo — accepted, and re-stamped in the run's own spelling rather
339
+ // than sent back for a rewrite that would change nothing.
340
+ const match = resolved.find((c) => c.repository && c.repository.toLowerCase() === declared.toLowerCase());
341
+ if (match)
342
+ return match.repository === declared ? {} : { repository: match.repository };
343
+ // It resolved somewhere else. Only say so when the run can name where —
344
+ // otherwise there is nothing to correct the attribution to.
345
+ if (names.length === 0)
346
+ return {};
347
+ return {
348
+ message: `${entry} is attributed to ${declared}, but ${where} resolves in ${names.join(", ")} — not in ${declared}. ` +
349
+ `Set repository to the repo that holds the code, or cite a file that exists in ${declared}.`,
350
+ };
351
+ }
352
+ if (resolved.length > 1) {
353
+ // Several checkouts hold this path, and the entry does not say which is meant.
354
+ if (names.length > 1) {
355
+ return {
356
+ message: `${entry} cites ${where}, which exists in more than one repository of this run (${names.join(", ")}), ` +
357
+ `and the entry does not say which one it is about. Set repository to the owner/repo whose file you read.`,
358
+ };
359
+ }
360
+ return {}; // Cannot name the alternatives — accept, and stamp nothing.
361
+ }
362
+ // Exactly one checkout. Stamp it unless it is the primary, whose findings are
363
+ // identified downstream by having NO repository — see SourceCitationCheck.
364
+ const [only] = resolved;
365
+ return only.primary || !only.repository ? {} : { repository: only.repository };
366
+ }
@@ -0,0 +1,3 @@
1
+ export declare function isBrowserTestType(testType?: string): boolean;
2
+ export declare function isModularizeFirstTarget(testType?: string, language?: string): boolean;
3
+ export declare function isPomAwareTarget(language: string, framework?: string, testType?: string): boolean;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Routing predicates for the code-reuse flow. Kept apart from the prompt
3
+ * modules so the generation services can route on them without importing the
4
+ * prompt text and its POM-catalog dependencies.
5
+ */
6
+ import { isPomReuseEnabled, isUtilsReuseEnabled } from "./featureFlags.js";
7
+ // Page objects model browser pages — mapping an API-level test onto them is never
8
+ // meaningful, so only browser test types (or an unspecified type, for backwards
9
+ // compatibility) may take the POM path regardless of the feature flag.
10
+ export function isBrowserTestType(testType) {
11
+ return testType === undefined || testType === "ui" || testType === "e2e";
12
+ }
13
+ // Modularize-first flows: fresh codegen output carries no helper functions, so
14
+ // skyramp_modularization runs BEFORE skyramp_reuse_code and the reuse pass seeds
15
+ // the shared utils file from the helpers it created. The utils flag is the
16
+ // single gate for every test type — the modularization prompts' per-step
17
+ // extraction reads the same predicate, so the hand-off, the extraction and the
18
+ // seeding step agree on every codeReuse flow. Integration qualifies whenever the flag is on;
19
+ // UI only while the POM path is off (POM refactoring supersedes modularization,
20
+ // and the testbot UI flow asks for codeReuse unconditionally, so the flag is what
21
+ // keeps the default-off behavior a no-op reuse pass). e2e and load are not
22
+ // routed here: load has no modularization routing at all, e2e is deferred.
23
+ // Deliberately keyed on the POM *flag*, not on isPomAwareTarget: with the flag
24
+ // on, a UI target the POM path does not cover keeps today's plain reuse pass.
25
+ // Composing utils reuse with the POM path is a separate change; this predicate
26
+ // only decides the flag-off UI flow. The modularization prompts read it too, so
27
+ // extraction and seeding agree for every codeReuse flow; a modularizeCode-only
28
+ // call (no codeReuse) still receives the shared-helper rules — harmless without
29
+ // a reuse pass, and the tool cannot see codeReuse.
30
+ export function isModularizeFirstTarget(testType, language) {
31
+ if (!isUtilsReuseEnabled())
32
+ return false;
33
+ if (testType === "integration")
34
+ return true;
35
+ if (testType !== "ui" || isPomReuseEnabled())
36
+ return false;
37
+ // Shared browser helpers are Playwright TS/JS; other UI languages have no
38
+ // utils-file convention here and would fall through to a Python file name.
39
+ const lang = language?.toLowerCase();
40
+ return lang === "typescript" || lang === "javascript";
41
+ }
42
+ export function isPomAwareTarget(language, framework, testType) {
43
+ if (!isPomReuseEnabled())
44
+ return false;
45
+ if (!isBrowserTestType(testType))
46
+ return false;
47
+ const lang = language.toLowerCase();
48
+ return ((lang === "typescript" || lang === "javascript") &&
49
+ framework?.toLowerCase() === "playwright");
50
+ }
@@ -35,6 +35,8 @@ export declare function parseRouteLine(line: string, sourceFile: string): Parsed
35
35
  * aggregator's `APIRouter(prefix="/api")` yields `/api/recipes`, not `/recipes`.
36
36
  */
37
37
  export declare function parseFileEndpoints(content: string, sourceFile: string, mountPrefixes?: Map<string, string>): ParsedDiffEndpoint[];
38
+ export declare function isParamSegment(segment: string): boolean;
39
+ export declare function isOpaqueIdSegment(segment: string): boolean;
38
40
  export declare const SKIP_PATH_SEGMENTS: Set<string>;
39
41
  /**
40
42
  * True for version/prefix path segments — "v1".."v99" and the literal "api".
@@ -127,10 +127,27 @@ export function parseRouteLine(line, sourceFile) {
127
127
  };
128
128
  }
129
129
  }
130
- // Go (Gin, Echo, Chi): <ident>.GET/POST/...("/path", handler)
131
- const ginMatch = stripped.match(/(?:\w+)\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*["']([^"'?#]+)/i);
132
- if (ginMatch && sourceFile.endsWith(".go")) {
133
- return { method: ginMatch[1].toUpperCase(), path: ginMatch[2], sourceFile };
130
+ // Go (Gin, Echo, Chi): <ident>.GET/POST/...("path", handler)
131
+ //
132
+ // An all-caps method token (Gin/Echo convention: `g.GET(...)`, `r.POST(...)`)
133
+ // is unambiguously a route registration — nobody writes `query.GET(...)` — so
134
+ // it's accepted even without a leading slash (real Gin routes like
135
+ // `g.GET("ping", ...)` omit it). A CamelCase token (Chi's `r.Get`/`r.Post`)
136
+ // shares call syntax with a plain getter — `query.Get("term")`,
137
+ // `Header.Get("Accept-Version")` — so it only counts as a route when the
138
+ // captured path starts with "/", which every Chi route does and a getter's
139
+ // argument never does.
140
+ const ginUpperMatch = stripped.match(/\w+\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*["']([^"'?#]+)/);
141
+ if (ginUpperMatch && sourceFile.endsWith(".go")) {
142
+ return { method: ginUpperMatch[1], path: ginUpperMatch[2], sourceFile };
143
+ }
144
+ const ginCamelMatch = stripped.match(/\w+\.(Get|Post|Put|Patch|Delete|Head|Options)\s*\(\s*["'](\/[^"'?#]*)/);
145
+ if (ginCamelMatch && sourceFile.endsWith(".go")) {
146
+ return {
147
+ method: ginCamelMatch[1].toUpperCase(),
148
+ path: ginCamelMatch[2],
149
+ sourceFile,
150
+ };
134
151
  }
135
152
  // Go stdlib: http.HandleFunc/Handle
136
153
  const goHandleMatch = stripped.match(/(?:HandleFunc|Handle)\s*\(\s*["']([^"'?#]+)/);
@@ -490,6 +507,45 @@ export function parseFileEndpoints(content, sourceFile, mountPrefixes) {
490
507
  path: ep.path && !ep.path.startsWith("/") ? `/${ep.path}` : ep.path,
491
508
  }));
492
509
  }
510
+ /**
511
+ * One path segment that names a parameter rather than a resource, in any route
512
+ * syntax the scanners read: OpenAPI "{id}", Express/NestJS ":id", and Next.js
513
+ * "[id]" / "[...id]" / "[[...id]]" (the double-bracket form needs its own
514
+ * alternative — it contains an inner "]", which the single-bracket form's
515
+ * [^\]]+ can never span).
516
+ */
517
+ // The colon form may carry an Express/Fastify constraint or modifier —
518
+ // `:id(\d+)`, `:id?`, `:id*`, `:id+`. Those are still parameters, and a
519
+ // resource name is never one, so the predicate has to accept them or
520
+ // extractResourceFromPath returns the whole segment as the resource.
521
+ const PARAM_SEGMENT_RE = /^\{[^}]+\}$|^:[A-Za-z_]\w*(?:\([^)]*\))?[?*+]?$|^\[\[[^\]]+\]\]$|^\[[^\]]+\]$/;
522
+ export function isParamSegment(segment) {
523
+ return PARAM_SEGMENT_RE.test(segment);
524
+ }
525
+ /**
526
+ * One path segment that is an id VALUE rather than a name: a UUID, digits only,
527
+ * or a long hex run (a Mongo ObjectId is 24). A caller writes a literal id in a
528
+ * path where the route declares a parameter, and an id can never be a resource
529
+ * name, so resource derivation must skip it exactly as it skips a parameter.
530
+ * SKYR-4214: an accepted UUID produced the key
531
+ * "DELETE::a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11::integration", which matches
532
+ * nothing, so the candidate escaped dedup entirely.
533
+ *
534
+ * The 16-character floor on the hex rule keeps real resource names safe: a name
535
+ * built only from the letters a-f and digits, 16 characters or longer, is not a
536
+ * word.
537
+ */
538
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
539
+ export function isOpaqueIdSegment(segment) {
540
+ // The sign belongs to the value: an edge-case step writes `/orders/-1` to
541
+ // probe a negative id, and without the sign that segment became the resource
542
+ // name, so the key could never match real order coverage.
543
+ return UUID_RE.test(segment) || /^-?\d+$/.test(segment) || /^[0-9a-f]{16,}$/i.test(segment);
544
+ }
545
+ /** A segment that names neither a resource nor a sub-resource. */
546
+ function isNonResourceSegment(segment) {
547
+ return isParamSegment(segment) || isOpaqueIdSegment(segment);
548
+ }
493
549
  export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
494
550
  /**
495
551
  * True for version/prefix path segments — "v1".."v99" and the literal "api".
@@ -510,14 +566,15 @@ export function isVersionLikeSegment(segment) {
510
566
  */
511
567
  export function extractResourceFromPath(endpointPath) {
512
568
  const segments = endpointPath.split("/").filter(Boolean);
513
- const meaningful = segments.filter((s) => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
569
+ const meaningful = segments.filter((s) => !isNonResourceSegment(s) && !SKIP_PATH_SEGMENTS.has(s));
514
570
  if (meaningful.length === 0)
515
571
  return "unknown";
516
- // Sub-resource pattern: /…/<parent>/{param}/<child>
517
- // Detected when the last raw segment is non-param and the second-to-last is a param.
572
+ // Sub-resource pattern: /…/<parent>/<id>/<child>
573
+ // Detected when the last raw segment names a resource and the one before it
574
+ // does not — an id in any spelling, not only "{param}".
518
575
  const last = segments[segments.length - 1];
519
576
  const secondLast = segments.length >= 2 ? segments[segments.length - 2] : "";
520
- if (!last.startsWith("{") && secondLast.startsWith("{") && meaningful.length >= 2) {
577
+ if (!isNonResourceSegment(last) && isNonResourceSegment(secondLast) && meaningful.length >= 2) {
521
578
  return `${meaningful[meaningful.length - 2]}_${meaningful[meaningful.length - 1]}`;
522
579
  }
523
580
  return meaningful[meaningful.length - 1];
@@ -46,7 +46,7 @@ export declare function draftScenariosFromEndpoints(endpoints: Array<EndpointInp
46
46
  /**
47
47
  * Enforce a global cap on drafted scenarios while preserving category diversity.
48
48
  *
49
- * 1. CRITICAL (new_endpoint / diff-direct) scenarios prioritized first.
49
+ * 1. CRITICAL (bug_caught) scenarios prioritized first.
50
50
  * 2. One scenario per non-empty category guaranteed (breadth).
51
51
  * 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
52
52
  * 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
@@ -4,6 +4,8 @@ import { CATEGORY_PRIORITY, PriorityTier } from "../types/TestRecommendation.js"
4
4
  import { inferExpectedStatus } from "./httpDefaults.js";
5
5
  import { WorkspaceAuthType } from "./workspaceAuth.js";
6
6
  import { deriveResourceToken } from "./importerHop.js";
7
+ import { isSegmentPrefix, pathSuffixMatches } from "./pathMatching.js";
8
+ import { singularize } from "./pluralization.js";
7
9
  // Only tokens that are structurally non-testable (meta, infra, static assets).
8
10
  // Semantic tokens like login, search, webhook, payment are removed — the LLM
9
11
  // already knows how to handle them and the filter was suppressing valid surfaces.
@@ -19,35 +21,10 @@ function extractExecutionVerb(endpointPath) {
19
21
  const match = EXECUTION_SUFFIX.exec(endpointPath);
20
22
  return match ? match[1].toLowerCase() : "execute";
21
23
  }
22
- /**
23
- * Returns true when `a` starts with `b` at a path-segment boundary.
24
- * Plain `startsWith` is not sufficient — "/orders-archive".startsWith("/orders") is true
25
- * even though they are different resources. Requiring the next character to be "/" or
26
- * end-of-string ensures only genuine path prefixes are matched.
27
- */
28
- const isSegmentPrefix = (a, b) => a.startsWith(b) && (a[b.length] === "/" || a[b.length] === undefined);
29
24
  export function isRealResource(r) {
30
25
  return !ACTION_PATTERN.test(r) && !ACTION_VERB_HYPHEN.test(r);
31
26
  }
32
27
  const SKIP_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
33
- /**
34
- * Convert a plural resource name to its singular form for use in field names
35
- * and step descriptions.
36
- *
37
- * Handles the most common irregular plurals found in REST API paths:
38
- * -ies → -y (categories → category, companies → company)
39
- * -ses → -s (statuses → status, classes → class)
40
- * Falls back to removing the trailing "s" for regular plurals.
41
- */
42
- function singularize(word) {
43
- if (word.endsWith("ies") && word.length > 3) {
44
- return word.slice(0, -3) + "y";
45
- }
46
- if (word.endsWith("ses") && word.length > 4) {
47
- return word.slice(0, -2); // statuses → status
48
- }
49
- return word.endsWith("s") && word.length > 1 ? word.slice(0, -1) : word;
50
- }
51
28
  /**
52
29
  * Extract the primary resource name from an endpoint path.
53
30
  * E.g. "/api/v1/flow-costs/{cost_id}" → "flow-costs"
@@ -145,10 +122,38 @@ export function inferResourceRelationships(endpoints) {
145
122
  }
146
123
  export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], wsAuthType, options = {}, removedEndpoints = []) {
147
124
  const scenarios = [];
125
+ // A drafted step path reaches the agent as the plan's `endpoint` and as the
126
+ // URL the generated test calls, so it has to be the spelling an HTTP client
127
+ // accepts. A route scanned from Express or NestJS source arrives as
128
+ // `/members/:uid`; the agent then spends one of its two repair attempts
129
+ // fixing the URL. Measured on eval run 32606742433, fixture
130
+ // cc15-org-reviewer-role: "2-attempt limit reached after URL fix", and the
131
+ // test failed on an unrelated assertion it had no attempt left to correct.
132
+ //
133
+ // Normalize here, at the one place the drafter reads an endpoint, rather than
134
+ // rewriting every stored route: `extractResourceFromPath` already treats both
135
+ // spellings as a parameter, so nothing else in the pipeline needs the change.
136
+ // Every path this function compares must be normalized together. Converting
137
+ // `endpoints` alone made the attack-surface sibling search compare a brace
138
+ // path against a colon `changedEndpoints` entry and find nothing.
139
+ endpoints = endpoints.map((ep) => ({ ...ep, path: toBraceParams(ep.path) }));
140
+ newEndpoints = newEndpoints.map((ep) => ({ ...ep, path: toBraceParams(ep.path) }));
141
+ removedEndpoints = removedEndpoints.map((ep) => ({ ...ep, path: toBraceParams(ep.path) }));
142
+ if (options.changedEndpoints) {
143
+ options = {
144
+ ...options,
145
+ changedEndpoints: options.changedEndpoints.map((ep) => ({ ...ep, path: toBraceParams(ep.path) })),
146
+ };
147
+ }
148
148
  const resourceGroups = new Map();
149
149
  for (const ep of endpoints) {
150
150
  const segments = ep.path.split("/").filter(Boolean);
151
- const nonParamSegs = segments.filter(s => !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
151
+ // Was `!s.startsWith("{")` — brace-only, so a colon-style segment became the
152
+ // resource and the drafted scenario was named ":uid-delete-auth-boundary".
153
+ // `isPathParam` is this file's own predicate and already accepts both forms;
154
+ // it deliberately does NOT reject a literal id (see its comment, SKYR-4229),
155
+ // so this changes the param spelling only.
156
+ const nonParamSegs = segments.filter(s => !isPathParam(s) && !SKIP_SEGMENTS.has(s));
152
157
  const resource = nonParamSegs[nonParamSegs.length - 1] || "unknown";
153
158
  const hasParam = /\{/.test(ep.path);
154
159
  const resourceSegIdx = segments.lastIndexOf(resource);
@@ -221,7 +226,7 @@ const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
221
226
  /**
222
227
  * Enforce a global cap on drafted scenarios while preserving category diversity.
223
228
  *
224
- * 1. CRITICAL (new_endpoint / diff-direct) scenarios prioritized first.
229
+ * 1. CRITICAL (bug_caught) scenarios prioritized first.
225
230
  * 2. One scenario per non-empty category guaranteed (breadth).
226
231
  * 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
227
232
  * 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
@@ -254,9 +259,13 @@ export function capScenarios(scenarios) {
254
259
  }
255
260
  // ── Diff-direct scenario drafting ──
256
261
  // Generates targeted scenarios for each new endpoint in the branch diff.
257
- // These get category "new_endpoint" which maps to the CRITICAL priority tier
258
- // in the scorer, so they always fill the GENERATE slots before any structural
259
- // scenario — aligning the plan with what the LLM naturally wants to do anyway.
262
+ // These get category "new_endpoint", which maps to the MEDIUM priority tier in
263
+ // the scorer — BELOW business_rule, security_boundary and the other categories
264
+ // that state what a test proves. They compete for GENERATE slots on merit
265
+ // rather than auto-filling ahead of everything else; CRITICAL is reserved for
266
+ // bug_caught, which targets an actually identified flaw. Level with the
267
+ // structural categories was still too high: the last rank key is the
268
+ // candidateId, so among same-tier candidates the alphabet decided.
260
269
  /**
261
270
  * Build the minimum steps for a diff-direct integration scenario.
262
271
  * Prerequisite resources (e.g. POST /products before POST /orders) are NOT
@@ -525,9 +534,27 @@ const SOURCE_RESOURCE_SKIP_SEGMENTS = new Set([
525
534
  ...SKIP_SEGMENTS,
526
535
  "app", "apps", "handler", "handlers", "index", "page", "pages", "route", "routes", "router", "server", "src",
527
536
  ]);
537
+ /**
538
+ * `:name` to `{name}`, including an Express/Fastify constraint or modifier
539
+ * (`:id(\d+)`, `:id?`). The match starts at a segment boundary and needs a
540
+ * letter or underscore first, so a port or a mid-segment colon is left alone.
541
+ * Next.js `[id]` stays as written — those mirror file paths.
542
+ */
543
+ function toBraceParams(path) {
544
+ return (path ?? "").replace(/(^|\/):([A-Za-z_]\w*)(\([^)]*\))?[?*+]?/g, "$1{$2}");
545
+ }
528
546
  function pathSegments(path) {
529
547
  return path.split("/").filter(Boolean);
530
548
  }
549
+ /**
550
+ * Recognises a declared parameter, NOT a literal id value. A UUID, digits or a
551
+ * long hex segment reads as a resource name here, unlike routeParsers.ts's
552
+ * isParamSegment + isOpaqueIdSegment. So `collectionPathForChangedEndpoint`
553
+ * does not reduce `/orders/<uuid>` to `/orders`, and no attack-surface
554
+ * auth-boundary scenario is drafted for that endpoint. Left as-is on purpose:
555
+ * fixing it drafts MORE scenarios, which needs measurement. SKYR-4229 tracks
556
+ * it; pathSegmentClassification.characterization.test.ts pins today's answer.
557
+ */
531
558
  function isPathParam(segment) {
532
559
  return /^\{[^}]+\}$/.test(segment) ||
533
560
  /^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment) ||
@@ -733,21 +760,6 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups, wsAuthTyp
733
760
  methods.add(ep.method.toUpperCase());
734
761
  grouped.set(ep.path, methods);
735
762
  }
736
- // Segment-boundary suffix match: avoids "/orders" matching "/preorders".
737
- const pathSuffixMatches = (fullPath, suffix) => {
738
- if (fullPath === suffix)
739
- return true;
740
- const fullSegs = fullPath.split("/").filter(Boolean);
741
- const suffixSegs = suffix.split("/").filter(Boolean);
742
- if (suffixSegs.length === 0 || suffixSegs.length > fullSegs.length)
743
- return false;
744
- const offset = fullSegs.length - suffixSegs.length;
745
- for (let i = 0; i < suffixSegs.length; i++) {
746
- if (fullSegs[offset + i] !== suffixSegs[i])
747
- return false;
748
- }
749
- return true;
750
- };
751
763
  for (const [epPath, methods] of grouped) {
752
764
  let resource = extractResourceName(epPath);
753
765
  let resolvedPath = epPath;
@@ -0,0 +1,19 @@
1
+ import { DraftedScenario, SubjectEndpoint } from "../types/RepositoryAnalysis.js";
2
+ import { ChangedRoute } from "./changedRoutes.js";
3
+ export type { SubjectEndpoint };
4
+ /**
5
+ * The endpoints a scenario tests, in step order.
6
+ *
7
+ * 1. Every step on a changed diff line that names a resource. A test written
8
+ * for a pull request is about what the pull request changed, and a
9
+ * scenario can exercise more than one changed endpoint.
10
+ * 2. If no step matches: the last mutating step that is not a teardown step.
11
+ * Earlier mutations are usually setup (POST /products before PATCH /orders).
12
+ * 3. If no mutating step remains: the last step.
13
+ *
14
+ * Returns an empty list only for a scenario with no steps. Every caller must
15
+ * treat an empty list as "no information", never as "covered".
16
+ */
17
+ export declare function resolveSubjectEndpoints(scenario: DraftedScenario, opts: {
18
+ changedRoutes?: ChangedRoute[];
19
+ }): SubjectEndpoint[];