@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
@@ -1,951 +0,0 @@
1
- import { ScenarioSource } from "../types/RepositoryAnalysis.js";
2
- import { TestType } from "../types/TestTypes.js";
3
- import { CATEGORY_PRIORITY, PriorityTier } from "../types/TestRecommendation.js";
4
- import { inferExpectedStatus } from "./httpDefaults.js";
5
- import { WorkspaceAuthType } from "./workspaceAuth.js";
6
- import { deriveResourceToken } from "./importerHop.js";
7
- import { isSegmentPrefix, pathSuffixMatches } from "./pathMatching.js";
8
- import { singularize } from "./pluralization.js";
9
- // Only tokens that are structurally non-testable (meta, infra, static assets).
10
- // Semantic tokens like login, search, webhook, payment are removed — the LLM
11
- // already knows how to handle them and the filter was suppressing valid surfaces.
12
- const ACTION_PATTERN = /^(me|merge|archive|dashboard|migration|favicon|health|status|ping|metrics)$/i;
13
- const ACTION_VERB_HYPHEN = /^(forgot-|reset-|verify-|confirm-|send-|check-|get-|set-|update-|delete-|create-|trigger-|start-|stop-)/i;
14
- /** Matches paths whose last segment is an execution verb (e.g. /jobs/execute, /actions/run). */
15
- const EXECUTION_SUFFIX = /\/(execute|run|trigger|process|invoke|dispatch|send|apply)$/i;
16
- function isExecutionEndpoint(path) {
17
- return EXECUTION_SUFFIX.test(path);
18
- }
19
- /** Extract the execution verb from the path's last segment (e.g. "/jobs/run" → "run"). */
20
- function extractExecutionVerb(endpointPath) {
21
- const match = EXECUTION_SUFFIX.exec(endpointPath);
22
- return match ? match[1].toLowerCase() : "execute";
23
- }
24
- export function isRealResource(r) {
25
- return !ACTION_PATTERN.test(r) && !ACTION_VERB_HYPHEN.test(r);
26
- }
27
- const SKIP_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
28
- /**
29
- * Extract the primary resource name from an endpoint path.
30
- * E.g. "/api/v1/flow-costs/{cost_id}" → "flow-costs"
31
- * "/collections/{id}/links" → "links"
32
- */
33
- function extractResourceName(path) {
34
- const segments = path.split("/").filter(Boolean);
35
- const nonParam = segments.filter(s => !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
36
- const name = nonParam[nonParam.length - 1];
37
- return name && isRealResource(name) ? name : null;
38
- }
39
- /**
40
- * Infer parent→child resource relationships from two signals already present
41
- * in the endpoint data — no source code scanning required.
42
- *
43
- * Signal 1 — path nesting (always available):
44
- * /collections/{id}/links → links depends on collections
45
- *
46
- * Signal 2 — request body FK fields (available when trace data is merged):
47
- * POST /orders body: { product_id: "..." } → orders depends on products
48
- *
49
- * Returns a map of `dependent → Set<parent>`. Only confirmed, real-resource
50
- * pairs are included. When no signal is found the map is empty, which tells
51
- * the caller to fall back to heuristic pairing.
52
- */
53
- export function inferResourceRelationships(endpoints) {
54
- const relationships = new Map();
55
- const addRelationship = (dependent, parent) => {
56
- if (dependent === parent)
57
- return;
58
- if (!isRealResource(dependent) || !isRealResource(parent))
59
- return;
60
- const deps = relationships.get(dependent) ?? new Set();
61
- deps.add(parent);
62
- relationships.set(dependent, deps);
63
- };
64
- // ── Signal 1: path nesting ──
65
- // Pattern: /…/parentResource/{param}/childResource
66
- for (const ep of endpoints) {
67
- const segments = ep.path.split("/").filter(Boolean);
68
- for (let i = 0; i + 2 < segments.length; i++) {
69
- const seg = segments[i];
70
- const paramSeg = segments[i + 1];
71
- const childSeg = segments[i + 2];
72
- if (!seg.startsWith("{") &&
73
- paramSeg.startsWith("{") &&
74
- !childSeg.startsWith("{") &&
75
- !SKIP_SEGMENTS.has(seg) &&
76
- !SKIP_SEGMENTS.has(childSeg)) {
77
- addRelationship(childSeg, seg);
78
- }
79
- }
80
- }
81
- // ── Signal 2: FK fields in request body interactions ──
82
- // Collect all known resource names first so we can validate FK targets.
83
- const knownResources = new Set(endpoints.map(ep => extractResourceName(ep.path)).filter(Boolean));
84
- // Map singular form → actual resource name to handle irregular plurals.
85
- // e.g. singularize("companies") = "company", so "company_id" resolves to "companies".
86
- const singularToResource = new Map([...knownResources].map(r => [singularize(r), r]));
87
- for (const ep of endpoints) {
88
- const resource = extractResourceName(ep.path);
89
- if (!resource)
90
- continue;
91
- for (const m of ep.methods) {
92
- if (typeof m === "string")
93
- continue;
94
- if (!["POST", "PUT", "PATCH"].includes(m.method))
95
- continue;
96
- for (const interaction of m.interactions ?? []) {
97
- const body = interaction.request?.body;
98
- if (!body || typeof body !== "object")
99
- continue;
100
- for (const key of Object.keys(body)) {
101
- if (!key.endsWith("_id"))
102
- continue;
103
- const depSingular = key.slice(0, -3); // "product_id" → "product"
104
- const depPlural = depSingular + "s"; // naive plural, fine for regular nouns
105
- // Only create relationship when the dependency actually exists as an endpoint.
106
- // Check naive plural first (products), then exact singular (product),
107
- // then reverse-singularize to catch irregular plurals (company → companies).
108
- if (knownResources.has(depPlural)) {
109
- addRelationship(resource, depPlural);
110
- }
111
- else if (knownResources.has(depSingular)) {
112
- addRelationship(resource, depSingular);
113
- }
114
- else if (singularToResource.has(depSingular)) {
115
- addRelationship(resource, singularToResource.get(depSingular));
116
- }
117
- }
118
- }
119
- }
120
- }
121
- return relationships;
122
- }
123
- export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], wsAuthType, options = {}, removedEndpoints = []) {
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
- const resourceGroups = new Map();
149
- for (const ep of endpoints) {
150
- const segments = ep.path.split("/").filter(Boolean);
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));
157
- const resource = nonParamSegs[nonParamSegs.length - 1] || "unknown";
158
- const hasParam = /\{/.test(ep.path);
159
- const resourceSegIdx = segments.lastIndexOf(resource);
160
- const basePath = "/" + segments.slice(0, resourceSegIdx + 1).join("/");
161
- let paramPath;
162
- if (hasParam && resourceSegIdx + 1 < segments.length && segments[resourceSegIdx + 1].startsWith("{")) {
163
- paramPath = basePath + "/" + segments[resourceSegIdx + 1];
164
- }
165
- const existing = resourceGroups.get(resource);
166
- if (existing) {
167
- // Only merge if the paths are related at a segment boundary.
168
- const related = isSegmentPrefix(basePath, existing.basePath) || isSegmentPrefix(existing.basePath, basePath);
169
- if (related) {
170
- for (const m of ep.methods)
171
- existing.methods.add(typeof m === "string" ? m : m.method);
172
- if (basePath.split("/").length < existing.basePath.split("/").length) {
173
- existing.basePath = basePath;
174
- }
175
- if (paramPath && (!existing.paramPath || paramPath.split("/").length < existing.paramPath.split("/").length)) {
176
- existing.paramPath = paramPath;
177
- }
178
- }
179
- else {
180
- // Use the raw basePath as the disambiguating suffix — replacing "/" with "_" could
181
- // produce collisions between e.g. /orders/search and /orders_search.
182
- const disambiguatedKey = `${resource}::${basePath}`;
183
- resourceGroups.set(disambiguatedKey, {
184
- basePath,
185
- methods: new Set(ep.methods.map((m) => typeof m === "string" ? m : m.method)),
186
- paramPath,
187
- });
188
- }
189
- }
190
- else {
191
- resourceGroups.set(resource, {
192
- basePath,
193
- methods: new Set(ep.methods.map((m) => typeof m === "string" ? m : m.method)),
194
- paramPath,
195
- });
196
- }
197
- }
198
- scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups, wsAuthType));
199
- scenarios.push(...draftAttackSurfaceExpansionScenarios(endpoints, options.changedEndpoints ?? [], wsAuthType, options.securityRelevantDiff ?? false));
200
- // 404-guard scenarios for removed endpoints — absence assertions confirming
201
- // the endpoint is gone. Single-step contract tests, no chaining needed.
202
- for (const ep of removedEndpoints) {
203
- scenarios.push({
204
- scenarioName: `verify-removed-${ep.method.toLowerCase()}-${ep.path.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`,
205
- description: `Verify ${ep.method} ${ep.path} returns 404 — endpoint was removed in this PR`,
206
- category: "breaking_change",
207
- priority: "high",
208
- steps: [{
209
- order: 1,
210
- method: ep.method,
211
- path: ep.path,
212
- description: `${ep.method} ${ep.path} → 404 (endpoint removed)`,
213
- interactionType: "error",
214
- expectedStatusCode: 404,
215
- }],
216
- chainingKeys: [],
217
- requiresAuth: false,
218
- estimatedComplexity: "simple",
219
- source: ScenarioSource.CodeInferred,
220
- });
221
- }
222
- return capScenarios(scenarios);
223
- }
224
- const MAX_TOTAL_SCENARIOS = 30;
225
- const TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
226
- /**
227
- * Enforce a global cap on drafted scenarios while preserving category diversity.
228
- *
229
- * 1. CRITICAL (bug_caught, requirement_conflict) scenarios prioritized first.
230
- * 2. One scenario per non-empty category guaranteed (breadth).
231
- * 3. Remaining budget filled by priority tier (HIGH > MEDIUM > LOW).
232
- * 4. Hard cap at MAX_TOTAL_SCENARIOS — applied to the combined output.
233
- */
234
- export function capScenarios(scenarios) {
235
- if (scenarios.length <= MAX_TOTAL_SCENARIOS)
236
- return scenarios;
237
- const critical = scenarios.filter(s => CATEGORY_PRIORITY[s.category] === PriorityTier.CRITICAL);
238
- const rest = scenarios.filter(s => CATEGORY_PRIORITY[s.category] !== PriorityTier.CRITICAL);
239
- const breadthPicks = [];
240
- const seenCategories = new Set();
241
- for (const s of rest) {
242
- if (!seenCategories.has(s.category)) {
243
- seenCategories.add(s.category);
244
- breadthPicks.push(s);
245
- }
246
- }
247
- const breadthSet = new Set(breadthPicks);
248
- const remaining = rest.filter(s => !breadthSet.has(s));
249
- remaining.sort((a, b) => {
250
- const ta = TIER_ORDER[CATEGORY_PRIORITY[a.category] ?? PriorityTier.LOW] ?? 1;
251
- const tb = TIER_ORDER[CATEGORY_PRIORITY[b.category] ?? PriorityTier.LOW] ?? 1;
252
- return tb - ta;
253
- });
254
- const budget = MAX_TOTAL_SCENARIOS - critical.length - breadthPicks.length;
255
- const filler = remaining.slice(0, Math.max(budget, 0));
256
- const combined = [...critical, ...breadthPicks, ...filler];
257
- // Enforce the hard cap even if critical set alone exceeds it
258
- return combined.slice(0, MAX_TOTAL_SCENARIOS);
259
- }
260
- // ── Diff-direct scenario drafting ──
261
- // Generates targeted scenarios for each new endpoint in the branch diff.
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.
269
- /**
270
- * Build the minimum steps for a diff-direct integration scenario.
271
- * Prerequisite resources (e.g. POST /products before POST /orders) are NOT
272
- * computed here — the execution plan instructs the LLM to discover them from
273
- * source code (FK fields in request bodies) and prepend the steps itself.
274
- */
275
- function diffDirectIntegration(method, resource, singular, group) {
276
- const steps = [];
277
- if (method === "POST") {
278
- steps.push({
279
- order: 1, method: "POST", path: group.basePath,
280
- description: `Create ${singular} and verify response`,
281
- interactionType: "success", expectedStatusCode: 201,
282
- });
283
- if (group.paramPath && group.methods.has("GET")) {
284
- steps.push({
285
- order: 2, method: "GET", path: group.paramPath,
286
- description: `Retrieve created ${singular} and verify fields`,
287
- interactionType: "success", expectedStatusCode: 200,
288
- chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" },
289
- });
290
- }
291
- }
292
- else if (method === "DELETE") {
293
- if (group.methods.has("POST")) {
294
- steps.push({
295
- order: 1, method: "POST", path: group.basePath,
296
- description: `Create ${singular} to delete`,
297
- interactionType: "success", expectedStatusCode: 201,
298
- });
299
- }
300
- const targetPath = group.paramPath ?? group.basePath;
301
- steps.push({
302
- order: steps.length + 1, method: "DELETE", path: targetPath,
303
- description: `Delete ${singular}`,
304
- interactionType: "success", expectedStatusCode: 204,
305
- ...(steps.length > 0 ? { chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" } } : {}),
306
- });
307
- if (group.paramPath && group.methods.has("GET")) {
308
- steps.push({
309
- order: steps.length + 1, method: "GET", path: group.paramPath,
310
- description: `Verify ${singular} is deleted (404)`,
311
- interactionType: "error", expectedStatusCode: 404,
312
- });
313
- }
314
- }
315
- const suffix = method === "DELETE" ? "delete" : method.toLowerCase();
316
- return {
317
- scenarioName: `${resource}-${suffix}-lifecycle`,
318
- description: `Lifecycle test: ${method} ${group.basePath} — verify ${method === "DELETE" ? "delete and subsequent 404" : "create and read-back"} flow`,
319
- category: "new_endpoint",
320
- priority: "high",
321
- steps,
322
- chainingKeys: ["id", `${singular}_id`],
323
- requiresAuth: true,
324
- estimatedComplexity: "moderate",
325
- source: ScenarioSource.CodeInferred,
326
- testType: TestType.INTEGRATION,
327
- };
328
- }
329
- function diffDirectContract(method, path, resource) {
330
- const expectedStatus = inferExpectedStatus(method);
331
- return {
332
- scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-contract`,
333
- description: `Contract: ${method} ${path} returns a schema-conformant response`,
334
- category: "new_endpoint",
335
- priority: "high",
336
- steps: [{ order: 1, method, path, description: `${method} ${path} with valid input — verify response schema`, interactionType: "success", expectedStatusCode: expectedStatus }],
337
- chainingKeys: [],
338
- requiresAuth: true,
339
- estimatedComplexity: "simple",
340
- source: ScenarioSource.CodeInferred,
341
- testType: TestType.CONTRACT,
342
- };
343
- }
344
- function diffDirectNotFound(method, path, resource, singular) {
345
- return {
346
- scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-not-found`,
347
- description: `Edge case: ${method} ${path} with a non-existent ${singular} ID returns 404`,
348
- category: "new_endpoint",
349
- priority: "high",
350
- steps: [{ order: 1, method, path, description: `${method} ${path} with non-existent ${singular} ID — expect 404 Not Found`, interactionType: "error", expectedStatusCode: 404 }],
351
- chainingKeys: [],
352
- requiresAuth: true,
353
- estimatedComplexity: "simple",
354
- source: ScenarioSource.CodeInferred,
355
- testType: TestType.CONTRACT,
356
- };
357
- }
358
- function diffDirectValidation(method, path, resource) {
359
- return {
360
- scenarioName: `${resource}-${method.toLowerCase()}-new-endpoint-validation`,
361
- description: `Validation: ${method} ${path} with missing required fields returns 422`,
362
- category: "new_endpoint",
363
- priority: "high",
364
- steps: [{ order: 1, method, path, description: `${method} ${path} with empty/missing required fields — expect 422`, interactionType: "error", expectedStatusCode: 422 }],
365
- chainingKeys: [],
366
- requiresAuth: true,
367
- estimatedComplexity: "simple",
368
- source: ScenarioSource.CodeInferred,
369
- testType: TestType.CONTRACT,
370
- };
371
- }
372
- /**
373
- * Draft a "mutation with collection modification" scenario for PUT/PATCH endpoints.
374
- * This tests adding/removing child items (e.g., order line items) and verifying that
375
- * derived totals (total_amount, item_count, subtotal) are recalculated.
376
- * This pattern catches the most common class of user-reported bugs.
377
- */
378
- function diffDirectBoundaryValues(method, resource, singular, group) {
379
- const steps = [];
380
- const targetPath = group.paramPath ?? group.basePath;
381
- const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
382
- if (group.methods.has("POST")) {
383
- steps.push({
384
- order: 1,
385
- method: "POST",
386
- path: group.basePath,
387
- description: `Create a ${singular} for boundary testing`,
388
- interactionType: "success",
389
- expectedStatusCode: 201,
390
- });
391
- }
392
- steps.push({
393
- order: steps.length + 1,
394
- method,
395
- path: targetPath,
396
- description: `${method} with valid boundary values (e.g. 0, maximum allowed, empty collection) — expect 200`,
397
- interactionType: "success",
398
- expectedStatusCode: 200,
399
- ...(steps.length > 0 && group.paramPath
400
- ? { chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
401
- : {}),
402
- });
403
- return {
404
- scenarioName: `${resource}-${method.toLowerCase()}-boundary-values`,
405
- description: `Boundary test: ${method} ${targetPath} — test valid boundary values (0%, 100%, minimum, maximum allowed by schema). Read source to identify numeric/percentage fields and their constraints; assert the response reflects the boundary value correctly.`,
406
- category: "new_endpoint",
407
- priority: "high",
408
- steps,
409
- chainingKeys: ["id", pathParamName],
410
- requiresAuth: true,
411
- estimatedComplexity: "moderate",
412
- source: ScenarioSource.CodeInferred,
413
- testType: TestType.INTEGRATION,
414
- };
415
- }
416
- function diffDirectMutationRecalc(method, resource, singular, group) {
417
- const steps = [];
418
- if (group.methods.has("POST")) {
419
- steps.push({
420
- order: 1,
421
- method: "POST",
422
- path: group.basePath,
423
- description: `Create a ${singular} for testing`,
424
- interactionType: "success",
425
- expectedStatusCode: 201,
426
- });
427
- }
428
- const targetPath = group.paramPath ?? group.basePath;
429
- const pathParamName = group.paramPath?.match(/\{([^}]+)\}/)?.[1] ?? `${singular}_id`;
430
- const sourceStep = steps.length;
431
- steps.push({
432
- order: steps.length + 1,
433
- method,
434
- path: targetPath,
435
- description: `${method} the ${singular} with valid changes — read source to find mutable fields`,
436
- interactionType: "success",
437
- expectedStatusCode: 200,
438
- ...(sourceStep > 0 && group.paramPath
439
- ? { chainsFrom: { sourceStep, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
440
- : {}),
441
- });
442
- if (group.paramPath && group.methods.has("GET")) {
443
- steps.push({
444
- order: steps.length + 1,
445
- method: "GET",
446
- path: group.paramPath,
447
- description: `Verify the ${singular} reflects changes — check any derived/calculated fields`,
448
- interactionType: "success",
449
- expectedStatusCode: 200,
450
- ...(sourceStep > 0 && group.paramPath
451
- ? { chainsFrom: { sourceStep, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
452
- : {}),
453
- });
454
- }
455
- return {
456
- scenarioName: `${resource}-${method.toLowerCase()}-mutation-verify`,
457
- description: `Mutation test: ${method} ${targetPath} — update fields and verify derived calculations (totals, discounts, sums, quantities) are recomputed correctly. Test at least one calculation-affecting change (e.g. discount percentage, quantity update, item addition/removal).`,
458
- category: "new_endpoint",
459
- priority: "high",
460
- steps,
461
- chainingKeys: ["id", pathParamName],
462
- requiresAuth: true,
463
- estimatedComplexity: "complex",
464
- source: ScenarioSource.CodeInferred,
465
- testType: TestType.INTEGRATION,
466
- };
467
- }
468
- function diffDirectExecutionHappy(method, path, resource) {
469
- const verb = extractExecutionVerb(path);
470
- return {
471
- scenarioName: `${resource}-${method.toLowerCase()}-${verb}-happy`,
472
- description: `Execution test: ${method} ${path} with valid payload — read source to identify required input fields; assert response reflects expected output (result, status, processed data)`,
473
- category: "new_endpoint",
474
- priority: "high",
475
- steps: [{
476
- order: 1,
477
- method,
478
- path,
479
- description: `${method} ${path} with valid input payload — expect 200 OK with execution result`,
480
- interactionType: "success",
481
- expectedStatusCode: 200,
482
- }],
483
- chainingKeys: [],
484
- requiresAuth: true,
485
- estimatedComplexity: "moderate",
486
- source: ScenarioSource.CodeInferred,
487
- testType: TestType.INTEGRATION,
488
- };
489
- }
490
- function diffDirectExecutionInvalid(method, path, resource) {
491
- const verb = extractExecutionVerb(path);
492
- return {
493
- scenarioName: `${resource}-${method.toLowerCase()}-${verb}-invalid`,
494
- description: `Validation test: ${method} ${path} with missing or invalid request body — read source to find required fields; assert 422 Unprocessable Entity with meaningful error`,
495
- category: "new_endpoint",
496
- priority: "high",
497
- steps: [{
498
- order: 1,
499
- method,
500
- path,
501
- description: `${method} ${path} with missing required fields — expect 422 Unprocessable Entity`,
502
- interactionType: "error",
503
- expectedStatusCode: 422,
504
- }],
505
- chainingKeys: [],
506
- requiresAuth: true,
507
- estimatedComplexity: "simple",
508
- source: ScenarioSource.CodeInferred,
509
- testType: TestType.CONTRACT,
510
- };
511
- }
512
- function diffDirectAuthBoundary(method, path, resource, wsAuthType) {
513
- // Bearer and OAuth both use the "Authorization: Bearer <token>" scheme. These setups
514
- // commonly return 403 (Forbidden) rather than 401 (Unauthorized) when a token is
515
- // absent, because the framework treats a missing credential as insufficient permission
516
- // rather than unauthenticated. All other auth types use 401.
517
- const expectedStatusCode = wsAuthType === WorkspaceAuthType.Bearer || wsAuthType === WorkspaceAuthType.OAuth ? 403 : 401;
518
- const statusLabel = expectedStatusCode === 403 ? "403 Forbidden" : "401 Unauthorized";
519
- return {
520
- scenarioName: `${resource}-${method.toLowerCase()}-auth-boundary`,
521
- description: `Auth boundary: ${method} ${path} without authentication returns ${statusLabel}`,
522
- category: "security_boundary",
523
- priority: "high",
524
- steps: [{ order: 1, method, path, description: `${method} ${path} without Authorization header — expect ${statusLabel}`, interactionType: "error", expectedStatusCode }],
525
- chainingKeys: [],
526
- requiresAuth: false,
527
- estimatedComplexity: "simple",
528
- source: ScenarioSource.CodeInferred,
529
- testType: TestType.CONTRACT,
530
- };
531
- }
532
- const DESTRUCTIVE_ACTION_TOKENS = new Set(["delete", "destroy", "remove", "cancel", "archive"]);
533
- const SOURCE_RESOURCE_SKIP_SEGMENTS = new Set([
534
- ...SKIP_SEGMENTS,
535
- "app", "apps", "handler", "handlers", "index", "page", "pages", "route", "routes", "router", "server", "src",
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
- }
546
- function pathSegments(path) {
547
- return path.split("/").filter(Boolean);
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
- */
558
- function isPathParam(segment) {
559
- return /^\{[^}]+\}$/.test(segment) ||
560
- /^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment) ||
561
- /^\[\.\.\.[^\]]+\]$/.test(segment) ||
562
- /^\[\[\.\.\.[^\]]+\]\]$/.test(segment) ||
563
- /^\[[^\].][^\]]*\]$/.test(segment);
564
- }
565
- function actionSegmentTokens(segment) {
566
- return segment
567
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
568
- .split(/[^a-z0-9]+/i)
569
- .filter(Boolean)
570
- .map(token => token.toLowerCase());
571
- }
572
- function isDestructiveActionSegment(segment) {
573
- return actionSegmentTokens(segment).some(token => DESTRUCTIVE_ACTION_TOKENS.has(token));
574
- }
575
- function sourceFileResource(sourceFile) {
576
- if (!sourceFile)
577
- return undefined;
578
- const sourceSegments = sourceFile.split(/[\\/]/).filter(Boolean);
579
- const filename = sourceSegments.at(-1)?.replace(/\.[^.]+$/, "").toLowerCase();
580
- if (filename && isRealResource(filename) && !SOURCE_RESOURCE_SKIP_SEGMENTS.has(filename))
581
- return filename;
582
- return [...sourceSegments]
583
- .slice(0, -1)
584
- .reverse()
585
- .map(segment => segment.replace(/\.[^.]+$/, "").toLowerCase())
586
- .find(segment => segment && isRealResource(segment) && !SOURCE_RESOURCE_SKIP_SEGMENTS.has(segment));
587
- }
588
- function collectionPathForChangedEndpoint(path, sourceFile) {
589
- const segments = pathSegments(path);
590
- const sourceResource = sourceFileResource(sourceFile);
591
- if (segments.length > 0 &&
592
- isPathParam(segments[0]) &&
593
- sourceResource &&
594
- (segments.length === 1 || segments[1] !== sourceResource)) {
595
- return `/${sourceResource}`;
596
- }
597
- if (segments.length >= 2) {
598
- const last = segments[segments.length - 1];
599
- if (isPathParam(last)) {
600
- return `/${segments.slice(0, -1).join("/")}`;
601
- }
602
- const paramIdx = segments.findIndex(isPathParam);
603
- if (paramIdx > 0) {
604
- return `/${segments.slice(0, paramIdx).join("/")}`;
605
- }
606
- }
607
- return sourceResource ? `/${sourceResource}` : undefined;
608
- }
609
- function resourceFromCollectionPath(collectionPath) {
610
- const segments = pathSegments(collectionPath);
611
- return segments.filter(s => !SKIP_SEGMENTS.has(s)).at(-1) ?? "resource";
612
- }
613
- function actionNameFromPath(path) {
614
- const action = pathSegments(path).at(-1) ?? "destructive";
615
- return action.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase() || "destructive";
616
- }
617
- function endpointMethods(endpoint) {
618
- return endpoint.methods.map(m => (typeof m === "string" ? m : m.method).toUpperCase());
619
- }
620
- function endpointSourceFiles(endpoint) {
621
- return new Set(endpoint.methods
622
- .filter((m) => typeof m !== "string")
623
- .map(m => m.sourceFile)
624
- .filter((sourceFile) => Boolean(sourceFile)));
625
- }
626
- function resolveSiblingPath(endpointPath, collectionPath) {
627
- if (isSegmentPrefix(endpointPath, collectionPath))
628
- return endpointPath;
629
- const segments = pathSegments(endpointPath);
630
- if (segments.length > 0 && isDestructiveActionSegment(segments[0])) {
631
- return `${collectionPath}/${segments.join("/")}`;
632
- }
633
- return endpointPath;
634
- }
635
- function isDestructiveSiblingEndpoint(endpoint, changed, collectionPath) {
636
- const methods = endpointMethods(endpoint);
637
- if (!methods.includes("POST"))
638
- return false;
639
- const endpointSegments = pathSegments(endpoint.path);
640
- const samePathFamily = isSegmentPrefix(endpoint.path, collectionPath) && endpoint.path !== collectionPath;
641
- const routerRelativeDestructive = endpointSegments.length > 0 &&
642
- isDestructiveActionSegment(endpointSegments[0]);
643
- const sameSourceFile = Boolean(routerRelativeDestructive && changed.sourceFile && endpointSourceFiles(endpoint).has(changed.sourceFile));
644
- if (!samePathFamily && !sameSourceFile)
645
- return false;
646
- const collectionSegments = samePathFamily ? pathSegments(collectionPath) : [];
647
- const suffix = endpointSegments.slice(collectionSegments.length);
648
- return suffix.some(segment => isDestructiveActionSegment(segment));
649
- }
650
- function draftAttackSurfaceExpansionScenarios(endpoints, changedEndpoints, wsAuthType, securityRelevantDiff) {
651
- if (!securityRelevantDiff || changedEndpoints.length === 0)
652
- return [];
653
- const scenarios = [];
654
- const seen = new Set();
655
- for (const changed of changedEndpoints) {
656
- if (changed.method.toUpperCase() !== "DELETE")
657
- continue;
658
- const collectionPath = collectionPathForChangedEndpoint(changed.path, changed.sourceFile);
659
- if (!collectionPath)
660
- continue;
661
- const resource = resourceFromCollectionPath(collectionPath);
662
- for (const endpoint of endpoints) {
663
- if (!isDestructiveSiblingEndpoint(endpoint, changed, collectionPath))
664
- continue;
665
- const siblingPath = resolveSiblingPath(endpoint.path, collectionPath);
666
- const scenario = diffDirectAuthBoundary("POST", siblingPath, `${resource}-${actionNameFromPath(siblingPath)}`, wsAuthType);
667
- scenario.description = `Attack-surface auth boundary: ${siblingPath} is a destructive sibling of changed ${changed.method.toUpperCase()} ${changed.path}; verify it rejects missing authentication`;
668
- scenario.isAttackSurfaceSecurityBoundary = true;
669
- scenario.steps[0].description = `POST ${siblingPath} without the new auth/admin guard — expect ${scenario.steps[0].expectedStatusCode}`;
670
- if (!seen.has(scenario.scenarioName)) {
671
- seen.add(scenario.scenarioName);
672
- scenarios.push(scenario);
673
- }
674
- }
675
- }
676
- return scenarios;
677
- }
678
- /**
679
- * Draft a unified scenario for GET endpoints without path params (collection or search).
680
- * Rather than hardcoding keyword detection, the description instructs the LLM to read
681
- * the diff source and determine whether query params are involved, then draft variations
682
- * accordingly.
683
- */
684
- function diffDirectGetCollection(path, resource, singular, group) {
685
- const steps = [];
686
- if (group.methods.has("POST")) {
687
- steps.push({
688
- order: 1,
689
- method: "POST",
690
- path: group.basePath,
691
- description: `Create a ${singular} for query verification`,
692
- interactionType: "success",
693
- expectedStatusCode: 201,
694
- });
695
- steps.push({
696
- order: 2,
697
- method: "GET",
698
- path,
699
- description: `Query ${resource} and verify results include the created item`,
700
- interactionType: "success",
701
- expectedStatusCode: 200,
702
- });
703
- }
704
- else {
705
- steps.push({
706
- order: 1,
707
- method: "GET",
708
- path,
709
- description: `Query ${resource} and verify response structure`,
710
- interactionType: "success",
711
- expectedStatusCode: 200,
712
- });
713
- }
714
- // Include a path-specific suffix in scenarioName to avoid collision when multiple GET
715
- // collection endpoints exist for the same resource (e.g. /products and /products/featured).
716
- // Strip API prefix segments (api, v1, ...) and param segments, take the last 1-2 meaningful ones.
717
- const meaningfulSegs = path.split("/").filter(s => s && !s.startsWith("{") && !SKIP_SEGMENTS.has(s));
718
- const pathSlug = meaningfulSegs.slice(-2).join("-") || resource;
719
- const uniqueName = pathSlug === resource ? `${resource}-list-query` : `${pathSlug}-list-query`;
720
- return {
721
- scenarioName: uniqueName,
722
- description: `List/query test: GET ${path} — read source to find supported query params (filters, pagination, search), then test with and without them`,
723
- category: "new_endpoint",
724
- priority: "high",
725
- steps,
726
- chainingKeys: ["id", `${singular}_id`],
727
- requiresAuth: true,
728
- estimatedComplexity: group.methods.has("POST") ? "moderate" : "simple",
729
- source: ScenarioSource.CodeInferred,
730
- testType: group.methods.has("POST") ? TestType.INTEGRATION : TestType.CONTRACT,
731
- };
732
- }
733
- /**
734
- * Draft scenarios that directly test each new endpoint in the branch diff.
735
- *
736
- * For each new endpoint, method-specific scenarios are produced:
737
- * PUT/PATCH → mutation-recalc integration, boundary-values integration, contract, not-found
738
- * POST → lifecycle integration (create + optional GET), contract, validation error
739
- * DELETE → lifecycle integration (create + delete + 404), contract
740
- * GET /{id} → contract, not-found
741
- * GET /coll → contract + collection-list integration (create-then-GET) or contract-only
742
- *
743
- * Additionally, PUT/PATCH/DELETE methods get an auth-boundary scenario
744
- * (category "security_boundary" → HIGH priority, not CRITICAL) so it
745
- * lands in ADDITIONAL deterministically instead of depending on LLM
746
- * supplement. GET is excluded (often public); POST is excluded because
747
- * the security-boundary supplement tier covers it. Exception: execution
748
- * endpoints (/execute, /run, /trigger, etc.) include auth-boundary for
749
- * all non-GET methods (including POST) since they are high-value targets.
750
- */
751
- export function draftDiffDirectScenarios(newEndpoints, resourceGroups, wsAuthType) {
752
- if (newEndpoints.length === 0)
753
- return [];
754
- const scenarios = [];
755
- const seen = new Set();
756
- // Group flat list by path
757
- const grouped = new Map();
758
- for (const ep of newEndpoints) {
759
- const methods = grouped.get(ep.path) ?? new Set();
760
- methods.add(ep.method.toUpperCase());
761
- grouped.set(ep.path, methods);
762
- }
763
- for (const [epPath, methods] of grouped) {
764
- let resource = extractResourceName(epPath);
765
- let resolvedPath = epPath;
766
- // The map key to use for the final resourceGroups lookup — may differ from `resource`
767
- // when the entry was stored under a disambiguated "resource::basePath" key.
768
- let resourceGroupKey = resource;
769
- // When resource was found but epPath may be router-relative (e.g. "/orders"
770
- // instead of "/api/v1/orders"), try to upgrade resolvedPath to the full path.
771
- if (resource) {
772
- const existingGroup = resourceGroups.get(resource);
773
- if (existingGroup) {
774
- if (existingGroup.paramPath && existingGroup.paramPath !== epPath && pathSuffixMatches(existingGroup.paramPath, epPath)) {
775
- resolvedPath = existingGroup.paramPath;
776
- }
777
- else if (existingGroup.basePath !== epPath && pathSuffixMatches(existingGroup.basePath, epPath)) {
778
- resolvedPath = existingGroup.basePath;
779
- }
780
- }
781
- }
782
- // When extractResourceName returns null OR the resource isn't in resourceGroups,
783
- // fall back to finding a matching group via prefix or suffix matching.
784
- if (!resource || !resourceGroups.has(resource)) {
785
- let bestMatch = null;
786
- for (const [rName, rGroup] of resourceGroups) {
787
- // Prefix match: epPath starts with group's basePath (e.g. /products/search → /products)
788
- if (epPath.startsWith(rGroup.basePath + "/") || epPath === rGroup.basePath) {
789
- if (!bestMatch || rGroup.basePath.length > bestMatch.length) {
790
- bestMatch = { name: rName, path: rGroup.basePath, length: rGroup.basePath.length, isPrefixMatch: true };
791
- }
792
- }
793
- // Suffix match for router-relative paths (e.g. /{order_id} → /api/v1/orders/{order_id})
794
- if (rGroup.paramPath && pathSuffixMatches(rGroup.paramPath, epPath)) {
795
- if (!bestMatch || rGroup.paramPath.length > bestMatch.length) {
796
- bestMatch = { name: rName, path: rGroup.paramPath, length: rGroup.paramPath.length, isPrefixMatch: false };
797
- }
798
- }
799
- }
800
- if (!bestMatch)
801
- continue;
802
- // Keep the full map key for lookup — disambiguated keys use "resource::basePath" format.
803
- // Use only the prefix before "::" as the resource name for scenario naming/singularization.
804
- resourceGroupKey = bestMatch.name;
805
- resource = resourceGroupKey.includes("::") ? resourceGroupKey.split("::")[0] : resourceGroupKey;
806
- // For prefix matches (action sub-paths), keep original path; for suffix matches, use resolved path
807
- resolvedPath = bestMatch.isPrefixMatch ? epPath : bestMatch.path;
808
- }
809
- const group = resourceGroups.get(resourceGroupKey);
810
- if (!group)
811
- continue;
812
- const singular = singularize(resource);
813
- const hasPathParam = resolvedPath.includes("{");
814
- const add = (s) => {
815
- if (!seen.has(s.scenarioName)) {
816
- seen.add(s.scenarioName);
817
- scenarios.push(s);
818
- }
819
- };
820
- for (const method of methods) {
821
- // Execution endpoints get their own focused templates instead of CRUD lifecycle ones.
822
- // Caller-traced execution endpoints (Bug 5 scenario) are handled by the LLM prompt;
823
- // this fires only when a new /execute-style endpoint appears directly in the diff.
824
- // GET is excluded — execution endpoints use request bodies, which don't apply to GET.
825
- // MULTI is excluded — it's a sentinel for catch-all handlers, not a real HTTP verb.
826
- if (isExecutionEndpoint(resolvedPath) && method !== "GET" && method !== "MULTI") {
827
- add(diffDirectExecutionHappy(method, resolvedPath, resource));
828
- add(diffDirectExecutionInvalid(method, resolvedPath, resource));
829
- add(diffDirectAuthBoundary(method, resolvedPath, resource, wsAuthType));
830
- continue;
831
- }
832
- if (method === "PUT" || method === "PATCH") {
833
- // mutation-recalc tests the primary calculation flow
834
- add(diffDirectMutationRecalc(method, resource, singular, group));
835
- // boundary-values tests edge cases (0, max, empty)
836
- add(diffDirectBoundaryValues(method, resource, singular, group));
837
- add(diffDirectContract(method, resolvedPath, resource));
838
- if (hasPathParam)
839
- add(diffDirectNotFound(method, resolvedPath, resource, singular));
840
- }
841
- else if (method === "POST") {
842
- add(diffDirectIntegration(method, resource, singular, group));
843
- add(diffDirectContract(method, resolvedPath, resource));
844
- add(diffDirectValidation(method, resolvedPath, resource));
845
- }
846
- else if (method === "DELETE") {
847
- add(diffDirectIntegration(method, resource, singular, group));
848
- add(diffDirectContract(method, resolvedPath, resource));
849
- }
850
- else if (method === "GET" && hasPathParam) {
851
- add(diffDirectContract(method, resolvedPath, resource));
852
- add(diffDirectNotFound(method, resolvedPath, resource, singular));
853
- }
854
- else if (method === "GET" && !hasPathParam) {
855
- add(diffDirectContract(method, resolvedPath, resource));
856
- add(diffDirectGetCollection(resolvedPath, resource, singular, group));
857
- }
858
- if (method === "PUT" || method === "PATCH" || method === "DELETE")
859
- add(diffDirectAuthBoundary(method, resolvedPath, resource, wsAuthType));
860
- }
861
- }
862
- return scenarios;
863
- }
864
- // ── Response-shape scenarios for importer-hop-mapped endpoints (SKYR-3855) ──
865
- // A changed DTO/service file has no route of its own, but the endpoints that
866
- // return/consume it (found via `mapUnmatchedFilesToImporterEndpoints`) can
867
- // have their response shape verified directly — this is the deterministic
868
- // signal that replaces the generic structural scenarios the ranker would
869
- // otherwise fall back to when a backend change matches zero endpoints.
870
- const RESPONSE_SHAPE_SCENARIOS_PER_FILE = 2;
871
- /** A GET on a single record (path param or a literal single-record action
872
- * segment like "/me") is a more direct place to observe a response-shape
873
- * change than a list/collection GET, so it's preferred when capping. */
874
- function isDetailGetPath(endpointPath) {
875
- const segments = endpointPath.split("/").filter(Boolean);
876
- const last = segments.at(-1) ?? "";
877
- return last.startsWith("{") || last.startsWith(":") || /^me$/i.test(last);
878
- }
879
- function slugify(value) {
880
- return value
881
- .toLowerCase()
882
- .replace(/[^a-z0-9]+/g, "-")
883
- .replace(/^-|-$/g, "") || "change";
884
- }
885
- /**
886
- * Drafts response-shape verification scenarios for endpoints reached via the
887
- * importer-hop classification: files that don't declare routes themselves
888
- * (DTOs, services, models) but are imported by files that do.
889
- *
890
- * `byFile` maps each hop-mapped source file to the endpoint paths it reaches;
891
- * `addedFieldsByFile` maps the same files to property names added in their
892
- * diff hunks (best-effort — an empty list is fine, the scenario is still
893
- * drafted, just without field hints).
894
- */
895
- export function draftResponseShapeScenarios(hopEndpoints, byFile, addedFieldsByFile) {
896
- const scenarios = [];
897
- const byPath = new Map();
898
- for (const ep of hopEndpoints) {
899
- if (!byPath.has(ep.path))
900
- byPath.set(ep.path, ep);
901
- }
902
- for (const [file, paths] of Object.entries(byFile)) {
903
- const fileName = file.split(/[\\/]/).pop() ?? file;
904
- const resource = deriveResourceToken(file);
905
- const addedFields = addedFieldsByFile[file] ?? [];
906
- const getEndpoints = [...new Set(paths)]
907
- .map((p) => byPath.get(p))
908
- .filter((ep) => !!ep && ep.methods.includes("GET"));
909
- if (getEndpoints.length === 0)
910
- continue;
911
- // Prefer detail GETs over collection GETs, then keep source order.
912
- const sorted = [...getEndpoints].sort((a, b) => Number(isDetailGetPath(a.path) ? 0 : 1) - Number(isDetailGetPath(b.path) ? 0 : 1));
913
- for (const ep of sorted.slice(0, RESPONSE_SHAPE_SCENARIOS_PER_FILE)) {
914
- const fieldsList = addedFields.join("`, `");
915
- const fieldsPhrase = addedFields.length > 0
916
- ? `includes the \`${fieldsList}\`${addedFields.length > 1 ? " fields" : " field"} added in ${fileName}`
917
- : `reflects the change made in ${fileName}`;
918
- const nameSuffix = addedFields.length > 0 ? addedFields[0] : "response-change";
919
- scenarios.push({
920
- // The endpoint path is part of the name because one file can reach
921
- // several GETs and neither the resource token (from the file) nor the
922
- // suffix (a field name, or the constant "response-change" when the hunk
923
- // yields none) varies between them. Two same-named candidates are
924
- // merged by name in registerTestPlanTool, so a collision here loses a
925
- // scenario outright rather than showing up as a duplicate.
926
- scenarioName: `${slugify(resource)}-response-shape-${slugify(nameSuffix)}-${slugify(ep.path)}`,
927
- description: `Verify GET ${ep.path} response ${fieldsPhrase}.`,
928
- category: "breaking_change",
929
- priority: "high",
930
- steps: [{
931
- order: 1,
932
- method: "GET",
933
- path: ep.path,
934
- description: `GET ${ep.path} — verify response shape ${fieldsPhrase}`,
935
- interactionType: "success",
936
- expectedStatusCode: 200,
937
- ...(addedFields.length > 0 ? { expectedResponseFields: addedFields } : {}),
938
- }],
939
- chainingKeys: [],
940
- requiresAuth: true,
941
- estimatedComplexity: "simple",
942
- source: ScenarioSource.CodeInferred,
943
- testType: TestType.CONTRACT,
944
- bugCatchingTarget: addedFields.length > 0
945
- ? `response shape regression: \`${fieldsList}\` added in ${fileName} missing or wrong in GET ${ep.path} response`
946
- : `response shape regression: change in ${fileName} not reflected in GET ${ep.path} response`,
947
- });
948
- }
949
- }
950
- return scenarios;
951
- }