@skyramp/mcp 0.3.4 → 0.3.6-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/build/playwright/registerPlaywrightTools.js +92 -30
  2. package/build/playwright/traceRecordingPrompt.d.ts +6 -0
  3. package/build/playwright/traceRecordingPrompt.js +6 -2
  4. package/build/prompts/code-reuse.d.ts +1 -2
  5. package/build/prompts/code-reuse.js +182 -77
  6. package/build/prompts/modularization/integration-test-modularization.d.ts +2 -0
  7. package/build/prompts/modularization/integration-test-modularization.js +83 -41
  8. package/build/prompts/modularization/render.d.ts +18 -0
  9. package/build/prompts/modularization/render.js +12 -0
  10. package/build/prompts/modularization/ui-test-modularization.d.ts +3 -1
  11. package/build/prompts/modularization/ui-test-modularization.js +89 -47
  12. package/build/prompts/pom-aware-code-reuse.js +3 -1
  13. package/build/prompts/shared-helper-policy.d.ts +57 -0
  14. package/build/prompts/shared-helper-policy.js +135 -0
  15. package/build/prompts/test-recommendation/diffExecutionPlan.js +62 -56
  16. package/build/prompts/test-recommendation/fullRepoCatalog.js +19 -8
  17. package/build/prompts/test-recommendation/recommendationShared.d.ts +28 -6
  18. package/build/prompts/test-recommendation/recommendationShared.js +90 -16
  19. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +22 -0
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -2
  21. package/build/prompts/test-recommendation/test-recommendation-prompt.js +3 -3
  22. package/build/prompts/testbot/testbot-prompts.js +88 -33
  23. package/build/recommendation/budgeters/shared.js +105 -27
  24. package/build/recommendation/discriminators.js +13 -2
  25. package/build/recommendation/planRanker.d.ts +6 -6
  26. package/build/recommendation/planRanker.js +6 -61
  27. package/build/services/AnalyticsService.d.ts +7 -0
  28. package/build/services/AnalyticsService.js +7 -1
  29. package/build/services/ModularizationService.js +1 -3
  30. package/build/services/TestDiscoveryService.d.ts +0 -2
  31. package/build/services/TestDiscoveryService.js +2 -37
  32. package/build/services/TestGenerationService.d.ts +16 -0
  33. package/build/services/TestGenerationService.js +86 -10
  34. package/build/services/containerEnv.js +13 -12
  35. package/build/tools/code-refactor/codeReuseTool.js +279 -93
  36. package/build/tools/code-refactor/enhance-state.d.ts +49 -0
  37. package/build/tools/code-refactor/enhance-state.js +109 -0
  38. package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -1
  39. package/build/tools/code-refactor/modularizationTool.js +9 -2
  40. package/build/tools/code-refactor/reuse-outcome.d.ts +23 -1
  41. package/build/tools/code-refactor/reuse-outcome.js +14 -4
  42. package/build/tools/code-refactor/reuse-state.d.ts +127 -5
  43. package/build/tools/code-refactor/reuse-state.js +628 -16
  44. package/build/tools/code-refactor/utils-verify-gates.d.ts +26 -0
  45. package/build/tools/code-refactor/utils-verify-gates.js +100 -0
  46. package/build/tools/code-refactor/verify-gates.d.ts +2 -1
  47. package/build/tools/code-refactor/verify-gates.js +90 -25
  48. package/build/tools/executeSkyrampTestTool.d.ts +19 -0
  49. package/build/tools/executeSkyrampTestTool.js +158 -8
  50. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -2
  51. package/build/tools/generate-tests/generateE2ERestTool.js +16 -0
  52. package/build/tools/generate-tests/generateUIRestTool.d.ts +1 -0
  53. package/build/tools/generate-tests/generateUIRestTool.js +22 -0
  54. package/build/tools/generate-tests/scenarioLint.d.ts +2 -0
  55. package/build/tools/generate-tests/scenarioLint.js +127 -19
  56. package/build/tools/generate-tests/trace-reuse-guard.d.ts +20 -0
  57. package/build/tools/generate-tests/trace-reuse-guard.js +93 -0
  58. package/build/tools/submitReportTool.d.ts +38 -38
  59. package/build/tools/submitReportTool.js +487 -104
  60. package/build/tools/test-management/analyzeChangesTool.d.ts +24 -1
  61. package/build/tools/test-management/analyzeChangesTool.js +75 -12
  62. package/build/tools/test-management/analyzeTestHealthTool.js +7 -7
  63. package/build/tools/test-management/registerTestPlanTool.d.ts +203 -0
  64. package/build/tools/test-management/registerTestPlanTool.js +149 -23
  65. package/build/types/Recommendation.d.ts +34 -5
  66. package/build/types/RepositoryAnalysis.d.ts +133 -114
  67. package/build/types/RepositoryAnalysis.js +1 -1
  68. package/build/types/ReuseOutcome.d.ts +102 -6
  69. package/build/types/ReuseOutcome.js +16 -2
  70. package/build/types/TestRecommendation.js +21 -3
  71. package/build/types/TestTypes.js +14 -8
  72. package/build/types/TestbotReport.d.ts +25 -3
  73. package/build/types/index.d.ts +2 -2
  74. package/build/types/index.js +1 -1
  75. package/build/utils/AnalysisStateManager.d.ts +69 -1
  76. package/build/utils/AnalysisStateManager.js +69 -5
  77. package/build/utils/branchDiff.d.ts +10 -0
  78. package/build/utils/branchDiff.js +28 -0
  79. package/build/utils/changedRoutes.d.ts +29 -0
  80. package/build/utils/changedRoutes.js +87 -0
  81. package/build/utils/featureFlags.d.ts +21 -0
  82. package/build/utils/featureFlags.js +23 -0
  83. package/build/utils/frontendIntegration.js +34 -4
  84. package/build/utils/importerHop.d.ts +2 -8
  85. package/build/utils/importerHop.js +15 -53
  86. package/build/utils/pathMatching.d.ts +38 -0
  87. package/build/utils/pathMatching.js +71 -0
  88. package/build/utils/pathSignatures.d.ts +22 -0
  89. package/build/utils/pathSignatures.js +57 -0
  90. package/build/utils/planMatchKeys.d.ts +16 -3
  91. package/build/utils/planMatchKeys.js +26 -10
  92. package/build/utils/pluralization.d.ts +10 -0
  93. package/build/utils/pluralization.js +18 -0
  94. package/build/utils/pom-catalog-parse.d.ts +52 -0
  95. package/build/utils/pom-catalog-parse.js +141 -0
  96. package/build/utils/pom-scope/selector-extractor.d.ts +12 -0
  97. package/build/utils/pom-scope/selector-extractor.js +34 -8
  98. package/build/utils/pom-verify/verify.d.ts +6 -5
  99. package/build/utils/pom-verify/verify.js +8 -6
  100. package/build/utils/reportLanguage.d.ts +43 -0
  101. package/build/utils/reportLanguage.js +125 -0
  102. package/build/utils/reportVerification.d.ts +74 -4
  103. package/build/utils/reportVerification.js +259 -3
  104. package/build/utils/reuseRouting.d.ts +3 -0
  105. package/build/utils/reuseRouting.js +50 -0
  106. package/build/utils/routeParsers.d.ts +2 -0
  107. package/build/utils/routeParsers.js +65 -8
  108. package/build/utils/scenarioDrafting.d.ts +1 -1
  109. package/build/utils/scenarioDrafting.js +57 -45
  110. package/build/utils/subjectEndpoints.d.ts +19 -0
  111. package/build/utils/subjectEndpoints.js +98 -0
  112. package/build/utils/testFileClassification.d.ts +11 -0
  113. package/build/utils/testFileClassification.js +47 -0
  114. package/build/utils/uiPageEnumerator.d.ts +45 -19
  115. package/build/utils/uiPageEnumerator.js +95 -51
  116. package/build/utils/utils-verify/allow.d.ts +16 -0
  117. package/build/utils/utils-verify/allow.js +68 -0
  118. package/build/utils/utils-verify/call-sites.d.ts +34 -0
  119. package/build/utils/utils-verify/call-sites.js +154 -0
  120. package/build/utils/utils-verify/index.d.ts +7 -0
  121. package/build/utils/utils-verify/index.js +7 -0
  122. package/build/utils/utils-verify/language-spec.d.ts +91 -0
  123. package/build/utils/utils-verify/language-spec.js +210 -0
  124. package/build/utils/utils-verify/locate.d.ts +39 -0
  125. package/build/utils/utils-verify/locate.js +199 -0
  126. package/build/utils/utils-verify/parse.d.ts +34 -0
  127. package/build/utils/utils-verify/parse.js +177 -0
  128. package/build/utils/utils-verify/stage.d.ts +24 -0
  129. package/build/utils/utils-verify/stage.js +107 -0
  130. package/build/utils/utils-verify/verify.d.ts +63 -0
  131. package/build/utils/utils-verify/verify.js +168 -0
  132. package/build/utils/utils.d.ts +3 -1
  133. package/build/utils/utils.js +3 -1
  134. package/build/workspace/workspace.d.ts +32 -32
  135. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +9 -5
  136. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +16 -0
  137. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -0
  138. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +115 -14
  139. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +13 -1
  140. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
  141. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -0
  142. package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +27 -253
  143. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-DtudTj_v.js → codeMirrorModule-DJMC4zNo.js} +1 -1
  144. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BW82eAUI.js +196 -0
  145. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  146. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-FNMuBzX1.js → codeMirrorModule-CZfp96qZ.js} +1 -1
  147. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-gpLo02E0.js +809 -0
  148. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.Bq1r1URj.js +2 -0
  149. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  150. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.VEfqi1qN.js +5 -0
  151. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  152. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  153. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  154. package/node_modules/playwright/package.json +1 -1
  155. package/package.json +2 -2
  156. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +0 -422
  157. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +0 -1035
  158. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +0 -2
  159. 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
