@skyramp/mcp 0.2.7 → 0.2.8

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 (90) hide show
  1. package/build/index.js +5 -2
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -5
  3. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.d.ts +2 -0
  4. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +114 -0
  5. package/build/prompts/sut-setup/modes/dockerComposePrompt.d.ts +2 -0
  6. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +292 -0
  7. package/build/prompts/sut-setup/shared.d.ts +39 -0
  8. package/build/prompts/sut-setup/shared.js +132 -0
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +9 -3
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -1
  11. package/build/prompts/test-recommendation/diffExecutionPlan.js +76 -2
  12. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +91 -0
  13. package/build/prompts/testbot/testbot-prompts.d.ts +16 -1
  14. package/build/prompts/testbot/testbot-prompts.js +113 -22
  15. package/build/prompts/testbot/testbot-prompts.test.js +95 -45
  16. package/build/resources/sutSetupResource.d.ts +2 -0
  17. package/build/resources/sutSetupResource.js +45 -0
  18. package/build/resources/testbotResource.js +2 -2
  19. package/build/services/TestDiscoveryService.d.ts +2 -2
  20. package/build/services/TestDiscoveryService.js +22 -12
  21. package/build/services/TestExecutionService.d.ts +10 -1
  22. package/build/services/TestExecutionService.js +158 -26
  23. package/build/services/TestExecutionService.test.js +306 -0
  24. package/build/tools/executeSkyrampTestTool.js +26 -7
  25. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +1 -1
  26. package/build/tools/submitReportTool.d.ts +10 -0
  27. package/build/tools/submitReportTool.js +106 -39
  28. package/build/tools/submitReportTool.test.js +296 -50
  29. package/build/tools/test-management/actionsTool.js +21 -7
  30. package/build/tools/test-management/analyzeChangesTool.d.ts +1 -1
  31. package/build/tools/test-management/analyzeChangesTool.js +106 -41
  32. package/build/tools/test-management/analyzeChangesTool.test.js +21 -41
  33. package/build/tools/test-management/analyzeTestHealthTool.js +16 -9
  34. package/build/tools/workspace/initializeWorkspaceTool.js +39 -22
  35. package/build/tools/workspace/serviceUpsert.d.ts +12 -0
  36. package/build/tools/workspace/serviceUpsert.js +23 -0
  37. package/build/tools/workspace/serviceUpsert.test.d.ts +1 -0
  38. package/build/tools/workspace/serviceUpsert.test.js +50 -0
  39. package/build/types/TestAnalysis.d.ts +2 -1
  40. package/build/types/TestExecution.d.ts +12 -2
  41. package/build/types/TestExecution.js +11 -1
  42. package/build/types/TestbotReport.d.ts +68 -0
  43. package/build/types/TestbotReport.js +1 -0
  44. package/build/types/index.d.ts +4 -0
  45. package/build/types/index.js +3 -0
  46. package/build/utils/AnalysisStateManager.d.ts +61 -1
  47. package/build/utils/AnalysisStateManager.js +144 -3
  48. package/build/utils/AnalysisStateManager.test.js +98 -0
  49. package/build/utils/docker.test.js +1 -1
  50. package/build/utils/routeParsers.d.ts +0 -10
  51. package/build/utils/routeParsers.js +31 -38
  52. package/build/utils/scenarioDrafting.d.ts +1 -1
  53. package/build/utils/scenarioDrafting.js +23 -1
  54. package/build/utils/utils.d.ts +7 -1
  55. package/build/utils/utils.js +10 -0
  56. package/build/utils/versions.d.ts +3 -3
  57. package/build/utils/versions.js +1 -1
  58. package/build/workspace/workspace.d.ts +10 -10
  59. package/build/workspace/workspace.js +7 -7
  60. package/build/workspace/workspace.test.js +6 -6
  61. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +31 -13
  62. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +88 -15
  63. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -3
  64. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  65. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  66. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderApp.js +79 -19
  67. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/aliasRemapper.js +84 -0
  68. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/skyramp/replayEngine.js +15 -4
  69. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-CPZKmjFR.js → codeMirrorModule-D0BjbCb7.js} +1 -1
  70. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-lvTRGFx-.js +193 -0
  71. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  72. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-B0JOjboO.js → codeMirrorModule-Cqp3cwEJ.js} +1 -1
  73. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-1anWeyDf.js → defaultSettingsView-CYf9adZh.js} +79 -79
  74. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.Cc5029a3.js → index.B_7ywgmr.js} +1 -1
  75. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  76. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  77. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.Wo5yvvVh.js → uiMode.u4_8VnCV.js} +1 -1
  78. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  79. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  80. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderApp.ts +129 -43
  81. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/aliasRemapper.ts +119 -0
  82. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/skyramp/replayEngine.ts +33 -10
  83. package/node_modules/playwright/package.json +1 -1
  84. package/package.json +10 -3
  85. package/node_modules/playwright/node_modules/playwright-core/bundles/mcp/node_modules/.bin/node-which +0 -52
  86. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/is-docker +0 -5
  87. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/mime +0 -46
  88. package/node_modules/playwright/node_modules/playwright-core/bundles/utils/node_modules/.bin/yaml +0 -11
  89. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-CsgPR2wy.js +0 -193
  90. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.CP3Y8Zsb.js +0 -2
