@skyramp/mcp 0.3.5 → 0.3.6-rc.2.ac20

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 (164) hide show
  1. package/build/adapters/jestAdapter.js +3 -0
  2. package/build/adapters/mochaAdapter.js +2 -0
  3. package/build/adapters/playwrightAdapter.js +3 -0
  4. package/build/adapters/pytestAdapter.js +12 -0
  5. package/build/playwright/registerPlaywrightTools.js +92 -30
  6. package/build/playwright/traceRecordingPrompt.d.ts +6 -0
  7. package/build/playwright/traceRecordingPrompt.js +6 -2
  8. package/build/prompts/code-reuse.d.ts +1 -2
  9. package/build/prompts/code-reuse.js +182 -77
  10. package/build/prompts/modularization/integration-test-modularization.d.ts +2 -0
  11. package/build/prompts/modularization/integration-test-modularization.js +83 -41
  12. package/build/prompts/modularization/render.d.ts +18 -0
  13. package/build/prompts/modularization/render.js +12 -0
  14. package/build/prompts/modularization/ui-test-modularization.d.ts +3 -1
  15. package/build/prompts/modularization/ui-test-modularization.js +89 -47
  16. package/build/prompts/pom-aware-code-reuse.js +3 -1
  17. package/build/prompts/shared-helper-policy.d.ts +57 -0
  18. package/build/prompts/shared-helper-policy.js +135 -0
  19. package/build/prompts/test-recommendation/diffExecutionPlan.js +62 -56
  20. package/build/prompts/test-recommendation/fullRepoCatalog.js +19 -8
  21. package/build/prompts/test-recommendation/recommendationShared.d.ts +28 -6
  22. package/build/prompts/test-recommendation/recommendationShared.js +90 -16
  23. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +22 -0
  24. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -2
  25. package/build/prompts/test-recommendation/test-recommendation-prompt.js +3 -3
  26. package/build/prompts/testbot/testbot-prompts.js +81 -35
  27. package/build/recommendation/budgeters/shared.js +105 -27
  28. package/build/recommendation/discriminators.js +13 -2
  29. package/build/recommendation/planRanker.d.ts +6 -6
  30. package/build/recommendation/planRanker.js +6 -61
  31. package/build/services/AnalyticsService.d.ts +7 -0
  32. package/build/services/AnalyticsService.js +7 -1
  33. package/build/services/ModularizationService.js +1 -3
  34. package/build/services/TestDiscoveryService.d.ts +0 -2
  35. package/build/services/TestDiscoveryService.js +2 -37
  36. package/build/services/TestGenerationService.d.ts +16 -0
  37. package/build/services/TestGenerationService.js +86 -10
  38. package/build/services/containerEnv.js +13 -12
  39. package/build/tools/code-refactor/codeReuseTool.js +279 -93
  40. package/build/tools/code-refactor/enhance-state.d.ts +49 -0
  41. package/build/tools/code-refactor/enhance-state.js +109 -0
  42. package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -1
  43. package/build/tools/code-refactor/modularizationTool.js +9 -2
  44. package/build/tools/code-refactor/reuse-outcome.d.ts +23 -1
  45. package/build/tools/code-refactor/reuse-outcome.js +14 -4
  46. package/build/tools/code-refactor/reuse-state.d.ts +127 -5
  47. package/build/tools/code-refactor/reuse-state.js +628 -16
  48. package/build/tools/code-refactor/utils-verify-gates.d.ts +26 -0
  49. package/build/tools/code-refactor/utils-verify-gates.js +100 -0
  50. package/build/tools/code-refactor/verify-gates.d.ts +2 -1
  51. package/build/tools/code-refactor/verify-gates.js +90 -25
  52. package/build/tools/executeSkyrampTestTool.d.ts +19 -0
  53. package/build/tools/executeSkyrampTestTool.js +158 -8
  54. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -2
  55. package/build/tools/generate-tests/generateE2ERestTool.js +16 -0
  56. package/build/tools/generate-tests/generateUIRestTool.d.ts +1 -0
  57. package/build/tools/generate-tests/generateUIRestTool.js +22 -0
  58. package/build/tools/generate-tests/scenarioLint.d.ts +2 -0
  59. package/build/tools/generate-tests/scenarioLint.js +127 -19
  60. package/build/tools/generate-tests/trace-reuse-guard.d.ts +20 -0
  61. package/build/tools/generate-tests/trace-reuse-guard.js +93 -0
  62. package/build/tools/runExistingTestsTool.d.ts +34 -2
  63. package/build/tools/runExistingTestsTool.js +104 -4
  64. package/build/tools/submitReportTool.d.ts +38 -38
  65. package/build/tools/submitReportTool.js +537 -120
  66. package/build/tools/test-management/analyzeChangesTool.d.ts +24 -1
  67. package/build/tools/test-management/analyzeChangesTool.js +71 -10
  68. package/build/tools/test-management/analyzeTestHealthTool.js +7 -7
  69. package/build/tools/test-management/registerTestPlanTool.d.ts +203 -0
  70. package/build/tools/test-management/registerTestPlanTool.js +70 -12
  71. package/build/types/ExternalTestExecution.d.ts +67 -1
  72. package/build/types/Recommendation.d.ts +34 -5
  73. package/build/types/RepositoryAnalysis.d.ts +133 -114
  74. package/build/types/RepositoryAnalysis.js +1 -1
  75. package/build/types/ReuseOutcome.d.ts +102 -6
  76. package/build/types/ReuseOutcome.js +16 -2
  77. package/build/types/TestRecommendation.js +21 -3
  78. package/build/types/TestTypes.js +14 -8
  79. package/build/types/TestbotReport.d.ts +10 -1
  80. package/build/types/index.d.ts +2 -2
  81. package/build/types/index.js +1 -1
  82. package/build/utils/AnalysisStateManager.d.ts +57 -1
  83. package/build/utils/AnalysisStateManager.js +54 -5
  84. package/build/utils/branchDiff.d.ts +10 -0
  85. package/build/utils/branchDiff.js +28 -0
  86. package/build/utils/changedRoutes.d.ts +29 -0
  87. package/build/utils/changedRoutes.js +87 -0
  88. package/build/utils/featureFlags.d.ts +21 -0
  89. package/build/utils/featureFlags.js +23 -0
  90. package/build/utils/frontendIntegration.js +34 -4
  91. package/build/utils/importerHop.d.ts +2 -8
  92. package/build/utils/importerHop.js +15 -53
  93. package/build/utils/pathMatching.d.ts +38 -0
  94. package/build/utils/pathMatching.js +71 -0
  95. package/build/utils/pathSignatures.d.ts +22 -0
  96. package/build/utils/pathSignatures.js +57 -0
  97. package/build/utils/planMatchKeys.d.ts +16 -3
  98. package/build/utils/planMatchKeys.js +26 -10
  99. package/build/utils/pluralization.d.ts +10 -0
  100. package/build/utils/pluralization.js +18 -0
  101. package/build/utils/pom-catalog-parse.d.ts +52 -0
  102. package/build/utils/pom-catalog-parse.js +141 -0
  103. package/build/utils/pom-scope/selector-extractor.d.ts +12 -0
  104. package/build/utils/pom-scope/selector-extractor.js +34 -8
  105. package/build/utils/pom-verify/verify.d.ts +6 -5
  106. package/build/utils/pom-verify/verify.js +8 -6
  107. package/build/utils/reportVerification.d.ts +64 -4
  108. package/build/utils/reportVerification.js +228 -3
  109. package/build/utils/reuseRouting.d.ts +3 -0
  110. package/build/utils/reuseRouting.js +50 -0
  111. package/build/utils/routeParsers.d.ts +2 -0
  112. package/build/utils/routeParsers.js +65 -8
  113. package/build/utils/scenarioDrafting.d.ts +1 -1
  114. package/build/utils/scenarioDrafting.js +57 -45
  115. package/build/utils/subjectEndpoints.d.ts +19 -0
  116. package/build/utils/subjectEndpoints.js +98 -0
  117. package/build/utils/testFileClassification.d.ts +11 -0
  118. package/build/utils/testFileClassification.js +47 -0
  119. package/build/utils/uiPageEnumerator.d.ts +45 -19
  120. package/build/utils/uiPageEnumerator.js +95 -51
  121. package/build/utils/utils-verify/allow.d.ts +16 -0
  122. package/build/utils/utils-verify/allow.js +68 -0
  123. package/build/utils/utils-verify/call-sites.d.ts +34 -0
  124. package/build/utils/utils-verify/call-sites.js +154 -0
  125. package/build/utils/utils-verify/index.d.ts +7 -0
  126. package/build/utils/utils-verify/index.js +7 -0
  127. package/build/utils/utils-verify/language-spec.d.ts +91 -0
  128. package/build/utils/utils-verify/language-spec.js +210 -0
  129. package/build/utils/utils-verify/locate.d.ts +39 -0
  130. package/build/utils/utils-verify/locate.js +199 -0
  131. package/build/utils/utils-verify/parse.d.ts +34 -0
  132. package/build/utils/utils-verify/parse.js +177 -0
  133. package/build/utils/utils-verify/stage.d.ts +24 -0
  134. package/build/utils/utils-verify/stage.js +107 -0
  135. package/build/utils/utils-verify/verify.d.ts +63 -0
  136. package/build/utils/utils-verify/verify.js +168 -0
  137. package/build/utils/utils.d.ts +3 -1
  138. package/build/utils/utils.js +3 -1
  139. package/build/workspace/workspace.d.ts +32 -32
  140. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +9 -5
  141. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +16 -0
  142. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -0
  143. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +115 -14
  144. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +13 -1
  145. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
  146. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -0
  147. package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +27 -253
  148. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-DtudTj_v.js → codeMirrorModule-DJMC4zNo.js} +1 -1
  149. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BW82eAUI.js +196 -0
  150. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  151. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-FNMuBzX1.js → codeMirrorModule-CZfp96qZ.js} +1 -1
  152. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-gpLo02E0.js +809 -0
  153. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.Bq1r1URj.js +2 -0
  154. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  155. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.VEfqi1qN.js +5 -0
  156. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  157. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  158. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  159. package/node_modules/playwright/package.json +1 -1
  160. package/package.json +2 -2
  161. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +0 -422
  162. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +0 -1035
  163. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +0 -2
  164. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +0 -5