+ }
@@ -18,9 +18,9 @@ export declare const REPORT_FILE_NAME = "testbot-result.txt";
18
18
  export declare const targetElementSchema: z.ZodObject<{
19
19
  role: z.ZodString;
20
20
  accessibleName: z.ZodString;
21
- testId: z.ZodNullable<z.ZodString>;
22
- stableId: z.ZodNullable<z.ZodString>;
23
- contextText: z.ZodNullable<z.ZodArray<z.ZodString, "many">>;
21
+ testId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null, string | null | undefined>;
22
+ stableId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null, string | null | undefined>;
23
+ contextText: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString, "many">>>, string[] | null, string[] | null | undefined>;
24
24
  mutability: z.ZodOptional<z.ZodEnum<["mutable", "immutable", "unknown"]>>;
25
25
  widgetType: z.ZodOptional<z.ZodEnum<["native", "custom", "unknown"]>>;
26
26
  }, "strip", z.ZodTypeAny, {
@@ -34,9 +34,9 @@ export declare const targetElementSchema: z.ZodObject<{
34
34
  }, {
35
35
  role: string;
36
36
  accessibleName: string;
37
- testId: string | null;
38
- stableId: string | null;
39
- contextText: string[] | null;
37
+ testId?: string | null | undefined;
38
+ stableId?: string | null | undefined;
39
+ contextText?: string[] | null | undefined;
40
40
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
41
41
  widgetType?: "unknown" | "custom" | "native" | undefined;
42
42
  }>;
@@ -65,9 +65,9 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
65
65
  targetElements: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
66
66
  role: z.ZodString;
67
67
  accessibleName: z.ZodString;
68
- testId: z.ZodNullable<z.ZodString>;
69
- stableId: z.ZodNullable<z.ZodString>;
70
- contextText: z.ZodNullable<z.ZodArray<z.ZodString, "many">>;
68
+ testId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null, string | null | undefined>;
69
+ stableId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null, string | null | undefined>;
70
+ contextText: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString, "many">>>, string[] | null, string[] | null | undefined>;
71
71
  mutability: z.ZodOptional<z.ZodEnum<["mutable", "immutable", "unknown"]>>;
72
72
  widgetType: z.ZodOptional<z.ZodEnum<["native", "custom", "unknown"]>>;
73
73
  }, "strip", z.ZodTypeAny, {
@@ -81,9 +81,9 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
81
81
  }, {
82
82
  role: string;
83
83
  accessibleName: string;
84
- testId: string | null;
85
- stableId: string | null;
86
- contextText: string[] | null;
84
+ testId?: string | null | undefined;
85
+ stableId?: string | null | undefined;
86
+ contextText?: string[] | null | undefined;
87
87
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
88
88
  widgetType?: "unknown" | "custom" | "native" | undefined;
89
89
  }>, "many">>>;