@@ -0,0 +1,68 @@
1
+ import type { DriftAction } from "./TestAnalysis.js";
2
+ import type { TestExecutionStatus } from "./TestExecution.js";
3
+ import type { HttpMethod, TestType } from "./TestTypes.js";
4
+ /**
5
+ * Shape of the JSON report written by skyramp_submit_report and read by testbot
6
+ * for rendering as Markdown. All fields mirror the corresponding Zod schemas in
7
+ * submitReportTool.ts — keep the two in sync.
8
+ */
9
+ export interface TestbotReport {
10
+ businessCaseAnalysis: string;
11
+ newTestsCreated: {
12
+ testId: string;
13
+ testType: TestType;
14
+ endpoint: string;
15
+ fileName: string;
16
+ reasoning: string;
17
+ description?: string;
18
+ scenarioFile?: string;
19
+ traceFile?: string;
20
+ frontendTrace?: string;
21
+ }[];
22
+ /** Omitted when no existing tests were found (agent omits the field). */
23
+ testMaintenance?: {
24
+ testType: TestType;
25
+ endpoint: string;
26
+ fileName: string;
27
+ description: string;
28
+ action: DriftAction;
29
+ beforeStatus: TestExecutionStatus;
30
+ beforeDetails: string;
31
+ afterStatus: TestExecutionStatus;
32
+ afterDetails: string;
33
+ }[];
34
+ testResults: {
35
+ testType: TestType;
36
+ endpoint: string;
37
+ status: "Pass" | "Fail" | "Skipped";
38
+ details: string;
39
+ videoPath?: string;
40
+ }[];
41
+ additionalRecommendations?: {
42
+ testId: string;
43
+ testType: TestType;
44
+ category?: string;
45
+ primaryEndpoint?: string;
46
+ scenarioName?: string;
47
+ steps: {
48
+ method?: HttpMethod;
49
+ path?: string;
50
+ description: string;
51
+ expectedStatusCode?: number;
52
+ requestBody?: Record<string, unknown>;
53
+ responseBody?: Record<string, unknown>;
54
+ }[];
55
+ description: string;
56
+ priority: "high" | "medium" | "low";
57
+ reasoning: string;
58
+ openApiSpec?: string;
59
+ backendTrace?: string;
60
+ frontendTrace?: string;
61
+ }[];
62
+ issuesFound: {
63
+ description: string;
64
+ severity?: "critical" | "high" | "medium" | "low";
65
+ }[];
66
+ nextSteps: string[];
67
+ commitMessage: string;
68
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export { TestExecutionStatus } from "./TestExecution.js";
2
+ export { DriftAction } from "./TestAnalysis.js";
3
+ export { TestType, HttpMethod } from "./TestTypes.js";
4
+ export type { TestbotReport } from "./TestbotReport.js";
@@ -0,0 +1,3 @@
1
+ export { TestExecutionStatus } from "./TestExecution.js";
2
+ export { DriftAction } from "./TestAnalysis.js";
3
+ export { TestType, HttpMethod } from "./TestTypes.js";
@@ -6,6 +6,10 @@ import type { CandidateUiPage } from "./uiPageEnumerator.js";
6
6
  export type { CandidateUiPage } from "./uiPageEnumerator.js";
7
7
  export declare function setTestsRepoDir(dir: string | undefined): void;
8
8
  export declare function getTestsRepoDir(): string | undefined;
9
+ export declare function setActiveRunStatePath(stateFilePath: string): void;
10
+ export declare function getActiveRunStatePath(): string | undefined;
11
+ /** Reset the run anchor — for tests, and as a safety hook between runs. */
12
+ export declare function clearActiveRunStatePath(): void;
9
13
  export declare function registerSession(sessionId: string, stateFilePath: string): void;
10
14
  export declare function getSessionFilePath(sessionId: string): string | undefined;
11
15
  export declare function getRegisteredSessions(): ReadonlyMap<string, string>;
@@ -96,17 +100,39 @@ interface StateMetadata {
96
100
  sessionId: string;
97
101
  stateType: StateType;
98
102
  repositoryPath?: string;
103
+ /** owner/repo of the PRIMARY repo, recorded on the root so related-repo writes
104
+ * can be distinguished from the primary. Absent only for legacy single-repo
105
+ * state files written before explicit tagging. */
106
+ repository?: string;
99
107
  createdAt: string;
100
108
  updatedAt: string;
101
109
  step?: string;
102
110
  }
103
111
  /**
104
- * State file format - data fields at root level with metadata
112
+ * One related repo's analysis section in a run-scoped (multi-repo) state file.
113
+ * The primary repo's analysis stays at the root for backward compatibility; each
114
+ * related repo's analysis is stored here keyed by its `owner/repo`.
115
+ */
116
+ interface RepoStateSection<T> {
117
+ /** Analysis payload — same shape as the root (e.g. UnifiedAnalysisState). */
118
+ data: T;
119
+ /** Absolute checkout path of this repo (used to re-absolutize test paths). */
120
+ repositoryPath?: string;
121
+ }
122
+ /**
123
+ * State file format - data fields at root level with metadata.
105
124
  * Example: { tests: [...], metadata: {...} }
106
125
  * Example: { repositoryPath: "...", analysis: {...}, mapping: {...}, metadata: {...} }
126
+ *
127
+ * Multi-repo (run-scoped) files additionally carry `relatedRepos`: a map of
128
+ * `owner/repo` → that repo's analysis section. State is per-RUN, not per-repo —
129
+ * one file holds the primary (at root) plus every related repo's section, so
130
+ * there is exactly one stateFile per Testbot run. A file with no `relatedRepos`
131
+ * key is a single-repo file and behaves exactly as before.
107
132
  */
108
133
  type StateFile<T> = T & {
109
134
  metadata: StateMetadata;
135
+ relatedRepos?: Record<string, RepoStateSection<T>>;
110
136
  };
111
137
  /**
112
138
  * Generic State Manager for persisting workflow data
@@ -150,7 +176,41 @@ export declare class StateManager<T = any> {
150
176
  writeData(data: T, options?: {
151
177
  repositoryPath?: string;
152
178
  step?: string;
179
+ /** owner/repo recorded on the root metadata — identifies the PRIMARY repo
180
+ * so later section routing (isPrimaryRepo) can compare against it.
181
+ * Preserved when omitted. */
182
+ repository?: string;
183
+ }): Promise<void>;
184
+ /** Is `repo` the primary section? Primary when `repo` is falsy (single-repo /
185
+ * legacy callers), when no state file exists yet (the FIRST write of a run is
186
+ * the primary — the testbot prompt mandates the primary is analyzed first), or
187
+ * when `repo` matches the `repo` recorded on the root metadata. A non-empty
188
+ * `repo` that doesn't match an already-recorded root is a RELATED repo. */
189
+ private isPrimaryRepo;
190
+ /**
191
+ * Write one repo's analysis into the run-scoped state file. The primary repo
192
+ * (repo absent, or matching the root's recorded repo, or the first write of the
193
+ * run) is written to the root via writeData(); a related repo is upserted into
194
+ * `relatedRepos[repo]`, preserving the primary + other sections. Used by
195
+ * skyramp_analyze_changes — every repo in the run writes the SAME stateFile path.
196
+ */
197
+ writeRepoData(data: T, options: {
198
+ repo?: string;
199
+ repositoryPath?: string;
200
+ step?: string;
153
201
  }): Promise<void>;
