@skyramp/mcp 0.3.1 → 0.3.2-rc.pom-2

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 (177) hide show
  1. package/build/index.js +2 -1
  2. package/build/prompts/code-reuse.d.ts +7 -1
  3. package/build/prompts/code-reuse.js +8 -5
  4. package/build/prompts/code-reuse.test.d.ts +1 -0
  5. package/build/prompts/code-reuse.test.js +62 -0
  6. package/build/prompts/pom-aware-code-reuse.d.ts +6 -1
  7. package/build/prompts/pom-aware-code-reuse.js +100 -53
  8. package/build/prompts/pom-aware-code-reuse.test.d.ts +1 -0
  9. package/build/prompts/pom-aware-code-reuse.test.js +11 -0
  10. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +4 -2
  11. package/build/prompts/test-recommendation/diffExecutionPlan.js +11 -65
  12. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +2 -2
  13. package/build/prompts/test-recommendation/recommendationSections.js +5 -2
  14. package/build/prompts/test-recommendation/scopeAssessment.js +1 -1
  15. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +26 -1
  16. package/build/prompts/test-recommendation/test-recommendation-prompt.js +68 -56
  17. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +48 -11
  18. package/build/prompts/testbot/testbot-prompts.d.ts +1 -1
  19. package/build/prompts/testbot/testbot-prompts.js +67 -21
  20. package/build/prompts/testbot/testbot-prompts.test.js +44 -0
  21. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +7 -0
  22. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +71 -0
  23. package/build/recommendation/budgeters/diversityBalancedBudgeter.test.d.ts +1 -0
  24. package/build/recommendation/budgeters/diversityBalancedBudgeter.test.js +75 -0
  25. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +7 -0
  26. package/build/recommendation/budgeters/fixedNBudgeter.js +11 -0
  27. package/build/recommendation/budgeters/fixedNBudgeter.test.d.ts +1 -0
  28. package/build/recommendation/budgeters/fixedNBudgeter.test.js +66 -0
  29. package/build/recommendation/budgeters/shared.d.ts +19 -0
  30. package/build/recommendation/budgeters/shared.js +66 -0
  31. package/build/recommendation/discriminators.d.ts +31 -0
  32. package/build/recommendation/discriminators.js +355 -0
  33. package/build/recommendation/discriminators.test.d.ts +1 -0
  34. package/build/recommendation/discriminators.test.js +324 -0
  35. package/build/recommendation/diversity.d.ts +47 -0
  36. package/build/recommendation/diversity.js +101 -0
  37. package/build/recommendation/diversity.test.d.ts +1 -0
  38. package/build/recommendation/diversity.test.js +77 -0
  39. package/build/recommendation/planRanker.d.ts +50 -0
  40. package/build/recommendation/planRanker.js +67 -0
  41. package/build/recommendation/planRanker.test.d.ts +1 -0
  42. package/build/recommendation/planRanker.test.js +110 -0
  43. package/build/recommendation/testFixtures.d.ts +25 -0
  44. package/build/recommendation/testFixtures.js +45 -0
  45. package/build/resources/testbotResource.js +4 -1
  46. package/build/services/ScenarioGenerationService.d.ts +5 -0
  47. package/build/services/ScenarioGenerationService.js +16 -1
  48. package/build/services/ScenarioGenerationService.test.js +44 -0
  49. package/build/services/TestExecutionService.d.ts +15 -1
  50. package/build/services/TestExecutionService.js +210 -55
  51. package/build/services/TestExecutionService.test.js +397 -0
  52. package/build/services/TestGenerationService.js +19 -1
  53. package/build/services/TestGenerationService.test.js +58 -0
  54. package/build/tool-phases.js +1 -0
  55. package/build/toolNames.d.ts +19 -0
  56. package/build/toolNames.js +19 -0
  57. package/build/tools/code-refactor/codeReuseTool.d.ts +7 -0
  58. package/build/tools/code-refactor/codeReuseTool.js +130 -4
  59. package/build/tools/code-refactor/codeReuseTool.test.d.ts +1 -0
  60. package/build/tools/code-refactor/codeReuseTool.test.js +290 -0
  61. package/build/tools/executeSkyrampTestTool.js +8 -2
  62. package/build/tools/generate-tests/generateBatchScenarioRestTool.d.ts +6 -1
  63. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +110 -17
  64. package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +147 -0
  65. package/build/tools/generate-tests/generateContractRestTool.js +11 -1
  66. package/build/tools/generate-tests/generateIntegrationRestTool.js +24 -1
  67. package/build/tools/generate-tests/generateIntegrationRestTool.test.d.ts +1 -0
  68. package/build/tools/generate-tests/generateIntegrationRestTool.test.js +159 -0
  69. package/build/tools/generate-tests/planGuard.d.ts +13 -0
  70. package/build/tools/generate-tests/planGuard.js +78 -0
  71. package/build/tools/generate-tests/planGuard.test.d.ts +1 -0
  72. package/build/tools/generate-tests/planGuard.test.js +185 -0
  73. package/build/tools/generate-tests/scenarioFileIdentity.d.ts +10 -0
  74. package/build/tools/generate-tests/scenarioFileIdentity.js +46 -0
  75. package/build/tools/generate-tests/scenarioLint.d.ts +30 -0
  76. package/build/tools/generate-tests/scenarioLint.js +150 -0
  77. package/build/tools/generate-tests/scenarioLint.test.d.ts +1 -0
  78. package/build/tools/generate-tests/scenarioLint.test.js +100 -0
  79. package/build/tools/submitReportTool.js +78 -0
  80. package/build/tools/submitReportTool.test.js +255 -0
  81. package/build/tools/test-management/analyzeChangesTool.js +55 -2
  82. package/build/tools/test-management/analyzeChangesTool.test.js +12 -0
  83. package/build/tools/test-management/index.d.ts +1 -0
  84. package/build/tools/test-management/index.js +1 -0
  85. package/build/tools/test-management/registerTestPlanTool.d.ts +2 -0
  86. package/build/tools/test-management/registerTestPlanTool.js +329 -0
  87. package/build/tools/test-management/registerTestPlanTool.test.d.ts +1 -0
  88. package/build/tools/test-management/registerTestPlanTool.test.js +296 -0
  89. package/build/types/Recommendation.d.ts +97 -0
  90. package/build/types/Recommendation.js +48 -0
  91. package/build/types/RepositoryAnalysis.d.ts +14 -14
  92. package/build/types/TestExecution.d.ts +2 -0
  93. package/build/types/TestRecommendation.d.ts +12 -1
  94. package/build/types/TestRecommendation.js +26 -11
  95. package/build/types/TestTypes.js +1 -1
  96. package/build/utils/AnalysisStateManager.d.ts +47 -0
  97. package/build/utils/docker.test.js +1 -1
  98. package/build/utils/planMatchKeys.d.ts +61 -0
  99. package/build/utils/planMatchKeys.js +125 -0
  100. package/build/utils/pom-scope/import-expansion.d.ts +5 -0
  101. package/build/utils/pom-scope/import-expansion.js +32 -0
  102. package/build/utils/pom-scope/index.d.ts +39 -0
  103. package/build/utils/pom-scope/index.js +120 -0
  104. package/build/utils/pom-scope/index.test.d.ts +1 -0
  105. package/build/utils/pom-scope/index.test.js +239 -0
  106. package/build/utils/pom-scope/pom-files.d.ts +3 -0
  107. package/build/utils/pom-scope/pom-files.js +48 -0
  108. package/build/utils/pom-scope/pom-files.test.d.ts +1 -0
  109. package/build/utils/pom-scope/pom-files.test.js +29 -0
  110. package/build/utils/pom-scope/scoring.d.ts +20 -0
  111. package/build/utils/pom-scope/scoring.js +45 -0
  112. package/build/utils/pom-scope/scoring.test.d.ts +1 -0
  113. package/build/utils/pom-scope/scoring.test.js +39 -0
  114. package/build/utils/pom-scope/selector-extractor.d.ts +7 -0
  115. package/build/utils/pom-scope/selector-extractor.js +57 -0
  116. package/build/utils/pom-scope/selector-extractor.test.d.ts +1 -0
  117. package/build/utils/pom-scope/selector-extractor.test.js +67 -0
  118. package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.d.ts +5 -0
  119. package/build/utils/pom-verify/__fixtures__/af-style/asset-list.page.js +5 -0
  120. package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.d.ts +5 -0
  121. package/build/utils/pom-verify/__fixtures__/af-style/pageobjects/asset-list-page.js +9 -0
  122. package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.d.ts +4 -0
  123. package/build/utils/pom-verify/__fixtures__/af-style/report.iframe.page.js +4 -0
  124. package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.d.ts +4 -0
  125. package/build/utils/pom-verify/__fixtures__/af-style/workflow-footer.page.js +4 -0
  126. package/build/utils/pom-verify/bindings.d.ts +19 -0
  127. package/build/utils/pom-verify/bindings.js +161 -0
  128. package/build/utils/pom-verify/bindings.test.d.ts +1 -0
  129. package/build/utils/pom-verify/bindings.test.js +164 -0
  130. package/build/utils/pom-verify/calls.d.ts +16 -0
  131. package/build/utils/pom-verify/calls.js +42 -0
  132. package/build/utils/pom-verify/calls.test.d.ts +1 -0
  133. package/build/utils/pom-verify/calls.test.js +61 -0
  134. package/build/utils/pom-verify/index.d.ts +4 -0
  135. package/build/utils/pom-verify/index.js +4 -0
  136. package/build/utils/pom-verify/resolve.d.ts +7 -0
  137. package/build/utils/pom-verify/resolve.js +27 -0
  138. package/build/utils/pom-verify/resolve.test.d.ts +1 -0
  139. package/build/utils/pom-verify/resolve.test.js +68 -0
  140. package/build/utils/pom-verify/strip.d.ts +9 -0
  141. package/build/utils/pom-verify/strip.js +89 -0
  142. package/build/utils/pom-verify/verify.d.ts +14 -0
  143. package/build/utils/pom-verify/verify.js +158 -0
  144. package/build/utils/pom-verify/verify.test.d.ts +1 -0
  145. package/build/utils/pom-verify/verify.test.js +325 -0
  146. package/build/utils/reportVerification.d.ts +61 -0
  147. package/build/utils/reportVerification.js +104 -0
  148. package/build/utils/reportVerification.test.d.ts +1 -0
  149. package/build/utils/reportVerification.test.js +185 -0
  150. package/build/utils/scenarioDrafting.js +5 -5
  151. package/build/utils/versions.d.ts +3 -3
  152. package/build/utils/versions.js +1 -1
  153. package/build/utils/workspaceAuth.d.ts +9 -1
  154. package/build/utils/workspaceAuth.js +25 -5
  155. package/build/utils/workspaceAuth.test.js +48 -0
  156. package/build/workspace/workspace.d.ts +20 -0
  157. package/build/workspace/workspace.js +4 -0
  158. package/build/workspace/workspace.test.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +77 -8
  160. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  161. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  162. package/node_modules/playwright/node_modules/playwright-core/lib/utils/isomorphic/volatileDate.js +101 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-aszq5EdG.js → codeMirrorModule-Bzd72-bG.js} +1 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-BxS7Jm4s.js → defaultSettingsView-DzxTioTK.js} +101 -101
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.D4JTTy4R.js → index.BGc30U3S.js} +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  168. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.DaRMQKOI.js → uiMode.IaDrb29A.js} +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  170. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  171. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  172. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  173. package/node_modules/playwright/node_modules/playwright-core/src/utils/isomorphic/volatileDate.ts +131 -0
  174. package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
  175. package/node_modules/playwright/package.json +1 -1
  176. package/package.json +3 -3
  177. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