@@ -136,9 +136,9 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
136
136
  targetElements?: {
137
137
  role: string;
138
138
  accessibleName: string;
139
- testId: string | null;
140
- stableId: string | null;
141
- contextText: string[] | null;
139
+ testId?: string | null | undefined;
140
+ stableId?: string | null | undefined;
141
+ contextText?: string[] | null | undefined;
142
142
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
143
143
  widgetType?: "unknown" | "custom" | "native" | undefined;
144
144
  }[] | null | undefined;
@@ -186,9 +186,9 @@ export declare const newTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
186
186
  targetElements?: {
187
187
  role: string;
188
188
  accessibleName: string;
189
- testId: string | null;
190
- stableId: string | null;
191
- contextText: string[] | null;
189
+ testId?: string | null | undefined;
190
+ stableId?: string | null | undefined;
191
+ contextText?: string[] | null | undefined;
192
192
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
193
193
  widgetType?: "unknown" | "custom" | "native" | undefined;
194
194
  }[] | null | undefined;
@@ -235,20 +235,20 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
235
235
  description: z.ZodString;
236
236
  expectedStatusCode: z.ZodOptional<z.ZodNumber>;
237
237
  requestBody: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
238
- responseBody: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
238
+ responseBody: z.ZodOptional<z.ZodUnion<[z.ZodRecord<z.ZodString, z.ZodAny>, z.ZodArray<z.ZodAny, "many">]>>;
239
239
  }, "strip", z.ZodTypeAny, {
240
240
  description: string;
241
241
  path?: string | undefined;
242
242
  method?: HttpMethod | undefined;
243
243
  requestBody?: Record<string, any> | undefined;
244
- responseBody?: Record<string, any> | undefined;
244
+ responseBody?: any[] | Record<string, any> | undefined;
245
245
  expectedStatusCode?: number | undefined;
246
246
  }, {
247
247
  description: string;
248
248
  path?: string | undefined;
249
249
  method?: HttpMethod | undefined;
250
250
  requestBody?: Record<string, any> | undefined;
251
- responseBody?: Record<string, any> | undefined;
251
+ responseBody?: any[] | Record<string, any> | undefined;
252
252
  expectedStatusCode?: number | undefined;
253
253
  }>, "many">;