202
+ /**
203
+ * Read one repo's analysis payload from the run-scoped state file. `repo`
204
+ * undefined → the primary (root) section (identical to readData()).
205
+ */
206
+ readRepoData(repo?: string): Promise<T | null>;
207
+ /**
208
+ * Resolve the checkout path for a repo's section — used to re-absolutize test
209
+ * paths. `repo` undefined → the primary's metadata.repositoryPath.
210
+ */
211
+ getRepoRepositoryPath(repo?: string): Promise<string | undefined>;
212
+ /** List the related repo keys present in the run-scoped file (empty if single-repo). */
213
+ listRelatedRepos(): Promise<string[]>;
154
214
  getStatePath(): string;
155
215
  getSessionId(): string;
156
216
  getStateType(): StateType;
@@ -22,6 +22,29 @@ export function setTestsRepoDir(dir) {
22
22
  export function getTestsRepoDir() {
23
23
  return _testsRepoDir;
24
24
  }
25
+ /**
26
+ * Multi-repo run anchor: the path of the run-scoped analysis state file for the
27
+ * current Testbot run. One Testbot run = one MCP server process, so a single
28
+ * module-level value is naturally run-scoped (same pattern as _testsRepoDir and
29
+ * processSessionRegistry above).
30
+ *
31
+ * skyramp_analyze_changes sets this on the FIRST call of a multi-repo run (the one
32
+ * carrying a `repository`) and REUSES it on subsequent related-repo calls, so every
33
+ * repo's analysis lands in ONE state file with per-repo sections — without the agent
34
+ * ever passing a file path (which previously caused filename hallucinations).
35
+ * Single-repo / standalone calls (no `repository`) never set or read this.
36
+ */
37
+ let _activeRunStatePath;
38
+ export function setActiveRunStatePath(stateFilePath) {
39
+ _activeRunStatePath = stateFilePath;
40
+ }
41
+ export function getActiveRunStatePath() {
42
+ return _activeRunStatePath;
43
+ }
44
+ /** Reset the run anchor — for tests, and as a safety hook between runs. */
45
+ export function clearActiveRunStatePath() {
46
+ _activeRunStatePath = undefined;
47
+ }
25
48
  /**
26
49
  * In-memory session store: sessionId → { data, storedAt }.
27
50
  * Eliminates the need for the LLM to read/write state files on disk.
@@ -119,6 +142,15 @@ export class StateManager {
119
142
  if (stateFilePath) {
120
143
  this.stateFile = stateFilePath;
121
144
  }
145
+ else if (stateType === "analysis" && process.env.RUNNER_TEMP) {
146
+ // In CI (testbot action), RUNNER_TEMP is a job-isolated dir that both the
147
+ // action and this tool can independently derive — no LLM input needed.
148
+ // TODO: interim step — remove this branch when stateFile and summaryOutputFile are merged
149
+ // (see getTestbotPrompt TODO). RUNNER_TEMP is GitHub Actions-specific; other CI systems
150
+ // (Jenkins, GitLab, Buildkite) must set it explicitly. Once merged, path flows through
151
+ // summaryOutputFile which is already testbot-controlled and CI-agnostic.
152
+ this.stateFile = path.join(process.env.RUNNER_TEMP, "skyramp", "analyze-changes-state.json");
153
+ }
122
154
  else {
123
155
  const baseDir = stateDir || os.tmpdir();
124
156
  const prefix = STATE_FILE_PREFIXES[stateType];
@@ -161,7 +193,11 @@ export class StateManager {
161
193
  }
162
194
  try {
163
195
  const content = await fs.promises.readFile(this.stateFile, "utf-8");
164
- const { metadata, ...data } = JSON.parse(content);
196
+ // Strip both metadata and the multi-repo relatedRepos section so callers
197
+ // get only the PRIMARY repo's analysis payload (unchanged single-repo shape).
198
+ const { metadata, relatedRepos, ...data } = JSON.parse(content);
199
+ void metadata;
200
+ void relatedRepos;
165
201
  logger.debug(`Read data from state file: ${this.stateFile}`);
166
202
  return data;
167
203
  }
@@ -192,11 +228,14 @@ export class StateManager {
192
228
  */
193
229
  async writeData(data, options) {
194
230
  try {
195
- // Read existing metadata if file exists
231
+ // Read existing metadata + any related-repo sections if file exists, so
232
+ // rewriting the primary (root) payload preserves the multi-repo sections.
196
233
  let existingMetadata;
234
+ let existingRelated;
197
235
  if (this.exists()) {
198
236
  const existing = await this.readFullState();
199
237
  existingMetadata = existing?.metadata;
238
+ existingRelated = existing?.relatedRepos;
200
239
  }
201
240
  const state = {
202
241
  ...data,
@@ -204,10 +243,12 @@ export class StateManager {
204
243
  sessionId: this.sessionId,
205
244
  stateType: this.stateType,
206
245
  repositoryPath: options?.repositoryPath || existingMetadata?.repositoryPath,
246
+ repository: options?.repository ?? existingMetadata?.repository,
207
247
  createdAt: existingMetadata?.createdAt || new Date().toISOString(),
208
248
  updatedAt: new Date().toISOString(),
209
249
  step: options?.step,
210
250
  },
251
+ ...(existingRelated ? { relatedRepos: existingRelated } : {}),
211
252
  };
212
253
  await fs.promises.mkdir(path.dirname(this.stateFile), { recursive: true });
213
254
  await fs.promises.writeFile(this.stateFile, JSON.stringify(state, null, 2), "utf-8");
@@ -218,6 +259,106 @@ export class StateManager {
218
259
  throw new Error(`Failed to write state file: ${error.message}`);
219
260
  }
220
261
  }
262
+ // -----------------------------------------------------------------------
263
+ // Multi-repo (run-scoped) section access
264
+ //
265
+ // State is per-RUN: one file holds the primary repo at the root plus each
266
+ // related repo under `relatedRepos[<owner/repo>]`. `repo` undefined (or
267
+ // matching the root's metadata.repo) targets the PRIMARY section, so all
268
+ // single-repo behavior is unchanged.
269
+ // -----------------------------------------------------------------------
270
+ /** Is `repo` the primary section? Primary when `repo` is falsy (single-repo /
271
+ * legacy callers), when no state file exists yet (the FIRST write of a run is
272
+ * the primary — the testbot prompt mandates the primary is analyzed first), or
273
+ * when `repo` matches the `repo` recorded on the root metadata. A non-empty
274
+ * `repo` that doesn't match an already-recorded root is a RELATED repo. */
275
+ async isPrimaryRepo(repo) {
276
+ if (!repo)
277
+ return true;
278
+ // First write of the run is the primary (the prompt mandates the primary is
279
+ // analyzed first), even when it carries an explicit owner/repo.
280
+ if (!this.exists())
281
+ return true;
282
+ const full = await this.readFullState();
283
+ // File already exists: primary only if `repo` matches the repo recorded on
284
+ // the root. An untagged root means the primary was written without a repo
285
+ // (legacy/single-repo), so any non-empty `repo` here is a RELATED repo.
286
+ return full?.metadata.repository === repo;
287
+ }
288
+ /**
289
+ * Write one repo's analysis into the run-scoped state file. The primary repo
290
+ * (repo absent, or matching the root's recorded repo, or the first write of the
291
+ * run) is written to the root via writeData(); a related repo is upserted into
292
+ * `relatedRepos[repo]`, preserving the primary + other sections. Used by
293
+ * skyramp_analyze_changes — every repo in the run writes the SAME stateFile path.
294
+ */
295
+ async writeRepoData(data, options) {
296
+ if (await this.isPrimaryRepo(options.repo)) {
297
+ await this.writeData(data, {
298
+ repositoryPath: options.repositoryPath,
299
+ step: options.step,
300
+ // Record the primary's owner/repo on the root so later related-repo
301
+ // writes can distinguish it from a section by matching this value.
302
+ repository: options.repo,
303
+ });
304
+ return;
305
+ }
306
+ // Related repo: read the current file (must already exist — primary is
307
+ // analyzed first), upsert this repo's section, write back.
308
+ const existing = (await this.readFullState());
309
+ const base = existing ??
310
+ {
311
+ metadata: {
312
+ sessionId: this.sessionId,
313
+ stateType: this.stateType,
314
+ createdAt: new Date().toISOString(),
315
+ updatedAt: new Date().toISOString(),
316
+ },
317
+ };
318
+ const relatedRepos = { ...(base.relatedRepos ?? {}) };
319
+ // Preserve a previously-stored repositoryPath when this write omits it.
320
+ // Callers that update a section without re-supplying the checkout path (e.g.
321
+ // skyramp_execute_test writing execution results) must not clobber the path
322
+ // that skyramp_analyze_changes recorded — getRepoRepositoryPath() relies on
323
+ // it to re-absolutize that repo's test paths.
324
+ const priorRepositoryPath = relatedRepos[options.repo]?.repositoryPath;
325
+ relatedRepos[options.repo] = {
326
+ data,
327
+ repositoryPath: options.repositoryPath ?? priorRepositoryPath,
328
+ };
329
+ base.relatedRepos = relatedRepos;
330
+ base.metadata = { ...base.metadata, updatedAt: new Date().toISOString() };
331
+ await fs.promises.mkdir(path.dirname(this.stateFile), { recursive: true });
332
+ await fs.promises.writeFile(this.stateFile, JSON.stringify(base, null, 2), "utf-8");
333
+ logger.debug(`Wrote ${options.repo} section to state file: ${this.stateFile}`);
334
+ }
335
+ /**
336
+ * Read one repo's analysis payload from the run-scoped state file. `repo`
337
+ * undefined → the primary (root) section (identical to readData()).
338
+ */
339
+ async readRepoData(repo) {
340
+ if (await this.isPrimaryRepo(repo)) {
341
+ return this.readData();
342
+ }
343
+ const full = await this.readFullState();
344
+ return full?.relatedRepos?.[repo]?.data ?? null;
345
+ }
346
+ /**
347
+ * Resolve the checkout path for a repo's section — used to re-absolutize test
348
+ * paths. `repo` undefined → the primary's metadata.repositoryPath.
349
+ */
350
+ async getRepoRepositoryPath(repo) {
351
+ const full = await this.readFullState();
352
+ if (await this.isPrimaryRepo(repo)) {
353
+ return full?.metadata.repositoryPath;
354
+ }
355
+ return full?.relatedRepos?.[repo]?.repositoryPath;
356
+ }
357
+ /** List the related repo keys present in the run-scoped file (empty if single-repo). */
358
+ async listRelatedRepos() {
359
+ const full = await this.readFullState();
360
+ return Object.keys(full?.relatedRepos ?? {});
361
+ }
221
362
  getStatePath() {
222
363
  return this.stateFile;
223
364
  }
@@ -260,7 +401,7 @@ export class StateManager {
260
401
  * @returns Number of files deleted
261
402
  */
262
403
  static async cleanupOldFiles(maxAgeHours = 24, stateDir, stateTypes) {
263
- const baseDir = stateDir || os.tmpdir();
404
+ const baseDir = stateDir || (process.env.RUNNER_TEMP ? path.join(process.env.RUNNER_TEMP, "skyramp") : os.tmpdir());
264
405
  const files = await fs.promises.readdir(baseDir).catch(() => []);
265
406
  const statePrefixes = stateTypes
266
407
  ? stateTypes.map((t) => STATE_FILE_PREFIXES[t])
@@ -34,3 +34,101 @@ describe("StateManager.writeData", () => {
34
34
  await fs.promises.rm(nestedDir, { recursive: true, force: true });
35
35
  });
36
36
  });
37
+ describe("StateManager — run-scoped per-repo sections (SKYR-3796)", () => {
38
+ let stateFile;
39
+ const mkState = (tag) => ({
40
+ existingTests: [{ testFile: `${tag}.spec.ts` }],
41
+ analysisScope: AnalysisScope.CurrentBranchDiff,
42
+ newEndpoints: [],
43
+ });
44
+ beforeEach(() => {
45
+ stateFile = path.join(os.tmpdir(), `skyramp-analysis-sections-${Date.now()}.json`);
46
+ });
47
+ afterEach(async () => {
48
+ await fs.promises.rm(stateFile, { force: true });
49
+ });
50
+ it("keeps the primary at root and additional repos in relatedRepos, all in ONE file", async () => {
51
+ const mgr = StateManager.fromStatePath(stateFile);
52
+ // Primary (repo omitted) → root; additional repo → section.
53
+ await mgr.writeRepoData(mkState("primary"), { repositoryPath: "/work/primary" });
54
+ await mgr.writeRepoData(mkState("api"), {
55
+ repo: "letsramp/api-insight",
56
+ repositoryPath: "/work/api-insight",
57
+ });
58
+ // Exactly one file on disk.
59
+ expect(fs.existsSync(stateFile)).toBe(true);
60
+ expect(fs.existsSync(`${stateFile}.1.json`)).toBe(false);
61
+ const raw = JSON.parse(await fs.promises.readFile(stateFile, "utf-8"));
62
+ // Primary analysis lives at root (backward-compatible single-repo shape).
63
+ expect(raw.existingTests[0].testFile).toBe("primary.spec.ts");
64
+ expect(raw.metadata.repositoryPath).toBe("/work/primary");
65
+ // Additional repo under relatedRepos, keyed by owner/repo.
66
+ expect(raw.relatedRepos["letsramp/api-insight"].data.existingTests[0].testFile).toBe("api.spec.ts");
67
+ expect(raw.relatedRepos["letsramp/api-insight"].repositoryPath).toBe("/work/api-insight");
68
+ });
69
+ it("reads back the correct section by repo (primary when omitted)", async () => {
70
+ const mgr = StateManager.fromStatePath(stateFile);
71
+ await mgr.writeRepoData(mkState("primary"), { repositoryPath: "/work/primary" });
72
+ await mgr.writeRepoData(mkState("api"), { repo: "letsramp/api-insight", repositoryPath: "/work/api-insight" });
73
+ expect((await mgr.readRepoData()).existingTests[0].testFile).toBe("primary.spec.ts");
74
+ expect((await mgr.readRepoData("letsramp/api-insight")).existingTests[0].testFile).toBe("api.spec.ts");
75
+ expect(await mgr.getRepoRepositoryPath()).toBe("/work/primary");
76
+ expect(await mgr.getRepoRepositoryPath("letsramp/api-insight")).toBe("/work/api-insight");
77
+ expect(await mgr.listRelatedRepos()).toEqual(["letsramp/api-insight"]);
78
+ });
79
+ it("preserves additional sections when the primary root is rewritten (writeData)", async () => {
80
+ const mgr = StateManager.fromStatePath(stateFile);
81
+ await mgr.writeRepoData(mkState("primary"), { repositoryPath: "/work/primary" });
82
+ await mgr.writeRepoData(mkState("api"), { repo: "letsramp/api-insight", repositoryPath: "/work/api-insight" });
83
+ // execute_test-style root rewrite must not drop the additional section.
84
+ const primary = (await mgr.readRepoData());
85
+ primary.existingTests[0].execution = { marker: "ran" };
86
+ await mgr.writeData(primary);
87
+ expect((await mgr.readRepoData("letsramp/api-insight")).existingTests[0].testFile).toBe("api.spec.ts");
88
+ expect((await mgr.readRepoData()).existingTests[0].execution.marker).toBe("ran");
89
+ });
90
+ it("preserves an additional repo's repositoryPath when a later write omits it", async () => {
91
+ const mgr = StateManager.fromStatePath(stateFile);
92
+ await mgr.writeRepoData(mkState("primary"), { repositoryPath: "/work/primary" });
93
+ // analyze_changes records the section WITH its checkout path.
94
+ await mgr.writeRepoData(mkState("api"), {
95
+ repo: "letsramp/api-insight",
96
+ repositoryPath: "/work/api-insight",
97
+ });
98
+ // A later write to the same section (e.g. execute_test) omits repositoryPath.
99
+ await mgr.writeRepoData(mkState("api-ran"), { repo: "letsramp/api-insight" });
100
+ // The path must survive so getRepoRepositoryPath can still re-absolutize.
101
+ expect(await mgr.getRepoRepositoryPath("letsramp/api-insight")).toBe("/work/api-insight");
102
+ expect((await mgr.readRepoData("letsramp/api-insight")).existingTests[0].testFile).toBe("api-ran.spec.ts");
103
+ });
104
+ it("routes the primary to root and a related repo to a section when BOTH pass an explicit repo", async () => {
105
+ const mgr = StateManager.fromStatePath(stateFile);
106
+ // The agent now always passes an explicit owner/repo, including for the
107
+ // primary. The FIRST write is the primary → root (tagged on metadata).
108
+ await mgr.writeRepoData(mkState("primary"), {
109
+ repo: "letsramp/primary-app",
110
+ repositoryPath: "/work/primary",
111
+ });
112
+ await mgr.writeRepoData(mkState("api"), {
113
+ repo: "letsramp/api-insight",
114
+ repositoryPath: "/work/api-insight",
115
+ });
116
+ const raw = JSON.parse(await fs.promises.readFile(stateFile, "utf-8"));
117
+ expect(raw.metadata.repository).toBe("letsramp/primary-app");
118
+ expect(raw.existingTests[0].testFile).toBe("primary.spec.ts");
119
+ expect(raw.relatedRepos["letsramp/api-insight"].data.existingTests[0].testFile).toBe("api.spec.ts");
120
+ // The primary is reachable BOTH by its explicit repo and by omitting it.
121
+ expect((await mgr.readRepoData("letsramp/primary-app")).existingTests[0].testFile).toBe("primary.spec.ts");
122
+ expect((await mgr.readRepoData()).existingTests[0].testFile).toBe("primary.spec.ts");
123
+ expect(await mgr.getRepoRepositoryPath("letsramp/primary-app")).toBe("/work/primary");
124
+ expect(await mgr.listRelatedRepos()).toEqual(["letsramp/api-insight"]);
125
+ });
126
+ it("reads a legacy single-section file (no relatedRepos) as the primary", async () => {
127
+ const mgr = StateManager.fromStatePath(stateFile);
128
+ // Legacy write path — no repo, no sections.
129
+ await mgr.writeData(mkState("legacy"), { repositoryPath: "/work/legacy" });
130
+ expect((await mgr.readRepoData()).existingTests[0].testFile).toBe("legacy.spec.ts");
131
+ expect(await mgr.getRepoRepositoryPath()).toBe("/work/legacy");
132
+ expect(await mgr.listRelatedRepos()).toEqual([]);
133
+ });
134
+ });
@@ -55,7 +55,7 @@ describe("dockerImageExistsLocally", () => {
55
55
  });
56
56
  });
