@skyramp/mcp 0.3.1 → 0.3.2-rc.pom-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 (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 +98 -52
  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 +3 -0
  58. package/build/tools/code-refactor/codeReuseTool.js +76 -4
  59. package/build/tools/code-refactor/codeReuseTool.test.d.ts +1 -0
  60. package/build/tools/code-refactor/codeReuseTool.test.js +51 -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_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>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * SKYR-3879 Path B — hard gate wired into the generation tools' handlers.
3
+ * Conditional on an ACTIVE testbot run carrying an `approvedPlan`: standalone
4
+ * / IDE MCP usage (no active run) and runs that never pre-seeded or
5
+ * registered a plan are both unaffected (return null = allowed).
6
+ */
7
+ import * as path from "path";
8
+ import { getActiveRunStatePath, StateManager } from "../../utils/AnalysisStateManager.js";
9
+ import { matchesApprovedPlan } from "../../utils/planMatchKeys.js";
10
+ import { TOOL_REGISTER_TEST_PLAN } from "../../toolNames.js";
11
+ /**
12
+ * Resolve the active run's state file path. `getActiveRunStatePath()` is only
13
+ * set by skyramp_analyze_changes for multi-repo runs (the ones carrying a
14
+ * `repository` param — see AnalysisStateManager.ts). Single-repo Testbot runs
15
+ * never set it, but their stateFile path is still deterministic in CI: the
16
+ * StateManager constructor roots "analysis"-type state under
17
+ * `$RUNNER_TEMP/skyramp/analyze-changes-state.json`. Falling back to that path
18
+ * covers single-repo Testbot runs without requiring a new anchor mechanism;
19
+ * standalone/IDE usage (no RUNNER_TEMP) still resolves to undefined.
20
+ */
21
+ function resolveGuardStatePath() {
22
+ const anchored = getActiveRunStatePath();
23
+ if (anchored)
24
+ return anchored;
25
+ if (process.env.RUNNER_TEMP) {
26
+ return path.join(process.env.RUNNER_TEMP, "skyramp", "analyze-changes-state.json");
27
+ }
28
+ return undefined;
29
+ }
30
+ /** Extract the URL path from a generation tool's `endpointURL` param — used
31
+ * for contract/direct-mode integration guard queries. Falls back to treating
32
+ * the value as a bare path when it isn't a parseable absolute URL, and to
33
+ * undefined (no path-based match attempted) when it's neither. */
34
+ export function pathFromEndpointURL(endpointURL) {
35
+ if (!endpointURL)
36
+ return undefined;
37
+ try {
38
+ // new URL() percent-encodes {param} placeholders ({id} → %7Bid%7D), while
39
+ // register-time matchKeys are computed from raw scenario paths with braces
40
+ // intact — restore them or parameterized endpoints never match the plan.
41
+ return new URL(endpointURL).pathname.replace(/%7B/gi, "{").replace(/%7D/gi, "}");
42
+ }
43
+ catch {
44
+ return endpointURL.startsWith("/") ? endpointURL : undefined;
45
+ }
46
+ }
47
+ /**
48
+ * Returns null when the generation call is allowed, or a human-readable error
49
+ * message (for `toolError`) when it must be rejected because it doesn't match
50
+ * any item in the approved plan's GENERATE list.
51
+ */
52
+ export async function guardScenarioApproved(query) {
53
+ const statePath = resolveGuardStatePath();
54
+ if (!statePath)
55
+ return null;
56
+ let stateData;
57
+ try {
58
+ stateData = await StateManager.fromStatePath(statePath).readData();
59
+ }
60
+ catch {
61
+ // Unreadable/corrupt state must never block generation on an infra hiccup.
62
+ return null;
63
+ }
64
+ const approvedPlan = stateData?.approvedPlan;
65
+ if (!approvedPlan)
66
+ return null;
67
+ // GENERATE items, plus ADDITIONAL as the documented backfill pool — but only
68
+ // when there's a non-empty GENERATE list to backfill (see matchesApprovedPlan).
69
+ if (matchesApprovedPlan(approvedPlan, query))
70
+ return null;
71
+ const outstanding = approvedPlan.generate.length > 0
72
+ ? approvedPlan.generate.map((item) => ` - [${item.testType}] ${item.scenarioName}`).join("\n")
73
+ : " (none)";
74
+ return (`This scenario is not in the approved plan from ${TOOL_REGISTER_TEST_PLAN} (plan ${approvedPlan.planId}) — neither its GENERATE nor its ADDITIONAL list.\n\n` +
75
+ `Outstanding approved GENERATE items:\n${outstanding}\n\n` +
76
+ `Call ${TOOL_REGISTER_TEST_PLAN} first with this candidate included in \`candidates\`, then retry generation using the ` +
77
+ `exact scenarioName/testType/endpoint from the returned GENERATE list.`);
78
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,185 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ const { guardScenarioApproved, pathFromEndpointURL } = await import("./planGuard.js");
5
+ const { setActiveRunStatePath, clearActiveRunStatePath } = await import("../../utils/AnalysisStateManager.js");
6
+ const { CandidateSource } = await import("../../types/Recommendation.js");
7
+ async function writeStateFile(dir, data) {
8
+ const stateFile = path.join(dir, "analyze-changes-state.json");
9
+ await fs.writeFile(stateFile, JSON.stringify({
10
+ existingTests: [],
11
+ analysisScope: "current_branch_diff",
12
+ newEndpoints: [],
13
+ ...data,
14
+ metadata: {
15
+ sessionId: "test-session",
16
+ stateType: "analysis",
17
+ createdAt: "2026-01-01T00:00:00.000Z",
18
+ updatedAt: "2026-01-01T00:00:00.000Z",
19
+ },
20
+ }), "utf-8");
21
+ return stateFile;
22
+ }
23
+ const APPROVED_ITEM = {
24
+ candidateId: "orders-workflow-abcd1234",
25
+ scenarioName: "orders_workflow",
26
+ testType: "integration",
27
+ category: "business_rule",
28
+ source: CandidateSource.AGENT,
29
+ matchKeys: ["name:orders-workflow", "ep:integration:POST:/api/v1/orders"],
30
+ };
31
+ describe("guardScenarioApproved — SKYR-3879 Path B hard gate", () => {
32
+ let tmpDirs = [];
33
+ const originalRunnerTemp = process.env.RUNNER_TEMP;
34
+ afterEach(async () => {
35
+ clearActiveRunStatePath();
36
+ process.env.RUNNER_TEMP = originalRunnerTemp;
37
+ for (const dir of tmpDirs) {
38
+ await fs.rm(dir, { recursive: true, force: true });
39
+ }
40
+ tmpDirs = [];
41
+ });
42
+ async function mkTmpDir() {
43
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "plan-guard-test-"));
44
+ tmpDirs.push(dir);
45
+ return dir;
46
+ }
47
+ it("allows (returns null) when there is no active run state at all", async () => {
48
+ clearActiveRunStatePath();
49
+ delete process.env.RUNNER_TEMP;
50
+ const result = await guardScenarioApproved({ scenarioName: "anything" });
51
+ expect(result).toBeNull();
52
+ });
53
+ it("allows when the active run's state has no approvedPlan (standalone / pre-registration back-compat)", async () => {
54
+ const dir = await mkTmpDir();
55
+ const stateFile = await writeStateFile(dir, {});
56
+ setActiveRunStatePath(stateFile);
57
+ const result = await guardScenarioApproved({ scenarioName: "orders_workflow", testType: "integration" });
58
+ expect(result).toBeNull();
59
+ });
60
+ it("allows a scenarioName match against an approved GENERATE item", async () => {
61
+ const dir = await mkTmpDir();
62
+ const stateFile = await writeStateFile(dir, {
63
+ approvedPlan: {
64
+ planId: "plan-1",
65
+ createdAt: "2026-01-01T00:00:00.000Z",
66
+ generate: [APPROVED_ITEM],
67
+ additional: [],
68
+ demotions: [],
69
+ },
70
+ });
71
+ setActiveRunStatePath(stateFile);
72
+ const result = await guardScenarioApproved({ scenarioName: "orders_workflow", testType: "integration" });
73
+ expect(result).toBeNull();
74
+ });
75
+ it("allows a testType+method+path match against an approved GENERATE item (contract fallback key)", async () => {
76
+ const dir = await mkTmpDir();
77
+ const stateFile = await writeStateFile(dir, {
78
+ approvedPlan: {
79
+ planId: "plan-1",
80
+ createdAt: "2026-01-01T00:00:00.000Z",
81
+ generate: [APPROVED_ITEM],
82
+ additional: [],
83
+ demotions: [],
84
+ },
85
+ });
86
+ setActiveRunStatePath(stateFile);
87
+ const result = await guardScenarioApproved({ testType: "integration", method: "POST", path: "/api/v1/orders" });
88
+ expect(result).toBeNull();
89
+ });
90
+ it("allows a match against an approved ADDITIONAL item (documented backfill path)", async () => {
91
+ const dir = await mkTmpDir();
92
+ const stateFile = await writeStateFile(dir, {
93
+ approvedPlan: {
94
+ planId: "plan-1",
95
+ createdAt: "2026-01-01T00:00:00.000Z",
96
+ generate: [APPROVED_ITEM],
97
+ additional: [
98
+ {
99
+ ...APPROVED_ITEM,
100
+ candidateId: "backfill-efgh5678",
101
+ scenarioName: "orders_backfill",
102
+ matchKeys: ["name:orders-backfill", "ep:integration:GET:/api/v1/orders"],
103
+ },
104
+ ],
105
+ demotions: [],
106
+ },
107
+ });
108
+ setActiveRunStatePath(stateFile);
109
+ const result = await guardScenarioApproved({ scenarioName: "orders_backfill", testType: "integration" });
110
+ expect(result).toBeNull();
111
+ });
112
+ it("rejects an ADDITIONAL match when the GENERATE list is empty — zero-budget plans have nothing to backfill", async () => {
113
+ const dir = await mkTmpDir();
114
+ const stateFile = await writeStateFile(dir, {
115
+ approvedPlan: {
116
+ planId: "plan-zero-budget",
117
+ createdAt: "2026-01-01T00:00:00.000Z",
118
+ generate: [],
119
+ additional: [APPROVED_ITEM],
120
+ demotions: [],
121
+ },
122
+ });
123
+ setActiveRunStatePath(stateFile);
124
+ const result = await guardScenarioApproved({ scenarioName: "orders_workflow", testType: "integration" });
125
+ expect(result).not.toBeNull();
126
+ expect(result).toContain("plan-zero-budget");
127
+ });
128
+ it("rejects with the outstanding GENERATE list when nothing matches", async () => {
129
+ const dir = await mkTmpDir();
130
+ const stateFile = await writeStateFile(dir, {
131
+ approvedPlan: {
132
+ planId: "plan-1",
133
+ createdAt: "2026-01-01T00:00:00.000Z",
134
+ generate: [APPROVED_ITEM],
135
+ additional: [],
136
+ demotions: [],
137
+ },
138
+ });
139
+ setActiveRunStatePath(stateFile);
140
+ const result = await guardScenarioApproved({ scenarioName: "unregistered_scenario", testType: "contract" });
141
+ expect(result).not.toBeNull();
142
+ expect(result).toContain("plan-1");
143
+ expect(result).toContain("orders_workflow");
144
+ expect(result).toContain("skyramp_register_test_plan");
145
+ });
146
+ it("falls back to the deterministic RUNNER_TEMP state path for single-repo Testbot runs (no active-run anchor set)", async () => {
147
+ const dir = await mkTmpDir();
148
+ process.env.RUNNER_TEMP = dir;
149
+ const skyrampDir = path.join(dir, "skyramp");
150
+ await fs.mkdir(skyrampDir, { recursive: true });
151
+ await fs.writeFile(path.join(skyrampDir, "analyze-changes-state.json"), JSON.stringify({
152
+ existingTests: [],
153
+ analysisScope: "current_branch_diff",
154
+ newEndpoints: [],
155
+ approvedPlan: {
156
+ planId: "ci-plan",
157
+ createdAt: "2026-01-01T00:00:00.000Z",
158
+ generate: [APPROVED_ITEM],
159
+ additional: [],
160
+ demotions: [],
161
+ },
162
+ metadata: {
163
+ sessionId: "ci-session",
164
+ stateType: "analysis",
165
+ createdAt: "2026-01-01T00:00:00.000Z",
166
+ updatedAt: "2026-01-01T00:00:00.000Z",
167
+ },
168
+ }), "utf-8");
169
+ clearActiveRunStatePath(); // simulate a single-repo run: never set
170
+ const matched = await guardScenarioApproved({ scenarioName: "orders_workflow" });
171
+ expect(matched).toBeNull();
172
+ const rejected = await guardScenarioApproved({ scenarioName: "something_else" });
173
+ expect(rejected).toContain("ci-plan");
174
+ });
175
+ });
176
+ describe("pathFromEndpointURL", () => {
177
+ it("preserves {param} placeholders that new URL() percent-encodes (seed-1 gate false-reject regression)", () => {
178
+ expect(pathFromEndpointURL("http://localhost:8092/api/circuits/providers/{id}/summary/")).toBe("/api/circuits/providers/{id}/summary/");
179
+ });
180
+ it("passes through bare paths and rejects non-URL garbage", () => {
181
+ expect(pathFromEndpointURL("/api/v1/products/{product_id}")).toBe("/api/v1/products/{product_id}");
182
+ expect(pathFromEndpointURL("not a url")).toBeUndefined();
183
+ expect(pathFromEndpointURL(undefined)).toBeUndefined();
184
+ });
185
+ });
@@ -0,0 +1,10 @@
1
+ /** File name the batch tool writes for a scenario: `scenario_<slug>.json`. */
2
+ export declare function scenarioFileNameFor(scenarioName: string): string;
3
+ /**
4
+ * Best-effort extraction of the scenarioName a scenario file belongs to, for
5
+ * plan-guard matching. Prefers the embedded `scenarioName` on the first
6
+ * entry; falls back to the filename slug after a successful parse. Returns
7
+ * undefined for unreadable/corrupt files — the guard then fails closed, so a
8
+ * malformed file can never bypass the gate.
9
+ */
10
+ export declare function readScenarioNameFromFile(scenarioFile: string | undefined): string | undefined;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * SKYR-3884 — the scenario file's identity contract, in one place.
3
+ *
4
+ * A scenario file is a bare TraceRequest[] (the Go codegen's format, so no
5
+ * file-level wrapper is possible). Its identity — the scenarioName the plan
6
+ * guard matches against the approved plan — travels two ways:
7
+ * 1. embedded as `scenarioName` on each entry (writer: ScenarioGenerationService), and
8
+ * 2. encoded in the file name `scenario_<slug>.json` (writer: the batch tool).
9
+ * The reader prefers (1) and falls back to (2) for files written before the
10
+ * embedding existed (MCP <= 0.3.1, incl. files already committed to repos).
11
+ * Keeping the two writers and the reader in one module pins the naming
12
+ * convention so they cannot drift apart silently.
13
+ */
14
+ import * as fs from "fs";
15
+ import * as path from "path";
16
+ import { logger } from "../../utils/logger.js";
17
+ /** File name the batch tool writes for a scenario: `scenario_<slug>.json`. */
18
+ export function scenarioFileNameFor(scenarioName) {
19
+ const slug = scenarioName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "scenario";
20
+ return `scenario_${slug}.json`;
21
+ }
22
+ /**
23
+ * Best-effort extraction of the scenarioName a scenario file belongs to, for
24
+ * plan-guard matching. Prefers the embedded `scenarioName` on the first
25
+ * entry; falls back to the filename slug after a successful parse. Returns
26
+ * undefined for unreadable/corrupt files — the guard then fails closed, so a
27
+ * malformed file can never bypass the gate.
28
+ */
29
+ export function readScenarioNameFromFile(scenarioFile) {
30
+ if (!scenarioFile)
31
+ return undefined;
32
+ try {
33
+ const parsed = JSON.parse(fs.readFileSync(scenarioFile, "utf-8"));
34
+ const first = Array.isArray(parsed) ? parsed[0] : undefined;
35
+ if (typeof first?.scenarioName === "string")
36
+ return first.scenarioName;
37
+ return /^scenario_(.+)\.json$/.exec(path.basename(scenarioFile))?.[1];
38
+ }
39
+ catch (error) {
40
+ logger.warning("could not read scenarioName from scenarioFile — plan guard will fail closed if a plan is active", {
41
+ scenarioFile,
42
+ error: error instanceof Error ? error.message : String(error),
43
+ });
44
+ return undefined;
45
+ }
46
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SKYR-3884 — write-side chaining lint for synthesized scenario files.
3
+ *
4
+ * The Go codegen chains FKs and path params by VALUE-matching a step's
5
+ * request against PRIOR steps' response bodies. The scenario schema therefore
6
+ * asks the agent to "plant" matching values (a create's responseBody id
7
+ * reused in later paths/bodies). When the bait is missing or mis-ordered the
8
+ * generated test silently degrades to hardcoded literals — the SKYR-3884
9
+ * failure family. This lint checks the bait at write time:
10
+ *
11
+ * - a referenced value produced ONLY by a later step → error (step order)
12
+ * - a reference produced by no prior step at all → warning (hardcode)
13
+ * - a reference produced by multiple prior steps → warning (ambiguous)
14
+ *
15
+ * Only id-like references are considered (numeric/UUID path segments; body
16
+ * fields with id-like keys, or keys resource-affine to a producing step's
17
+ * collection path) to keep noise down — mirroring the reader's own matching.
18
+ */
19
+ export interface LintStep {
20
+ method?: string;
21
+ path?: string;
22
+ requestBody?: string;
23
+ responseBody?: string;
24
+ }
25
+ export interface ScenarioLintResult {
26
+ errors: string[];
27
+ warnings: string[];
28
+ }
29
+ export declare function parseJsonObject(raw: string | undefined): unknown;
30
+ export declare function lintScenarioChaining(steps: LintStep[]): ScenarioLintResult;