254
254
  description: z.ZodString;
@@ -261,9 +261,9 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
261
261
  targetElements: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
262
262
  role: z.ZodString;
263
263
  accessibleName: z.ZodString;
264
- testId: z.ZodNullable<z.ZodString>;
265
- stableId: z.ZodNullable<z.ZodString>;
266
- contextText: z.ZodNullable<z.ZodArray<z.ZodString, "many">>;
264
+ testId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null, string | null | undefined>;
265
+ stableId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null, string | null | undefined>;
266
+ contextText: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString, "many">>>, string[] | null, string[] | null | undefined>;
267
267
  mutability: z.ZodOptional<z.ZodEnum<["mutable", "immutable", "unknown"]>>;
268
268
  widgetType: z.ZodOptional<z.ZodEnum<["native", "custom", "unknown"]>>;
269
269
  }, "strip", z.ZodTypeAny, {
@@ -277,9 +277,9 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
277
277
  }, {
278
278
  role: string;
279
279
  accessibleName: string;
280
- testId: string | null;
281
- stableId: string | null;
282
- contextText: string[] | null;
280
+ testId?: string | null | undefined;
281
+ stableId?: string | null | undefined;
282
+ contextText?: string[] | null | undefined;
283
283
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
284
284
  widgetType?: "unknown" | "custom" | "native" | undefined;
285
285
  }>, "many">>>;