57
57
  describe("pullDockerImage", () => {
58
- const IMAGE = "skyramp/executor:v1.3.28";
58
+ const IMAGE = "skyramp/executor:v1.3.29";
59
59
  beforeEach(() => jest.clearAllMocks());
60
60
  describe("on amd64 host", () => {
61
61
  const originalArch = process.arch;
@@ -50,14 +50,4 @@ export interface ClassifiedEndpoints {
50
50
  baseBranch: string;
51
51
  affectedServices: string[];
52
52
  }
53
- /**
54
- * Classify endpoints by cross-referencing `changedFiles` against `scannedEndpoints[].sourceFile`.
55
- *
56
- * This replaces `parseEndpointsFromDiff` — instead of regex-parsing diff hunks for route
57
- * annotations (fragile for 15+ frameworks), it uses the already-scanned endpoint catalog
58
- * which has full, resolved paths and concrete HTTP methods.
59
- *
60
- * For deleted files, the caller must supply endpoints recovered from the base branch
61
- * (via `parseFileEndpoints` on `git show base:<file>`) in `deletedFileEndpoints`.
62
- */
63
53
  export declare function classifyEndpointsByChangedFiles(diffData: BranchDiffData, scannedEndpoints: ScannedEndpoint[], deletedFileEndpoints?: ScannedEndpoint[]): ClassifiedEndpoints;
@@ -406,21 +406,36 @@ const GENERIC_ROUTE_BASENAMES = new Set(["api", "app", "handler", "handlers", "i
406
406
  function normalizeSourcePath(filePath) {
407
407
  return filePath.replace(/\\/g, "/");
408
408
  }
409
- /**
410
- * Classify endpoints by cross-referencing `changedFiles` against `scannedEndpoints[].sourceFile`.
411
- *
412
- * This replaces `parseEndpointsFromDiff` — instead of regex-parsing diff hunks for route
413
- * annotations (fragile for 15+ frameworks), it uses the already-scanned endpoint catalog
414
- * which has full, resolved paths and concrete HTTP methods.
415
- *
416
- * For deleted files, the caller must supply endpoints recovered from the base branch
417
- * (via `parseFileEndpoints` on `git show base:<file>`) in `deletedFileEndpoints`.
418
- */
409
+ function staticPathSegments(urlPath) {
410
+ return urlPath.split("/").filter(s => s && !/^[:{[]/.test(s)).map(s => s.toLowerCase());
411
+ }
412
+ function endpointSourceResource(ep) {
413
+ const sourceSegments = ep.sourceFile.split(/[\\/]/).filter(Boolean);
414
+ const filename = sourceSegments.at(-1)?.replace(/\.[^.]+$/, "") ?? "";
415
+ const normalizedFilename = filename.replace(/^(?:old|new)[-_]/i, "").toLowerCase();
416
+ const isDynamic = /^\[.*\]$/.test(normalizedFilename) || /^\{.*\}$/.test(normalizedFilename);
417
+ if (!isDynamic && !GENERIC_ROUTE_BASENAMES.has(normalizedFilename) && normalizedFilename) {
418
+ return normalizedFilename;
419
+ }
420
+ else if (GENERIC_ROUTE_BASENAMES.has(normalizedFilename)) {
421
+ // Generic filename: walk up the directory tree for a more specific ancestor name.
422
+ const parentSegments = sourceSegments.slice(0, -1).reverse();
423
+ const contextSegment = parentSegments
424
+ .map(s => s.replace(/\.[^.]+$/, "").replace(/^(?:old|new)[-_]/i, "").toLowerCase())
425
+ .find(s => s && !GENERIC_ROUTE_BASENAMES.has(s) && !/^\[.*\]$/.test(s) && !/^\{.*\}$/.test(s));
426
+ if (contextSegment)
427
+ return `${contextSegment}/${normalizedFilename}`;
428
+ }
429
+ // Fall back to the last static segment of the endpoint's URL path.
430
+ const segments = staticPathSegments(ep.path);
431
+ return segments.at(-1) ?? normalizedFilename;
432
+ }
433
+ function endpointMovedKey(path, resource, method) {
434
+ return method ? `${path}::${resource}::${method}` : `${path}::${resource}`;
435
+ }
419
436
  export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints) {
420
437
  const newFileSet = new Set(diffData.newFiles.map(normalizeSourcePath));
421
438
  const deletedFileSet = new Set(diffData.deletedFiles.map(normalizeSourcePath));
422
- // Build sourceFile -> ScannedEndpoint[] map from the post-change catalog.
423
- // Git diffs use POSIX separators; scanner output can reflect OS-native paths.
424
439
  const bySourceFile = new Map();
425
440
  for (const ep of scannedEndpoints) {
426
441
  const sourceKey = normalizeSourcePath(ep.sourceFile);
@@ -435,7 +450,6 @@ export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, dele
435
450
  const unmatchedFiles = [];
436
451
  for (const file of diffData.changedFiles) {
437
452
  const fileKey = normalizeSourcePath(file);
438
- // Deleted files won't appear in scanned catalog — handled separately below
439
453
  if (deletedFileSet.has(fileKey))
440
454
  continue;
441
455
  const eps = bySourceFile.get(fileKey);
@@ -454,47 +468,26 @@ export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, dele
454
468
  // Filter out endpoints that still exist in the current catalog as moved/refactored.
455
469
  // Same router-relative paths can exist in sibling files, so only treat them as moved
456
470
  // when the source resource also matches.
457
- function endpointSourceResource(ep) {
458
- const sourceSegments = ep.sourceFile.split(/[\\/]/).filter(Boolean);
459
- const base = sourceSegments.at(-1)?.replace(/\.[^.]+$/, "") ?? "";
460
- const normalizedBase = base.replace(/^(?:old|new)[-_]/i, "").toLowerCase();
461
- const isDynamicBase = /^\[.*\]$/.test(normalizedBase) || /^\{.*\}$/.test(normalizedBase);
462
- if (normalizedBase && !isDynamicBase && !GENERIC_ROUTE_BASENAMES.has(normalizedBase)) {
463
- return normalizedBase;
464
- }
465
- if (normalizedBase && GENERIC_ROUTE_BASENAMES.has(normalizedBase)) {
466
- const contextSegment = [...sourceSegments]
467
- .slice(0, -1)
468
- .reverse()
469
- .map((segment) => segment.replace(/\.[^.]+$/, "").replace(/^(?:old|new)[-_]/i, "").toLowerCase())
470
- .find((segment) => segment && !GENERIC_ROUTE_BASENAMES.has(segment) && !/^\[.*\]$/.test(segment) && !/^\{.*\}$/.test(segment));
471
- if (contextSegment)
472
- return `${contextSegment}/${normalizedBase}`;
473
- }
474
- const staticSegments = ep.path.split("/").filter(Boolean).filter((segment) => !/^[:{[]/.test(segment));
475
- return staticSegments[staticSegments.length - 1]?.toLowerCase() ?? normalizedBase;
476
- }
477
471
  const currentMethodKeys = new Set();
478
472
  const currentPathResourceKeys = new Set();
479
473
  const currentMultiKeys = new Set();
480
- const movedKey = (path, resource, method) => method ? `${path}::${resource}::${method}` : `${path}::${resource}`;
481
474
  for (const current of scannedEndpoints) {
482
475
  const currentResource = endpointSourceResource(current);
483
- const pathResourceKey = movedKey(current.path, currentResource);
476
+ const pathResourceKey = endpointMovedKey(current.path, currentResource);
484
477
  currentPathResourceKeys.add(pathResourceKey);
485
478
  for (const method of current.methods) {
486
479
  if (method === "MULTI")
487
480
  currentMultiKeys.add(pathResourceKey);
488
- currentMethodKeys.add(movedKey(current.path, currentResource, method));
481
+ currentMethodKeys.add(endpointMovedKey(current.path, currentResource, method));
489
482
  }
490
483
  }
491
484
  function isMovedEndpoint(deleted, method) {
492
485
  const deletedResource = endpointSourceResource(deleted);
493
- const pathResourceKey = movedKey(deleted.path, deletedResource);
486
+ const pathResourceKey = endpointMovedKey(deleted.path, deletedResource);
494
487
  if (method === "MULTI")
495
488
  return currentPathResourceKeys.has(pathResourceKey);
496
489
  return currentMultiKeys.has(pathResourceKey) ||
497
- currentMethodKeys.has(movedKey(deleted.path, deletedResource, method));
490
+ currentMethodKeys.has(endpointMovedKey(deleted.path, deletedResource, method));
498
491
  }
499
492
  const removedEndpoints = (deletedFileEndpoints ?? [])
500
493
  .map((ep) => ({
@@ -41,7 +41,7 @@ export type ResourceRelationships = Map<string, Set<string>>;
41
41
  * the caller to fall back to heuristic pairing.
42
42
  */
43
43
  export declare function inferResourceRelationships(endpoints: Array<EndpointInput>): ResourceRelationships;
44
- export declare function draftScenariosFromEndpoints(endpoints: Array<EndpointInput>, newEndpoints?: Array<EndpointRef>, wsAuthType?: WorkspaceAuthType, options?: ScenarioDraftOptions): DraftedScenario[];
44
+ export declare function draftScenariosFromEndpoints(endpoints: Array<EndpointInput>, newEndpoints?: Array<EndpointRef>, wsAuthType?: WorkspaceAuthType, options?: ScenarioDraftOptions, removedEndpoints?: Array<EndpointRef>): DraftedScenario[];
45
45
  /**
46
46
  * Draft scenarios that directly test each new endpoint in the branch diff.
47
47
  *