@@ -24,6 +24,7 @@ export declare const uiTestSchema: {
24
24
  force: z.ZodDefault<z.ZodBoolean>;
25
25
  codeReuse: z.ZodDefault<z.ZodBoolean>;
26
26
  modularizeCode: z.ZodDefault<z.ZodDefault<z.ZodBoolean>>;
27
+ allowTraceReuse: z.ZodDefault<z.ZodBoolean>;
27
28
  language: z.ZodNativeEnum<typeof import("../../types/TestTypes.js").ProgrammingLanguage>;
28
29
  framework: z.ZodString;
29
30
  };
@@ -5,6 +5,8 @@ import { TestGenerationService, } from "../../services/TestGenerationService.js"
5
5
  import { normalizeLanguageParams, resolveParamAliases, } from "../../utils/normalizeParams.js";
6
6
  import { getPersonaPrefix } from "../../prompts/personas.js";
7
7
  import { isTestbotEnabled } from "../../utils/featureFlags.js";
8
+ import { toolError } from "../../utils/utils.js";
9
+ import { checkTraceReuse, recordTraceOutput } from "./trace-reuse-guard.js";
8
10
  const TOOL_NAME = "skyramp_ui_test_generation";
9
11
  export class UITestService extends TestGenerationService {
10
12
  getTestType() {
@@ -32,6 +34,11 @@ export class UITestService extends TestGenerationService {
32
34
  });
33
35
  if (result.isError)
34
36
  return result;
37
+ // On modularize-first flows the base hand-off sequences the whole post-gen
38
+ // chain (enhance → modularize → reuse); a second block here would instruct
39
+ // enhance twice and re-extract the consolidated helpers.
40
+ if (this.emitsModularizeFirstHandOff(params))
41
+ return result;
35
42
  if (!params.enhanceAssertions && !params.modularizeCode)
36
43
  return result;
37
44
  const content = [...result.content];
@@ -83,6 +90,13 @@ export const uiTestSchema = {
83
90
  force: baseSchema.shape.force,
84
91
  codeReuse: codeRefactoringSchema.shape.codeReuse,
85
92
  modularizeCode: codeRefactoringSchema.shape.modularizeCode.default(true),
93
+ allowTraceReuse: z
94
+ .boolean()
95
+ .default(false)
96
+ .describe("Explicit override for the trace-reuse guard. By default, generating a second differently-named spec " +
97
+ "from a trace zip that already produced one is rejected — one recording covers one scenario, and reusing " +
98
+ "it manufactures identical tests under names the recording never covered. Set true only when the same " +
99
+ "recording genuinely covers the new scenario end-to-end."),
86
100
  };
87
101
  export function registerUITestTool(server) {
88
102
  server.registerTool(TOOL_NAME, {
@@ -123,8 +137,16 @@ This tells you exactly which frontend files changed so you record traces for the
123
137
  zip: "playwrightInput",
124
138
  });
125
139
  normalizeLanguageParams(params);
140
+ const traceReuse = await checkTraceReuse(params.playwrightInput, params.output, params.outputDir, !!params.allowTraceReuse, "ui");
141
+ if (traceReuse.rejection)
142
+ return toolError(traceReuse.rejection);
126
143
  const service = new UITestService();
127
144
  const result = await service.generateTest(params);
145
+ // Register only what generation actually produced: recording at check
146
+ // time poisoned the registry on failures (name-validation rejections,
147
+ // codegen errors), refusing the corrected retry for reusing its own trace.
148
+ if (!result.isError && traceReuse.key && traceReuse.identity)
149
+ recordTraceOutput(traceReuse.key, traceReuse.identity);
128
150
  AnalyticsService.pushTestGenerationToolEvent(TOOL_NAME, result, params).catch(() => {
129
151
  // Silently ignore analytics errors
130
152
  });
@@ -9,6 +9,8 @@
9
9
  * failure family. This lint checks the bait at write time:
10
10
  *
11
11
  * - a referenced value produced ONLY by a later step → error (step order)
12
+ * (with ONE remedy: reorder when that later step creates the referenced
13
+ * resource, else raise the entropy of its fabricated id — see below)
12
14
  * - a reference produced by no prior step at all → warning (hardcode)
13
15
  * - a reference produced by multiple prior steps → warning (ambiguous)
14
16
  *
@@ -9,6 +9,8 @@
9
9
  * failure family. This lint checks the bait at write time:
10
10
  *
11
11
  * - a referenced value produced ONLY by a later step → error (step order)
12
+ * (with ONE remedy: reorder when that later step creates the referenced
13
+ * resource, else raise the entropy of its fabricated id — see below)
12
14
  * - a reference produced by no prior step at all → warning (hardcode)
13
15
  * - a reference produced by multiple prior steps → warning (ambiguous)
14
16
  *
@@ -16,6 +18,7 @@
16
18
  * fields with id-like keys, or keys resource-affine to a producing step's
17
19
  * collection path) to keep noise down — mirroring the reader's own matching.
18
20
  */
21
+ import { singularize } from "../../utils/pluralization.js";
19
22
  const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
20
23
  function isIdLikeKey(key) {
21
24
  return /^(id|pk|uuid)$/i.test(key) || /_id$/i.test(key) || /[a-z]Id$/.test(key);
@@ -23,6 +26,14 @@ function isIdLikeKey(key) {
23
26
  function isIdishSegment(seg) {
24
27
  return /^\d+$/.test(seg) || UUID_RE.test(seg);
25
28
  }
29
+ /** The collection an id path-segment hangs off: "orders" in /orders/1/confirm. */
30
+ function owningSegment(segs, idIndex) {
31
+ for (let i = idIndex - 1; i >= 0; i--) {
32
+ if (!isIdishSegment(segs[i]))
33
+ return segs[i].toLowerCase();
34
+ }
35
+ return "";
36
+ }
26
37
  function lastStaticSegment(path) {
27
38
  const segs = path.split("/").filter((s) => s !== "");
28
39
  for (let i = segs.length - 1; i >= 0; i--) {
@@ -31,13 +42,45 @@ function lastStaticSegment(path) {
31
42
  }
32
43
  return "";
33
44
  }
45
+ /** Strip the id suffix from a body key, accepting exactly the two spellings
46
+ * `isIdLikeKey` accepts and no others: `_id` case-insensitively (it reads
47
+ * `/_id$/i`, so `provider_ID` is a reference too) and a camel `Id` after a
48
+ * lower-case letter. Words that merely end in those letters (`uuid`, `valid`)
49
+ * keep their spelling — a blanket case-insensitive strip would turn `uuid`
50
+ * into `uu` and match any 4-character collection. */
51
+ function stripIdSuffix(key) {
52
+ if (/_id$/i.test(key))
53
+ return key.slice(0, -3);
54
+ if (/[a-z]Id$/.test(key))
55
+ return key.slice(0, -2);
56
+ return key;
57
+ }
34
58
  /** "provider" ↔ "providers" style prefix affinity between a body key and a
35
59
  * producing step's collection segment. Minimum overlap of 4 chars keeps
36
- * short generic keys from matching everything. */
60
+ * short generic keys from matching everything.
61
+ *
62
+ * The id suffix comes off first: `product_id` and `products` are prefixes of
63
+ * neither direction, and an FK is spelled with that suffix far more often than
64
+ * without, so leaving it on made the affinity fail on the commonest shape it
65
+ * exists to recognise — and a mis-ordered create then received the remedy
66
+ * meant for a fixed-position sub-action. Both sides are then singularized,
67
+ * because a prefix test cannot bridge an irregular plural: "category" is a
68
+ * prefix of neither "categories" nor the reverse. */
37
69
  function keyResourceAffinity(key, segment) {
38
- const k = key.toLowerCase();
39
- const s = segment.toLowerCase();
40
- if (k.length < 4 || s.length < 4)
70
+ const rawKey = stripIdSuffix(key).toLowerCase();
71
+ const rawSegment = segment.toLowerCase();
72
+ const k = singularize(rawKey);
73
+ const s = singularize(rawSegment);
74
+ // Equal singular forms are the strongest affinity there is, so they do not
75
+ // need the length floor — and they must skip it, because singularizing first
76
+ // collapses a 4-letter plural under it (`tags` -> `tag`, and `jobs`, `orgs`,
77
+ // `keys`, `pets` alike). `tag_id` with a later `POST /tags` then read as a
78
+ // sub-action and took the remedy that ships a hardcoded value.
79
+ if (k === s)
80
+ return true;
81
+ // The floor reads the RAW words for the same reason: `cases` -> `cas` is a
82
+ // 3-character over-strip that would fail a comparison the raw pair passes.
83
+ if (rawKey.length < 4 || rawSegment.length < 4)
41
84
  return false;
42
85
  return k.startsWith(s) || s.startsWith(k);
43
86
  }
@@ -85,27 +128,40 @@ export function lintScenarioChaining(steps) {
85
128
  if (body === undefined)
86
129
  return;
87
130
  const segment = typeof step.path === "string" ? lastStaticSegment(step.path) : "";
131
+ const creates = (step.method ?? "").toUpperCase() === "POST";
132
+ const collectionCreate = creates &&
133
+ typeof step.path === "string" &&
134
+ !step.path.split("/").some((seg) => seg !== "" && isIdishSegment(seg));
88
135
  walkPrimitives(body, "", (key, value) => {
89
136
  if (!isIdLikeKey(key))
90
137
  return;
91
138
  const list = producers.get(value) ?? [];
92
- if (!list.some((p) => p.step === idx))
93
- list.push({ step: idx, segment });
139
+ const existing = list.find((p) => p.step === idx);
140
+ if (existing) {
141
+ if (!existing.keys.includes(key))
142
+ existing.keys.push(key);
143
+ }
144
+ else {
145
+ list.push({ step: idx, segment, keys: [key], creates, collectionCreate });
146
+ }
94
147
  producers.set(value, list);
95
148
  });
96
149
  });
97
150
  steps.forEach((step, idx) => {
98
151
  // Collect this step's id-like references, one per distinct value, so a
99
152
  // value referenced in both the path and the body (or repeated in an
100
- // array) yields a single classification and a single message.
101
- const refs = new Map(); // value → first reference description
153
+ // array) yields a single classification and a single message. `resource`
154
+ // names the thing the reference points at ("provider", "orders") and only
155
+ // picks the remedy wording below — it never changes what is flagged.
156
+ const refs = new Map();
102
157
  // Path segments that look like ids are always FK-ish (path params are ids).
103
158
  if (typeof step.path === "string") {
104
- for (const seg of step.path.split("/")) {
105
- if (seg !== "" && isIdishSegment(seg) && !refs.has(seg)) {
106
- refs.set(seg, "path segment");
107
- }
108
- }
159
+ const segs = step.path.split("/").filter((s) => s !== "");
160
+ segs.forEach((seg, i) => {
161
+ if (!isIdishSegment(seg) || refs.has(seg))
162
+ return;
163
+ refs.set(seg, { desc: "path segment", resource: owningSegment(segs, i) });
164
+ });
109
165
  }
110
166
  // Body values: only id-like keys, or keys resource-affine to a producer.
111
167
  const body = parseJsonObject(step.requestBody);
@@ -117,16 +173,44 @@ export function lintScenarioChaining(steps) {
117
173
  const relevant = isIdLikeKey(key) || producerList.some((p) => keyResourceAffinity(key, p.segment));
118
174
  if (!relevant)
119
175
  return;
120
- refs.set(value, `body field "${key}"`);
176
+ refs.set(value, { desc: `body field "${key}"`, resource: key, key });
121
177
  });
122
178
  }
123
- for (const [value, refDesc] of refs) {
179
+ for (const [value, ref] of refs) {
180
+ const refDesc = ref.desc;
124
181
  const all = producers.get(value) ?? [];
125
182
  const earlier = all.filter((p) => p.step < idx);
126
183
  // Strictly-later producers are a fixable ordering mistake; a producer in
127
184
  // the SAME step (e.g. a create whose own response echoes the id) is just
128
185
  // unchainable — Go only chains from strictly-prior steps.
129
186
  const later = all.filter((p) => p.step > idx);
187
+ // An echo of the referencing field is not a producer. A sub-action
188
+ // response that mirrors the parent's child collection re-emits the very
189
+ // field the earlier step SENT — `items[].product_id` comes back on every
190
+ // confirm/ship/deliver — so no value of that field can clear the error,
191
+ // and the only escape is to delete the array from the fabricated
192
+ // response. Measured on run 32395388709: the agent did exactly that, and
193
+ // the assertion judge then docked 10 points for the missing array
194
+ // coverage. A DIFFERENT id key carrying the same value is still an error:
195
+ // that is the genuine `product_id: 1` vs `order_id: 1` ambiguity, where
196
+ // raising the fabricated id is both followable and correct.
197
+ // A later step that creates the very resource this reference points at.
198
+ // Found BEFORE the echo test, because a create is a genuine producer: a
199
+ // conventional `POST /orders` answering `{ order_id }` reuses the FK's own
200
+ // spelling and would otherwise satisfy the echo condition below, silently
201
+ // downgrading a real ordering mistake to a hardcoding warning.
202
+ // The second lookup catches an ALIASED foreign key. `customer_id` created
203
+ // by `POST /clients` shares no prefix with "clients", so affinity cannot
204
+ // see it, and the echo test below then downgraded a genuine ordering
205
+ // mistake to a warning. A collection create planting the referencing key
206
+ // is a producer whatever the key is called; a sub-action POST is excluded
207
+ // by `collectionCreate`, so the measured `/orders/{id}/confirm` echo is
208
+ // untouched.
209
+ const creator = later.find((p) => p.creates && keyResourceAffinity(ref.resource, p.segment)) ??
210
+ later.find((p) => p.collectionCreate && ref.key !== undefined && p.keys.includes(ref.key));
211
+ const echoesReferencingField = ref.key !== undefined &&
212
+ creator === undefined &&
213
+ later.every((p) => p.keys.every((k) => k === ref.key));
130
214
  if (earlier.length > 0) {
131
215
  if (earlier.length > 1) {
132
216
  warnings.push(`Value "${value}" referenced by ${stepLabel(steps, idx)} is produced by multiple prior steps ` +
@@ -134,10 +218,34 @@ export function lintScenarioChaining(steps) {
134
218
  `use distinct, high-entropy ids (e.g. fresh UUIDs) in fabricated responses.`);
135
219
  }
136
220
  }
137
- else if (later.length > 0) {
138
- errors.push(`${stepLabel(steps, idx)} references value "${value}" (${refDesc}) that is only produced by a ` +
139
- `later ${stepLabel(steps, later[0].step)} — reorder the steps so the producing create comes ` +
140
- `first (the generated test can only chain from PRIOR steps).`);
221
+ else if (later.length > 0 && !echoesReferencingField) {
222
+ // Two causes need OPPOSITE fixes, and only one of them can be
223
+ // reordered. Naming both is worse than naming one: renumbering the
224
+ // later id ALWAYS clears the error because it deletes the producer, so
225
+ // an agent offered both picks it and the mis-ordered batch goes green
226
+ // while still hardcoding the value. Pick the remedy here instead.
227
+ if (creator) {
228
+ // The later step creates the very resource being referenced, so it
229
+ // is a real ordering mistake and the value is already correct.
230
+ errors.push(`${stepLabel(steps, idx)} references value "${value}" (${refDesc}) that is only produced by a ` +
231
+ `later ${stepLabel(steps, creator.step)} — that step creates the "${creator.segment}" this ` +
232
+ `reference points at, so reorder the batch to put step ${creator.step + 1} before ` +
233
+ `step ${idx + 1} and keep the value unchanged (the generated test can only chain from PRIOR steps).`);
234
+ }
235
+ else {
236
+ // The later step is a sub-action on a fixed rung of a state machine
237
+ // (confirm/ship/deliver). It cannot move, so the collision is the
238
+ // fabricated id being too guessable — raise its entropy.
239
+ const producer = later[0];
240
+ const shape = producer.segment
241
+ ? `is a "${producer.segment}" sub-action, not a create of what this reference points at`
242
+ : `does not create what this reference points at`;
243
+ errors.push(`${stepLabel(steps, idx)} references value "${value}" (${refDesc}) that is only produced by a ` +
244
+ `later ${stepLabel(steps, producer.step)} — that step ${shape}, so its position is fixed and ` +
245
+ `reordering cannot fix this. Change the id in step ${producer.step + 1}'s fabricated ` +
246
+ `responseBody to a distinct high-entropy value (e.g. a fresh UUID or a large random integer) ` +
247
+ `so it stops colliding with "${value}" here.`);
248
+ }
141
249
  }
142
250
  else {
143
251
  warnings.push(`${stepLabel(steps, idx)} ${refDesc} value "${value}" does not appear in any prior step's ` +
@@ -0,0 +1,20 @@
1
+ /** Test-only: clear the in-process trace→outputs registry. */
2
+ export declare function resetTraceReuseGuard(): void;
3
+ export interface TraceReuseCheck {
4
+ /** Set when the call must be rejected (same zip, different output). */
5
+ rejection?: string;
6
+ /** Content hash of the zip — pass to recordTraceOutput on success. */
7
+ key?: string;
8
+ /** The resolved output identity — pass to recordTraceOutput on success. */
9
+ identity?: string;
10
+ }
11
+ /**
12
+ * Read-only reuse check. Returns a rejection when this zip's content already
13
+ * generated a DIFFERENT output identity and `allowTraceReuse` is not set; on a
14
+ * clean check, returns the hash/identity for the caller to record after
15
+ * generation succeeds. Fails open (empty result) when the zip is unreadable —
16
+ * generation will surface its own error for that.
17
+ */
18
+ export declare function checkTraceReuse(playwrightInput: string | undefined, output: string | undefined, outputDir: string | undefined, allowTraceReuse: boolean, scope: "ui" | "e2e"): Promise<TraceReuseCheck>;
19
+ /** Register a generated output for its trace. Call ONLY after generation succeeded. */
20
+ export declare function recordTraceOutput(key: string, identity: string): void;
@@ -0,0 +1,93 @@
1
+ import { createHash } from "crypto";
2
+ import { createReadStream } from "fs";
3
+ import { pipeline } from "stream/promises";
4
+ import * as path from "path";
5
+ /**
6
+ * SKYR-4262 — trace-reuse guard. One recording captures one flow; generating
7
+ * several differently-named specs from the same trace zip produces byte-identical
8
+ * tests whose names claim scenarios the recording never covered (observed in
9
+ * production: one trace fanned into 4 specs, none asserting its named behavior).
10
+ * Keyed by CONTENT hash, not path, so re-exporting a genuinely new recording to
11
+ * the same zip path never trips the guard.
12
+ *
13
+ * Scope: this registry is per MCP-server process. That is narrower than "one
14
+ * testbot run" — testbot retries an agent up to 3 times, each attempt spawning a
15
+ * fresh agent CLI and therefore a fresh server (a retry also deletes the run's
16
+ * state files and restarts the flow, so the reset is coherent) — and broader
17
+ * than one recording session in a long-lived IDE/stdio server. Both are
18
+ * acceptable for this guard's job: reusing an IDENTICAL zip under a new name is
19
+ * the failure mode wherever it happens, and a fresh recording always hashes
20
+ * differently.
21
+ *
22
+ * Check/record are split: `checkTraceReuse` is read-only and runs BEFORE
23
+ * generation; `recordTraceOutput` runs only after generation SUCCEEDS.
24
+ * Registering at check time poisoned the registry on any generation failure —
25
+ * e.g. output-name validation rejects `a.ts` with "rename to a.spec.ts", and the
26
+ * corrected retry was then refused for reusing its own trace.
27
+ */
28
+ const generatedFromTrace = new Map();
29
+ /** Test-only: clear the in-process trace→outputs registry. */
30
+ export function resetTraceReuseGuard() {
31
+ generatedFromTrace.clear();
32
+ }
33
+ /**
34
+ * The registered identity of one generated spec: the RESOLVED output path, so
35
+ * the same filename into two different outputDirs still counts as a second,
36
+ * differently-named spec. An omitted `output` (Skyramp picks a default name)
37
+ * maps to a per-directory sentinel: two default-named generations from the same
38
+ * zip into the same directory overwrite each other and are allowed, while
39
+ * mixing a named and an unnamed generation of the same zip is a fan-out and is
40
+ * not.
41
+ */
42
+ function outputIdentity(output, outputDir) {
43
+ return path.resolve(outputDir ?? process.cwd(), output ?? "(default output name)");
44
+ }
45
+ /**
46
+ * Read-only reuse check. Returns a rejection when this zip's content already
47
+ * generated a DIFFERENT output identity and `allowTraceReuse` is not set; on a
48
+ * clean check, returns the hash/identity for the caller to record after
49
+ * generation succeeds. Fails open (empty result) when the zip is unreadable —
50
+ * generation will surface its own error for that.
51
+ */
52
+ export async function checkTraceReuse(playwrightInput, output, outputDir, allowTraceReuse, scope) {
53
+ if (!playwrightInput)
54
+ return {};
55
+ let key;
56
+ try {
57
+ // Stream the zip through the hash — trace zips carry full HARs and can be
58
+ // large, so buffering the whole file would spike the server's memory.
59
+ const hasher = createHash("sha256");
60
+ await pipeline(createReadStream(playwrightInput), hasher);
61
+ // Namespace by generator: a UI test and an E2E test from ONE recording are
62
+ // different artifacts (the e2e generator adds API correlation), not the
63
+ // byte-identical fan-out this guard exists to stop — only same-generator
64
+ // reuse of a zip is a duplicate.
65
+ key = `${scope}:${hasher.digest("hex")}`;
66
+ }
67
+ catch {
68
+ // Unreadable zip fails in generation with its own error; the guard stays out of the way.
69
+ return {};
70
+ }
71
+ const identity = outputIdentity(output, outputDir);
72
+ const outputs = generatedFromTrace.get(key);
73
+ if (!allowTraceReuse && outputs && outputs.size > 0 && !outputs.has(identity)) {
74
+ const prior = [...outputs].map(p => path.basename(p)).join(", ");
75
+ return {
76
+ key,
77
+ identity,
78
+ rejection: `This trace zip already generated ${prior}. A recording captures ONE flow — generating a second, ` +
79
+ `differently-named spec (${path.basename(identity)}) from the same trace produces an identical test whose name claims a ` +
80
+ `scenario the recording never covered. Record a dedicated trace for this scenario with the browser_* tools ` +
81
+ `(including browser_assert on the behavior the test is named for) and skyramp_export_zip, then generate from ` +
82
+ `that. If this recording genuinely covers the scenario end-to-end, pass allowTraceReuse: true and be prepared ` +
83
+ `to differentiate the spec's assertions afterward via skyramp_enhance_assertions.`,
84
+ };
85
+ }
86
+ return { key, identity };
87
+ }
88
+ /** Register a generated output for its trace. Call ONLY after generation succeeded. */
89
+ export function recordTraceOutput(key, identity) {
90
+ const outputs = generatedFromTrace.get(key) ?? new Set();
91
+ outputs.add(identity);
92
+ generatedFromTrace.set(key, outputs);
93
+ }
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { WorkspaceConfig } from "../workspace/workspace.js";
3
- import { ParsedExternalRun } from "../types/ExternalTestExecution.js";
3
+ import { ExternalFileOutcome, ExternalTestResult, ParsedExternalRun } from "../types/ExternalTestExecution.js";
4
4
  import { SUPPORTED_FRAMEWORKS } from "../workspace/frameworks.js";
5
5
  /** Test-run lifecycle resolved from a service's workspace.yml runtimeDetails. */
6
6
  export interface ResolvedRunConfig {
@@ -42,7 +42,9 @@ export interface RunExistingTestsParams {
42
42
  workspacePath: string;
43
43
  service?: string;
44
44
  testSelectors?: string[];
45
- mode?: "confirm" | "verify";
45
+ /** Required: the only thing that marks a record as the pre-edit baseline or the
46
+ * post-edit re-run. A wrong label is read as fact by the report. */
47
+ mode: "confirm" | "verify";
46
48
  project?: string;
47
49
  stateFile?: string;
48
50
  repository?: string;
@@ -128,6 +130,36 @@ export declare function aggregateRuns(parsed: ParsedExternalRun[]): ParsedExtern
128
130
  */
129
131
  export declare function runExternalSuite(spec: ReaderSpec, cfg: ResolvedRunConfig, opts: PlaywrightRunOpts): Promise<ParsedExternalRun>;
130
132
  export declare function runPlaywright(cfg: ResolvedRunConfig, opts: PlaywrightRunOpts): Promise<ParsedExternalRun>;
133
+ /**
134
+ * Reduce a run's flat result list to one outcome per spec file.
135
+ *
136
+ * This is the only place that knows BOTH what the framework reported and what the
137
+ * run asked it for, which is what `complete` needs. Deriving it later from the
138
+ * results alone is not possible: a file re-run for one test looks identical to a
139
+ * file whose other tests silently vanished.
140
+ *
141
+ * `partialFiles` are the files a `file::test title` selector narrowed to a subset of
142
+ * their tests — the one narrowing this tool applies itself, and so the only one it
143
+ * can state with certainty.
144
+ */
145
+ export declare function summarizeFilesInRun(results: ExternalTestResult[], opts: {
146
+ partialFiles: Set<string>;
147
+ }): ExternalFileOutcome[];
148
+ /** Placeholder identity for a record that names no suite because none resolved.
149
+ * Deliberately not a valid suiteIdOf output, so it can never collide with one. */
150
+ export declare const NO_RUNNABLE_SUITE = "(no runnable suite)";
151
+ /** Stable identity for ONE configured suite. A service can declare several
152
+ * (workspace.yml testSuites), so its name alone collides — two suites under one
153
+ * service would share a slot and the later would mask the other instead of both
154
+ * counting. */
155
+ export declare function suiteIdOf(cfg: {
156
+ serviceName: string;
157
+ framework: string;
158
+ testRunCommand: string;
159
+ }): string;
160
+ /** The files a selector narrowed to individual tests. A bare `file` selector runs
161
+ * the whole file; `file::test title` does not. */
162
+ export declare function partialFilesFrom(selectors: string[]): Set<string>;
131
163
  /**
132
164
  * Orchestrates an external test run: resolve config → apply maxTests cap → run →
133
165
  * persist a record to the state file. Pure of MCP/registration concerns and
@@ -440,6 +440,95 @@ const defaultDeps = {
440
440
  },
441
441
  now: () => new Date().toISOString(),
442
442
  };
443
+ /** Worst outcome wins for a file. `error` (never reached its assertions) outranks
444
+ * `fail` so a collection failure is never reported as an assertion failure, and
445
+ * `skipped` sits below everything: it only wins when nothing else ran. */
446
+ const STATUS_RANK = {
447
+ skipped: 0,
448
+ pass: 1,
449
+ fail: 2,
450
+ error: 3,
451
+ };
452
+ /**
453
+ * Reduce a run's flat result list to one outcome per spec file.
454
+ *
455
+ * This is the only place that knows BOTH what the framework reported and what the
456
+ * run asked it for, which is what `complete` needs. Deriving it later from the
457
+ * results alone is not possible: a file re-run for one test looks identical to a
458
+ * file whose other tests silently vanished.
459
+ *
460
+ * `partialFiles` are the files a `file::test title` selector narrowed to a subset of
461
+ * their tests — the one narrowing this tool applies itself, and so the only one it
462
+ * can state with certainty.
463
+ */
464
+ export function summarizeFilesInRun(results, opts) {
465
+ const byFile = new Map();
466
+ for (const r of results) {
467
+ if (typeof r.file !== "string" || r.file === "")
468
+ continue;
469
+ const key = r.absoluteFile ?? r.file;
470
+ byFile.set(key, [...(byFile.get(key) ?? []), r]);
471
+ }
472
+ return [...byFile.values()].map((group) => {
473
+ const counts = { pass: 0, fail: 0, error: 0, skipped: 0 };
474
+ for (const r of group)
475
+ counts[r.status] = (counts[r.status] ?? 0) + 1;
476
+ const status = group.reduce((a, b) => STATUS_RANK[b.status] > STATUS_RANK[a.status] ? b : a).status;
477
+ const first = group[0];
478
+ return {
479
+ file: first.file,
480
+ ...(first.absoluteFile ? { absoluteFile: first.absoluteFile } : {}),
481
+ status,
482
+ counts,
483
+ complete: !isPartial(first, opts.partialFiles),
484
+ errors: group
485
+ .filter((r) => r.status === "fail" || r.status === "error")
486
+ .map((r) => `${r.testId}: ${r.message ?? r.status}`),
487
+ durationMs: group.reduce((sum, r) => sum + (r.durationMs ?? 0), 0),
488
+ };
489
+ });
490
+ }
491
+ /** A selector spelling and a result spelling need not agree — the selector is
492
+ * repo-relative, the result may be absolute or framework-root-relative — so match
493
+ * on a path-segment suffix in either direction. */
494
+ function isPartial(r, partialFiles) {
495
+ if (partialFiles.size === 0)
496
+ return false;
497
+ const candidates = [r.file, r.absoluteFile].filter((c) => typeof c === "string" && c !== "");
498
+ for (const sel of partialFiles) {
499
+ for (const c of candidates) {
500
+ if (c === sel || c.endsWith("/" + sel) || sel.endsWith("/" + c))
501
+ return true;
502
+ }
503
+ }
504
+ return false;
505
+ }
506
+ /** Placeholder identity for a record that names no suite because none resolved.
507
+ * Deliberately not a valid suiteIdOf output, so it can never collide with one. */
508
+ export const NO_RUNNABLE_SUITE = "(no runnable suite)";
509
+ /** Stable identity for ONE configured suite. A service can declare several
510
+ * (workspace.yml testSuites), so its name alone collides — two suites under one
511
+ * service would share a slot and the later would mask the other instead of both
512
+ * counting. */
513
+ export function suiteIdOf(cfg) {
514
+ return `${cfg.serviceName}::${cfg.framework}::${cfg.testRunCommand}`;
515
+ }
516
+ /** The files a selector narrowed to individual tests. A bare `file` selector runs
517
+ * the whole file; `file::test title` does not. */
518
+ export function partialFilesFrom(selectors) {
519
+ const out = new Set();
520
+ for (const sel of selectors) {
521
+ const [file, ...rest] = sel.split("::");
522
+ // A trailing `::` names no title, so every builder drops it and the whole file
523
+ // runs. Treating it as partial would throw away a real status.
524
+ if (rest.join("::").trim() === "")
525
+ continue;
526
+ const trimmed = file.trim();
527
+ if (trimmed)
528
+ out.add(trimmed);
529
+ }
530
+ return out;
531
+ }
443
532
  /** Appends a run record into UnifiedAnalysisState.externalTestResults for the
444
533
  * given repo section. Best-effort: a state failure never fails the run. */
445
534
  async function persistExternalRun(stateFile, repository, record) {
@@ -463,7 +552,7 @@ async function persistExternalRun(stateFile, repository, record) {
463
552
  * fully injectable for tests.
464
553
  */
465
554
  export async function runExternalTests(params, deps = defaultDeps) {
466
- const mode = params.mode ?? "confirm";
555
+ const mode = params.mode;
467
556
  const selectors = params.testSelectors ?? [];
468
557
  let effective = selectors;
469
558
  let truncated = false;
@@ -486,6 +575,11 @@ export async function runExternalTests(params, deps = defaultDeps) {
486
575
  if (params.stateFile) {
487
576
  await persistExternalRun(params.stateFile, params.repository, {
488
577
  ...skippedRun,
578
+ files: [],
579
+ // No run config resolved, so there is no suite to identify — and a service
580
+ // name here would read as one. The reader never keys on it (the record is
581
+ // skipped, and skipped records are dropped first), so name it for what it is.
582
+ suite: NO_RUNNABLE_SUITE,
489
583
  mode,
490
584
  ranAt: deps.now(),
491
585
  repository: params.repository,
@@ -518,6 +612,8 @@ export async function runExternalTests(params, deps = defaultDeps) {
518
612
  if (params.stateFile) {
519
613
  await persistExternalRun(params.stateFile, params.repository, {
520
614
  ...routedOut,
615
+ files: [],
616
+ suite: suiteIdOf(cfg),
521
617
  mode,
522
618
  ranAt: deps.now(),
523
619
  repository: params.repository,
@@ -542,8 +638,13 @@ export async function runExternalTests(params, deps = defaultDeps) {
542
638
  };
543
639
  perSuite.push(suiteResult);
544
640
  if (params.stateFile) {
641
+ const files = summarizeFilesInRun(suiteResult.results, {
642
+ partialFiles: partialFilesFrom(routing.kept),
643
+ });
545
644
  await persistExternalRun(params.stateFile, params.repository, {
546
645
  ...suiteResult,
646
+ files,
647
+ suite: suiteIdOf(cfg),
547
648
  mode,
548
649
  ranAt: deps.now(),
549
650
  repository: params.repository,
@@ -575,8 +676,7 @@ export function registerRunExistingTestsTool(server) {
575
676
  .describe("Spec files to run (paths relative to the run command's cwd), optionally `file::test title` to also filter by name. Omit to run the whole configured suite."),
576
677
  mode: z
577
678
  .enum(["confirm", "verify"])
578
- .optional()
579
- .describe("confirm (default): scoped run to establish which tests a change breaks; disables the suite's fail-fast cap (e.g. Playwright's maxFailures) so ALL breakages are counted. verify: re-run after an edit."),
679
+ .describe("REQUIRED, and the only thing that marks a run as before or after the edit: confirm = the pre-edit baseline (a scoped run establishing which tests the change breaks; disables the suite's fail-fast cap, e.g. Playwright's maxFailures, so ALL breakages are counted), verify = the re-run after you edited. Passing confirm for a post-edit run overwrites the baseline with the fixed result, and the report then shows a repair as having never been broken."),
580
680
  project: z
581
681
  .string()
582
682
  .optional()
@@ -635,7 +735,7 @@ export function registerRunExistingTestsTool(server) {
635
735
  AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
636
736
  workspacePath: params.workspacePath,
637
737
  service: params.service ?? "",
638
- mode: params.mode ?? "confirm",
738
+ mode: params.mode,
639
739
  }).catch((err) => {
640
740
  logger.warning("Analytics event failed", { error: String(err) });
641
741
  });