@@ -303,7 +303,7 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
303
303
  path?: string | undefined;
304
304
  method?: HttpMethod | undefined;
305
305
  requestBody?: Record<string, any> | undefined;
306
- responseBody?: Record<string, any> | undefined;
306
+ responseBody?: any[] | Record<string, any> | undefined;
307
307
  expectedStatusCode?: number | undefined;
308
308
  }[];
309
309
  testId: string;
@@ -335,7 +335,7 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
335
335
  path?: string | undefined;
336
336
  method?: HttpMethod | undefined;
337
337
  requestBody?: Record<string, any> | undefined;
338
- responseBody?: Record<string, any> | undefined;
338
+ responseBody?: any[] | Record<string, any> | undefined;
339
339
  expectedStatusCode?: number | undefined;
340
340
  }[];
341
341
  testId: string;
@@ -347,9 +347,9 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
347
347
  targetElements?: {
348
348
  role: string;
349
349
  accessibleName: string;
350
- testId: string | null;
351
- stableId: string | null;
352
- contextText: string[] | null;
350
+ testId?: string | null | undefined;
351
+ stableId?: string | null | undefined;
352
+ contextText?: string[] | null | undefined;
353
353
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
354
354
  widgetType?: "unknown" | "custom" | "native" | undefined;
355
355
  }[] | null | undefined;
@@ -371,7 +371,7 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
371
371
  path?: string | undefined;
372
372
  method?: HttpMethod | undefined;
373
373
  requestBody?: Record<string, any> | undefined;
374
- responseBody?: Record<string, any> | undefined;
374
+ responseBody?: any[] | Record<string, any> | undefined;
375
375
  expectedStatusCode?: number | undefined;
376
376
  }[];
377
377
  testId: string;
@@ -403,7 +403,7 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
403
403
  path?: string | undefined;
404
404
  method?: HttpMethod | undefined;
405
405
  requestBody?: Record<string, any> | undefined;
406
- responseBody?: Record<string, any> | undefined;
406
+ responseBody?: any[] | Record<string, any> | undefined;
407
407
  expectedStatusCode?: number | undefined;
408
408
  }[];
409
409
  testId: string;
@@ -415,9 +415,9 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
415
415
  targetElements?: {
416
416
  role: string;
417
417
  accessibleName: string;
418
- testId: string | null;
419
- stableId: string | null;
420
- contextText: string[] | null;
418
+ testId?: string | null | undefined;
419
+ stableId?: string | null | undefined;
420
+ contextText?: string[] | null | undefined;
421
421
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
422
422
  widgetType?: "unknown" | "custom" | "native" | undefined;
423
423
  }[] | null | undefined;
@@ -439,7 +439,7 @@ export declare const additionalRecommendationSchema: z.ZodEffects<z.ZodEffects<z
439
439
  path?: string | undefined;
440
440
  method?: HttpMethod | undefined;
441
441
  requestBody?: Record<string, any> | undefined;
442
- responseBody?: Record<string, any> | undefined;
442
+ responseBody?: any[] | Record<string, any> | undefined;
443
443
  expectedStatusCode?: number | undefined;
444
444
  }[];
445
445
  testId: string;