@@ -1,3 +1,4 @@
1
+ import { TOOL_BATCH_SCENARIO_TEST_GENERATION } from "../../toolNames.js";
1
2
  import { z } from "zod";
2
3
  import path from "path";
3
4
  import { ScenarioGenerationService } from "../../services/ScenarioGenerationService.js";
@@ -7,6 +8,10 @@ import { AnalyticsService } from "../../services/AnalyticsService.js";
7
8
  import { getWorkspaceAuthConfig, WorkspaceAuthType, getDefaultAuthHeader, isAuthorizationHeaderName, getAuthScheme } from "../../utils/workspaceAuth.js";
8
9
  import yaml from "js-yaml";
9
10
  import { logger } from "../../utils/logger.js";
11
+ import { guardScenarioApproved } from "./planGuard.js";
12
+ import { lintScenarioChaining } from "./scenarioLint.js";
13
+ import { scenarioFileNameFor } from "./scenarioFileIdentity.js";
14
+ import { toolError } from "../../utils/utils.js";
10
15
  function isJsonValue(v) {
11
16
  if (v === undefined || v === null)
12
17
  return true;
@@ -18,6 +23,19 @@ function isJsonValue(v) {
18
23
  return false;
19
24
  }
20
25
  }
26
+ /** Resolve a gjson-style dot path ("name", "nested.id", "items.0.sku") against parsed JSON.
27
+ * The Go generator silently skips uniqueFields paths that don't resolve, which would
28
+ * quietly disable the SKYR-3882 run-unique protection — so a typo must fail loudly here.
29
+ * `seg in cur` covers arrays too (in-bounds numeric indices are own properties). */
30
+ function resolvesInJson(obj, dotPath) {
31
+ let cur = obj;
32
+ for (const seg of dotPath.split(".")) {
33
+ if (cur === null || typeof cur !== "object" || !(seg in cur))
34
+ return false;
35
+ cur = cur[seg];
36
+ }
37
+ return true;
38
+ }
21
39
  function isJsonObject(v) {
22
40
  if (v === undefined || v === null)
23
41
  return true;
@@ -67,6 +85,7 @@ CRITICAL: For search/filter/list endpoints (e.g., GET /products/search?q=bear&li
67
85
  responseBody: z
68
86
  .string()
69
87
  .optional()
88
+ .refine(isJsonValue, { message: "responseBody must be valid JSON (e.g. '{\"id\": 1}'). The backend extracts chaining identifiers by parsing it — a malformed body silently disables chaining for every later step." })
70
89
  .describe("JSON string of the expected response body. For steps that produce an id used by later steps, include that id field with a realistic UUID generated fresh for this scenario — the backend chains by value-matching any subsequent step path or requestBody that contains the same UUID."),
71
90
  statusCode: z
72
91
  .number()
@@ -76,26 +95,69 @@ CRITICAL: For search/filter/list endpoints (e.g., GET /products/search?q=bear&li
76
95
  .record(z.array(z.string()))
77
96
  .optional()
78
97
  .describe("Response headers as a JSON object. Defaults to Content-Type: application/json."),
98
+ uniqueFields: z
99
+ .array(z.string().min(1, { message: "uniqueFields paths must be non-empty" }))
100
+ .min(1, { message: "uniqueFields, when provided, must list at least one field path" })
101
+ .optional()
102
+ .describe("For POST/PUT create steps: request-body field paths (gjson notation, e.g. \"name\", \"slug\", \"nested.id\") "
103
+ + "that carry a UNIQUE constraint in the target's data model. The generated test injects a run-unique value "
104
+ + "for each so re-runs don't fail with HTTP 409 'already exists'. Identify these from the source schema "
105
+ + "(e.g. Django unique=True, SQL UNIQUE, Pydantic/DTO). Omit for GET/DELETE steps and non-unique fields."),
79
106
  }).superRefine((step, ctx) => {
80
107
  const isBodyMethod = ["POST", "PUT", "PATCH"].includes(step.method);
81
- if (isBodyMethod && step.requestBody) {
82
- let parsed;
83
- try {
84
- parsed = JSON.parse(step.requestBody);
85
- }
86
- catch {
87
- // JSON parse errors are already caught by the isJsonValue refine above
88
- return;
108
+ // Parse once for every body-shape check below; malformed JSON is already
109
+ // rejected by the isJsonValue refine, so an unparseable body just skips them.
110
+ let parsedBody;
111
+ try {
112
+ parsedBody = step.requestBody ? JSON.parse(step.requestBody) : undefined;
113
+ }
114
+ catch {
115
+ parsedBody = undefined;
116
+ }
117
+ // uniqueFields only applies to create-style requests that carry a body — the
118
+ // run-unique value is injected into a request-body field. Reject it on GET/DELETE
119
+ // (or a body method with no requestBody) so a misplaced hint fails loudly.
120
+ if (step.uniqueFields && step.uniqueFields.length > 0) {
121
+ if (!isBodyMethod) {
122
+ ctx.addIssue({
123
+ code: z.ZodIssueCode.custom,
124
+ path: ["uniqueFields"],
125
+ message: `uniqueFields is only valid on POST/PUT/PATCH steps (got ${step.method} ${step.path}). `
126
+ + `Omit it for GET/DELETE steps.`,
127
+ });
89
128
  }
90
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
129
+ else if (!step.requestBody) {
91
130
  ctx.addIssue({
92
131
  code: z.ZodIssueCode.custom,
93
- path: ["requestBody"],
94
- message: `requestBody for ${step.method} ${step.path} is an empty object {}. `
95
- + `Provide actual field values from the source code schema (Pydantic model, Zod type, DTO) — `
96
- + `do not send an empty body for ${step.method} requests.`,
132
+ path: ["uniqueFields"],
133
+ message: `uniqueFields is set for ${step.method} ${step.path} but there is no requestBody `
134
+ + `containing those fields.`,
97
135
  });
98
136
  }
137
+ else if (parsedBody !== undefined) {
138
+ for (const fieldPath of step.uniqueFields) {
139
+ if (!resolvesInJson(parsedBody, fieldPath)) {
140
+ ctx.addIssue({
141
+ code: z.ZodIssueCode.custom,
142
+ path: ["uniqueFields"],
143
+ message: `uniqueFields path "${fieldPath}" does not resolve in the requestBody of `
144
+ + `${step.method} ${step.path}. The generator silently skips unresolvable paths, `
145
+ + `disabling the run-unique protection — fix the path (gjson dot notation, e.g. `
146
+ + `"name", "nested.id", "items.0.sku") to match an existing body field.`,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ if (isBodyMethod && parsedBody !== null && typeof parsedBody === "object"
153
+ && !Array.isArray(parsedBody) && Object.keys(parsedBody).length === 0) {
154
+ ctx.addIssue({
155
+ code: z.ZodIssueCode.custom,
156
+ path: ["requestBody"],
157
+ message: `requestBody for ${step.method} ${step.path} is an empty object {}. `
158
+ + `Provide actual field values from the source code schema (Pydantic model, Zod type, DTO) — `
159
+ + `do not send an empty body for ${step.method} requests.`,
160
+ });
99
161
  }
100
162
  });
101
163
  const batchScenarioSchema = {
@@ -141,7 +203,7 @@ const batchScenarioSchema = {
141
203
  + `${AUTH_PLACEHOLDER_TOKEN} for all auth headers (Authorization, X-API-Key, Cookie, etc.). `
142
204
  + `Only provide this when you need a custom value (e.g., 'session=${AUTH_PLACEHOLDER_TOKEN}' for Cookie).`),
143
205
  };
144
- const TOOL_NAME = "skyramp_batch_scenario_test_generation";
206
+ const TOOL_NAME = TOOL_BATCH_SCENARIO_TEST_GENERATION;
145
207
  export function registerBatchScenarioTestTool(server) {
146
208
  server.registerTool(TOOL_NAME, {
147
209
  description: `Generate a synthetic scenario trace file for a multi-step API workflow scenario — use this before skyramp_integration_test_generation.
@@ -167,6 +229,13 @@ If a step fails, the error shows which step (N/total), method+path, and reason.
167
229
  Call \`skyramp_integration_test_generation\` with the returned \`scenarioFile\` path. Do NOT also pass \`apiSchema\` or \`endpointURL\` — the scenario file already contains all endpoint information and passing both will cause test generation to fail.`,
168
230
  inputSchema: batchScenarioSchema,
169
231
  }, async (params) => {
232
+ const guardMessage = await guardScenarioApproved({
233
+ scenarioName: params.scenarioName,
234
+ testType: "integration",
235
+ });
236
+ if (guardMessage) {
237
+ return { content: [{ type: "text", text: guardMessage }], isError: true };
238
+ }
170
239
  if (params.authHeader === undefined) {
171
240
  try {
172
241
  const repoPath = params.outputDir || process.cwd();
@@ -319,8 +388,7 @@ Call \`skyramp_integration_test_generation\` with the returned \`scenarioFile\`
319
388
  }
320
389
  const service = new ScenarioGenerationService();
321
390
  const steps = params.steps;
322
- const scenarioSlug = params.scenarioName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "scenario";
323
- const fileName = `scenario_${scenarioSlug}.json`;
391
+ const fileName = scenarioFileNameFor(params.scenarioName);
324
392
  const filePath = path.join(params.outputDir, fileName);
325
393
  const resolvedOut = path.resolve(params.outputDir);
326
394
  if (!path.resolve(filePath).startsWith(resolvedOut + path.sep) && path.resolve(filePath) !== resolvedOut) {
@@ -350,6 +418,7 @@ Call \`skyramp_integration_test_generation\` with the returned \`scenarioFile\`
350
418
  authHeader: params.authHeader ?? "",
351
419
  authScheme: params.authScheme ?? "",
352
420
  authToken: params.authToken,
421
+ uniqueFields: step.uniqueFields,
353
422
  };
354
423
  const traceRequest = service.generateTraceRequestFromInput(stepParams);
355
424
  if (!traceRequest) {
@@ -375,6 +444,29 @@ Call \`skyramp_integration_test_generation\` with the returned \`scenarioFile\`
375
444
  }
376
445
  traceRequests.push(traceRequest);
377
446
  }
447
+ // ── SKYR-3884: chaining lint ──
448
+ // The generated test chains FKs/path params by value-matching against
449
+ // PRIOR steps' responses; mis-ordered or missing "bait" values silently
450
+ // degrade to hardcoded literals. Lint the built TraceRequests — the
451
+ // artifact the Go reader actually consumes (post basePath/default
452
+ // transforms), not the raw tool input. Ordering mistakes are fixable →
453
+ // hard error before the file is written; unchainable/ambiguous
454
+ // references → warning on the success text.
455
+ const chainLint = lintScenarioChaining(traceRequests.map((t) => ({
456
+ method: t.Method,
457
+ path: t.Path,
458
+ requestBody: t.RequestBody,
459
+ responseBody: t.ResponseBody,
460
+ })));
461
+ if (chainLint.errors.length > 0) {
462
+ return toolError(`**Scenario step-ordering errors**\n\n`
463
+ + chainLint.errors.map((e) => `- ${e}`).join("\n")
464
+ + `\n\nReorder or fix the steps and resubmit the full batch.`);
465
+ }
466
+ const chainLintWarning = chainLint.warnings.length > 0
467
+ ? `\n\n⚠️ **Chaining warnings** — the generated test may hardcode these instead of chaining:\n`
468
+ + chainLint.warnings.map((w) => ` - ${w}`).join("\n")
469
+ : "";
378
470
  try {
379
471
  fs.writeFileSync(filePath, JSON.stringify(traceRequests, null, 2), "utf8");
380
472
  }
@@ -395,7 +487,8 @@ Call \`skyramp_integration_test_generation\` with the returned \`scenarioFile\`
395
487
  + `**Steps:**\n${steps.map((s, i) => ` ${i + 1}. ${s.method} ${s.path} → ${s.statusCode ?? "default"}`).join("\n")}\n\n`
396
488
  + `**File:** ${filePath}\n\n`
397
489
  + `**Next:** Call \`skyramp_integration_test_generation\` with \`scenarioFile: "${filePath}"\``
398
- + specValidationWarning,
490
+ + specValidationWarning
491
+ + chainLintWarning,
399
492
  },
400
493
  ],
401
494
  isError: false,
@@ -284,3 +284,150 @@ describe("generateBatchScenarioRestTool — spec URL fetch safety (comment 32030
284
284
  delete global.fetch;
285
285
  });
286
286
  });
287
+ describe("stepSchema uniqueFields validation (SKYR-3882)", () => {
288
+ const base = { method: "POST", path: "/api/v1/providers/", requestBody: '{"name":"x"}' };
289
+ it("accepts uniqueFields on a POST with a body", () => {
290
+ expect(stepSchema.safeParse({ ...base, uniqueFields: ["name"] }).success).toBe(true);
291
+ });
292
+ it("rejects an empty uniqueFields array", () => {
293
+ expect(stepSchema.safeParse({ ...base, uniqueFields: [] }).success).toBe(false);
294
+ });
295
+ it("rejects empty-string paths in uniqueFields", () => {
296
+ expect(stepSchema.safeParse({ ...base, uniqueFields: [""] }).success).toBe(false);
297
+ });
298
+ it("rejects uniqueFields on a non-body method (GET)", () => {
299
+ expect(stepSchema.safeParse({ method: "GET", path: "/x", uniqueFields: ["name"] }).success).toBe(false);
300
+ });
301
+ it("rejects uniqueFields on a POST with no requestBody", () => {
302
+ expect(stepSchema.safeParse({ method: "POST", path: "/api/v1/providers/", uniqueFields: ["name"] }).success).toBe(false);
303
+ });
304
+ });
305
+ // ─────────────────────────────────────────────────────────────────────────────
306
+ // SKYR-3884 write-side validations
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+ describe("stepSchema — responseBody JSON validation (SKYR-3884)", () => {
309
+ it("rejects a responseBody that is not valid JSON", () => {
310
+ const result = stepSchema.safeParse({
311
+ method: "POST",
312
+ path: "/api/v1/products",
313
+ requestBody: JSON.stringify({ name: "W" }),
314
+ responseBody: "{id: 1, oops",
315
+ });
316
+ expect(result.success).toBe(false);
317
+ const issue = result.error?.issues.find((i) => i.path.includes("responseBody"));
318
+ expect(issue?.message).toContain("valid JSON");
319
+ });
320
+ it("accepts a valid JSON responseBody and an omitted one", () => {
321
+ expect(stepSchema.safeParse({
322
+ method: "POST",
323
+ path: "/api/v1/products",
324
+ requestBody: JSON.stringify({ name: "W" }),
325
+ responseBody: JSON.stringify({ id: 1 }),
326
+ }).success).toBe(true);
327
+ expect(stepSchema.safeParse({ method: "GET", path: "/api/v1/products" }).success).toBe(true);
328
+ });
329
+ });
330
+ describe("stepSchema — uniqueFields paths must resolve in requestBody (SKYR-3884)", () => {
331
+ it("rejects a uniqueFields path that does not resolve in the requestBody", () => {
332
+ const result = stepSchema.safeParse({
333
+ method: "POST",
334
+ path: "/api/v1/products",
335
+ requestBody: JSON.stringify({ name: "W", slug: "w" }),
336
+ uniqueFields: ["nmae"],
337
+ });
338
+ expect(result.success).toBe(false);
339
+ const issue = result.error?.issues.find((i) => i.path.includes("uniqueFields"));
340
+ expect(issue?.message).toContain("nmae");
341
+ });
342
+ it("accepts top-level, nested, and array-indexed uniqueFields paths that resolve", () => {
343
+ const result = stepSchema.safeParse({
344
+ method: "POST",
345
+ path: "/api/v1/products",
346
+ requestBody: JSON.stringify({ name: "W", nested: { slug: "w" }, items: [{ sku: "S1" }] }),
347
+ uniqueFields: ["name", "nested.slug", "items.0.sku"],
348
+ });
349
+ expect(result.success).toBe(true);
350
+ });
351
+ });
352
+ describe("generateBatchScenarioRestTool — chaining lint (SKYR-3884)", () => {
353
+ let handler;
354
+ let writeFileSyncSpy;
355
+ const baseParams = {
356
+ scenarioName: "test_scenario",
357
+ destination: "localhost",
358
+ outputDir: "/tmp/skyramp-test",
359
+ };
360
+ beforeEach(() => {
361
+ jest.clearAllMocks();
362
+ handler = captureHandler();
363
+ writeFileSyncSpy = jest.spyOn(fs, "writeFileSync").mockImplementation(() => { });
364
+ // The lint inspects the BUILT TraceRequests (the artifact the Go reader
365
+ // consumes), so the mock must pass the step through in TraceRequest shape.
366
+ ScenarioGenerationService.mockImplementation(() => ({
367
+ generateTraceRequestFromInput: jest.fn((p) => ({
368
+ Method: p.method,
369
+ Path: p.path,
370
+ RequestBody: p.requestBody ?? "",
371
+ ResponseBody: p.responseBody ?? "",
372
+ })),
373
+ }));
374
+ });
375
+ afterEach(() => {
376
+ writeFileSyncSpy.mockRestore();
377
+ });
378
+ it("hard-rejects a scenario whose FK reference is produced only by a later step", async () => {
379
+ const result = await handler({
380
+ ...baseParams,
381
+ steps: [
382
+ {
383
+ method: "POST",
384
+ path: "/api/circuits/provider-accounts/",
385
+ requestBody: JSON.stringify({ provider: 1, name: "A" }),
386
+ responseBody: JSON.stringify({ id: 7 }),
387
+ },
388
+ {
389
+ method: "POST",
390
+ path: "/api/circuits/providers/",
391
+ requestBody: JSON.stringify({ name: "P" }),
392
+ responseBody: JSON.stringify({ id: 1 }),
393
+ },
394
+ ],
395
+ });
396
+ expect(result.isError).toBe(true);
397
+ expect(result.content[0].text).toContain("later step");
398
+ expect(writeFileSyncSpy).not.toHaveBeenCalled();
399
+ });
400
+ it("appends a chaining warning (does not reject) for unchainable path values", async () => {
401
+ const result = await handler({
402
+ ...baseParams,
403
+ steps: [
404
+ {
405
+ method: "POST",
406
+ path: "/api/circuits/providers/",
407
+ requestBody: JSON.stringify({ name: "P" }),
408
+ responseBody: JSON.stringify({ id: 1 }),
409
+ },
410
+ { method: "GET", path: "/api/circuits/providers/999/summary/" },
411
+ ],
412
+ });
413
+ expect(result.isError).toBeFalsy();
414
+ expect(result.content[0].text).toContain("Chaining warning");
415
+ expect(result.content[0].text).toContain("999");
416
+ });
417
+ it("emits no chaining output for a well-chained scenario", async () => {
418
+ const result = await handler({
419
+ ...baseParams,
420
+ steps: [
421
+ {
422
+ method: "POST",
423
+ path: "/api/circuits/providers/",
424
+ requestBody: JSON.stringify({ name: "P" }),
425
+ responseBody: JSON.stringify({ id: 1 }),
426
+ },
427
+ { method: "GET", path: "/api/circuits/providers/1/summary/" },
428
+ ],
429
+ });
430
+ expect(result.isError).toBeFalsy();
431
+ expect(result.content[0].text).not.toContain("Chaining warning");
432
+ });
433
+ });
@@ -1,10 +1,13 @@
1
1
  import path from "path";
2
+ import { TOOL_CONTRACT_TEST_GENERATION } from "../../toolNames.js";
2
3
  import { z } from "zod";
3
4
  import { baseTestSchema, TestType } from "../../types/TestTypes.js";
4
5
  import { TestGenerationService, } from "../../services/TestGenerationService.js";
5
6
  import { AnalyticsService } from "../../services/AnalyticsService.js";
6
7
  import { getPersonaPrefix } from "../../prompts/personas.js";
7
8
  import { isContractConsumerModeEnabled, isTestbotEnabled } from "../../utils/featureFlags.js";
9
+ import { guardScenarioApproved, pathFromEndpointURL } from "./planGuard.js";
10
+ import { toolError } from "../../utils/utils.js";
8
11
  // SKYRAMP_FEATURE_CONTRACT_CONSUMER_MODE gates BOTH:
9
12
  // 1. Consumer-side contract test generation (`consumerMode` /
10
13
  // `consumerOutput` schema fields, validation, post-gen instructions,
@@ -353,7 +356,7 @@ The generated consumer contract test contains a stub test function that uses Sky
353
356
  return baseOptions;
354
357
  }
355
358
  }
356
- const TOOL_NAME = "skyramp_contract_test_generation";
359
+ const TOOL_NAME = TOOL_CONTRACT_TEST_GENERATION;
357
360
  const MODES_SECTION = CONSUMER_MODE_ENABLED
358
361
  ? `**Modes:**
359
362
  - Default (no mode set): both \`providerMode\` and \`consumerMode\` default to false. This generates both provider and consumer contract tests — equivalent to setting both modes to true.
@@ -413,6 +416,13 @@ ${CHAINING_SECTION}
413
416
  `,
414
417
  inputSchema: contractTestSchema,
415
418
  }, async (params) => {
419
+ const guardMessage = await guardScenarioApproved({
420
+ testType: "contract",
421
+ method: params.method || undefined,
422
+ path: pathFromEndpointURL(params.endpointURL),
423
+ });
424
+ if (guardMessage)
425
+ return toolError(guardMessage);
416
426
  const service = new ContractTestService();
417
427
  const result = await service.generateTest(params);
418
428
  AnalyticsService.pushTestGenerationToolEvent(TOOL_NAME, result, params).catch(() => {
@@ -1,3 +1,4 @@
1
+ import { TOOL_INTEGRATION_TEST_GENERATION } from "../../toolNames.js";
1
2
  import { z } from "zod";
2
3
  import { baseTestSchema, baseTraceSchema, TestType, codeRefactoringSchema, } from "../../types/TestTypes.js";
3
4
  import { TestGenerationService, } from "../../services/TestGenerationService.js";
@@ -5,6 +6,9 @@ import { AnalyticsService } from "../../services/AnalyticsService.js";
5
6
  import { AUTH_CONFLICT_ERROR_MSG } from "../../prompts/test-recommendation/recommendationSections.js";
6
7
  import { getPersonaPrefix } from "../../prompts/personas.js";
7
8
  import { isTestbotEnabled } from "../../utils/featureFlags.js";
9
+ import { guardScenarioApproved, pathFromEndpointURL } from "./planGuard.js";
10
+ import { readScenarioNameFromFile } from "./scenarioFileIdentity.js";
11
+ import { toolError } from "../../utils/utils.js";
8
12
  const ADD_ASSERTIONS_DEFAULT = isTestbotEnabled();
9
13
  const integrationTestSchema = z
10
14
  .object({
@@ -76,7 +80,7 @@ Call \`skyramp_enhance_assertions\` with \`testFile\` set to the absolute path o
76
80
  return null;
77
81
  }
78
82
  }
79
- const TOOL_NAME = "skyramp_integration_test_generation";
83
+ const TOOL_NAME = TOOL_INTEGRATION_TEST_GENERATION;
80
84
  export function registerIntegrationTestTool(server) {
81
85
  server.registerTool(TOOL_NAME, {
82
86
  description: `${getPersonaPrefix()}Before calling this tool, you MUST output a <thinking> block that covers:
@@ -122,6 +126,25 @@ Generate an integration test from a scenario file or a live endpoint trace.
122
126
  isError: true,
123
127
  };
124
128
  }
129
+ let guardMessage = null;
130
+ if (params.scenarioFile) {
131
+ // Guard even when scenarioName can't be derived (fail closed): skipping
132
+ // the check for an unreadable/malformed scenarioFile would let any such
133
+ // file bypass the hard gate. With no derivable identity the query can't
134
+ // match, so an active plan rejects it; standalone usage (no active
135
+ // run / no plan) is still allowed by guardScenarioApproved itself.
136
+ const scenarioName = readScenarioNameFromFile(params.scenarioFile);
137
+ guardMessage = await guardScenarioApproved({ scenarioName, testType: "integration" });
138
+ }
139
+ else {
140
+ guardMessage = await guardScenarioApproved({
141
+ testType: "integration",
142
+ method: params.method || undefined,
143
+ path: pathFromEndpointURL(params.endpointURL),
144
+ });
145
+ }
146
+ if (guardMessage)
147
+ return toolError(guardMessage);
125
148
  const service = new IntegrationTestService();
126
149
  const result = await service.generateTest(params);
127
150
  AnalyticsService.pushTestGenerationToolEvent(TOOL_NAME, result, params).catch(() => {
@@ -0,0 +1,159 @@
1
+ // @ts-nocheck - Jest ESM type inference issues
2
+ import { jest } from "@jest/globals";
3
+ import * as fs from "fs/promises";
4
+ import * as path from "path";
5
+ import * as os from "os";
6
+ // TestGenerationService transitively loads the @skyramp/skyramp native library
7
+ // (blocks 180s waiting for the platform dylib) — stub the base class out.
8
+ jest.unstable_mockModule("../../services/TestGenerationService.js", () => ({
9
+ TestGenerationService: class {
10
+ async generateTest() {
11
+ return { content: [{ type: "text", text: "generated" }] };
12
+ }
13
+ buildBaseGenerationOptions() {
14
+ return {};
15
+ }
16
+ },
17
+ }));
18
+ jest.unstable_mockModule("../../services/AnalyticsService.js", () => ({
19
+ AnalyticsService: {
20
+ pushTestGenerationToolEvent: jest.fn().mockResolvedValue(undefined),
21
+ },
22
+ }));
23
+ jest.unstable_mockModule("../../utils/logger.js", () => ({
24
+ logger: {
25
+ debug: jest.fn(),
26
+ info: jest.fn(),
27
+ warning: jest.fn(),
28
+ error: jest.fn(),
29
+ },
30
+ }));
31
+ const { registerIntegrationTestTool, IntegrationTestService } = await import("./generateIntegrationRestTool.js");
32
+ const { setActiveRunStatePath, clearActiveRunStatePath } = await import("../../utils/AnalysisStateManager.js");
33
+ const { CandidateSource } = await import("../../types/Recommendation.js");
34
+ /** Build a minimal mock McpServer, register the tool, and return the captured handler. */
35
+ function captureHandler() {
36
+ let capturedHandler;
37
+ const mockServer = {
38
+ registerTool: jest.fn((_name, _meta, handler) => {
39
+ capturedHandler = handler;
40
+ }),
41
+ };
42
+ registerIntegrationTestTool(mockServer);
43
+ return capturedHandler;
44
+ }
45
+ const APPROVED_ITEM = {
46
+ candidateId: "orders-workflow-abcd1234",
47
+ scenarioName: "orders_workflow",
48
+ testType: "integration",
49
+ category: "business_rule",
50
+ source: CandidateSource.AGENT,
51
+ matchKeys: ["name:orders-workflow", "ep:integration:POST:/api/v1/orders"],
52
+ };
53
+ async function writeStateFile(dir, approvedPlan) {
54
+ const stateFile = path.join(dir, "analyze-changes-state.json");
55
+ await fs.writeFile(stateFile, JSON.stringify({
56
+ existingTests: [],
57
+ analysisScope: "current_branch_diff",
58
+ newEndpoints: [],
59
+ approvedPlan,
60
+ metadata: {
61
+ sessionId: "test-session",
62
+ stateType: "analysis",
63
+ createdAt: "2026-01-01T00:00:00.000Z",
64
+ updatedAt: "2026-01-01T00:00:00.000Z",
65
+ },
66
+ }), "utf-8");
67
+ return stateFile;
68
+ }
69
+ const PLAN = {
70
+ planId: "plan-int-1",
71
+ createdAt: "2026-01-01T00:00:00.000Z",
72
+ generate: [APPROVED_ITEM],
73
+ additional: [],
74
+ demotions: [],
75
+ };
76
+ describe("skyramp_integration_test_generation — plan guard on scenarioFile mode", () => {
77
+ let handler;
78
+ let generateSpy;
79
+ let tmpDirs = [];
80
+ const originalRunnerTemp = process.env.RUNNER_TEMP;
81
+ beforeEach(() => {
82
+ handler = captureHandler();
83
+ generateSpy = jest
84
+ .spyOn(IntegrationTestService.prototype, "generateTest")
85
+ .mockResolvedValue({ content: [{ type: "text", text: "generated" }] });
86
+ });
87
+ afterEach(async () => {
88
+ generateSpy.mockRestore();
89
+ clearActiveRunStatePath();
90
+ process.env.RUNNER_TEMP = originalRunnerTemp;
91
+ for (const dir of tmpDirs) {
92
+ await fs.rm(dir, { recursive: true, force: true });
93
+ }
94
+ tmpDirs = [];
95
+ });
96
+ async function mkTmpDir() {
97
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "integration-guard-test-"));
98
+ tmpDirs.push(dir);
99
+ return dir;
100
+ }
101
+ it("allows a scenarioFile whose scenarioName matches an approved GENERATE item", async () => {
102
+ const dir = await mkTmpDir();
103
+ setActiveRunStatePath(await writeStateFile(dir, PLAN));
104
+ const scenarioFile = path.join(dir, "scenario_orders-workflow.json");
105
+ await fs.writeFile(scenarioFile, JSON.stringify([{ scenarioName: "orders_workflow", method: "POST" }]), "utf-8");
106
+ const result = await handler({ scenarioFile });
107
+ expect(result.isError).toBeUndefined();
108
+ expect(generateSpy).toHaveBeenCalled();
109
+ });
110
+ it("rejects (fail closed) when scenarioFile is unreadable and an approved plan is active — no gate bypass", async () => {
111
+ const dir = await mkTmpDir();
112
+ setActiveRunStatePath(await writeStateFile(dir, PLAN));
113
+ const result = await handler({
114
+ scenarioFile: path.join(dir, "does-not-exist.json"),
115
+ });
116
+ expect(result.isError).toBe(true);
117
+ expect(result.content[0].text).toContain("plan-int-1");
118
+ expect(generateSpy).not.toHaveBeenCalled();
119
+ });
120
+ it("rejects (fail closed) when scenarioFile parses but carries no scenarioName and an approved plan is active", async () => {
121
+ const dir = await mkTmpDir();
122
+ setActiveRunStatePath(await writeStateFile(dir, PLAN));
123
+ const scenarioFile = path.join(dir, "scenario_nameless.json");
124
+ await fs.writeFile(scenarioFile, JSON.stringify([{ method: "POST", path: "/api/v1/orders" }]), "utf-8");
125
+ const result = await handler({ scenarioFile });
126
+ expect(result.isError).toBe(true);
127
+ expect(generateSpy).not.toHaveBeenCalled();
128
+ });
129
+ it("still allows an unreadable scenarioFile when there is no active run state (standalone back-compat)", async () => {
130
+ clearActiveRunStatePath();
131
+ delete process.env.RUNNER_TEMP;
132
+ const result = await handler({
133
+ scenarioFile: "/nonexistent/scenario.json",
134
+ });
135
+ expect(result.isError).toBeUndefined();
136
+ expect(generateSpy).toHaveBeenCalled();
137
+ });
138
+ // SKYR-3884 — filename fallback for files with no embedded scenarioName.
139
+ it("allows a scenario file with no embedded scenarioName when its filename slug matches the plan (files written by MCP <= 0.3.1)", async () => {
140
+ const dir = await mkTmpDir();
141
+ setActiveRunStatePath(await writeStateFile(dir, PLAN));
142
+ // Bare TraceRequest array — exactly what ScenarioGenerationService wrote
143
+ // before scenarioName embedding. Identity must come from the file name.
144
+ const scenarioFile = path.join(dir, "scenario_orders-workflow.json");
145
+ await fs.writeFile(scenarioFile, JSON.stringify([{ Method: "POST", Path: "/api/v1/orders" }]), "utf-8");
146
+ const result = await handler({ scenarioFile });
147
+ expect(result.isError).toBeUndefined();
148
+ expect(generateSpy).toHaveBeenCalled();
149
+ });
150
+ it("still rejects when neither embedded name nor filename slug matches the plan", async () => {
151
+ const dir = await mkTmpDir();
152
+ setActiveRunStatePath(await writeStateFile(dir, PLAN));
153
+ const scenarioFile = path.join(dir, "scenario_unrelated-flow.json");
154
+ await fs.writeFile(scenarioFile, JSON.stringify([{ Method: "POST", Path: "/api/v1/orders" }]), "utf-8");
155
+ const result = await handler({ scenarioFile });
156
+ expect(result.isError).toBe(true);
157
+ expect(generateSpy).not.toHaveBeenCalled();
158
+ });
159
+ });
@@ -0,0 +1,13 @@
1
+ import { PlanMatchQuery } from "../../utils/planMatchKeys.js";
2
+ export type PlanGuardQuery = PlanMatchQuery;
3
+ /** Extract the URL path from a generation tool's `endpointURL` param — used
4
+ * for contract/direct-mode integration guard queries. Falls back to treating
5
+ * the value as a bare path when it isn't a parseable absolute URL, and to
6
+ * undefined (no path-based match attempted) when it's neither. */
7
+ export declare function pathFromEndpointURL(endpointURL: string | undefined): string | undefined;
8
+ /**
9
+ * Returns null when the generation call is allowed, or a human-readable error
10
+ * message (for `toolError`) when it must be rejected because it doesn't match
11
+ * any item in the approved plan's GENERATE list.
12
+ */
13
+ export declare function guardScenarioApproved(query: PlanGuardQuery): Promise<string | null>;