@skyramp/mcp 0.4.0 → 0.4.1-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/build/prompts/enhance-assertions/contractProviderAssertionsPrompt.js +2 -1
  2. package/build/prompts/enhance-assertions/integrationAssertionsPrompt.js +2 -1
  3. package/build/prompts/enhance-assertions/sharedAssertionRules.d.ts +1 -1
  4. package/build/prompts/enhance-assertions/sharedAssertionRules.js +57 -22
  5. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +17 -9
  6. package/build/prompts/test-recommendation/diffExecutionPlan.js +0 -2
  7. package/build/prompts/test-recommendation/test-recommendation-prompt.js +11 -6
  8. package/build/prompts/testbot/testbot-prompts.js +19 -12
  9. package/build/recommendation/answers.d.ts +11 -7
  10. package/build/recommendation/answers.js +14 -10
  11. package/build/recommendation/pullRequestText.d.ts +18 -0
  12. package/build/recommendation/pullRequestText.js +31 -0
  13. package/build/recommendation/registerPlan.d.ts +9 -1
  14. package/build/recommendation/registerPlan.js +8 -1
  15. package/build/recommendation/runVerifiers.js +6 -0
  16. package/build/recommendation/types.d.ts +58 -0
  17. package/build/recommendation/verifierContracts.d.ts +108 -13
  18. package/build/recommendation/verifierContracts.js +147 -29
  19. package/build/recommendation/verifiers/coverage.d.ts +10 -0
  20. package/build/recommendation/verifiers/coverage.js +144 -22
  21. package/build/recommendation/verifiers/defects.d.ts +9 -0
  22. package/build/recommendation/verifiers/defects.js +117 -0
  23. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +22 -0
  24. package/build/recommendation/verifiers/deliveredMatchesPlan.js +43 -0
  25. package/build/recommendation/verifiers/existingCoverage.js +53 -0
  26. package/build/recommendation/verifiers/expectedValueSourced.d.ts +14 -0
  27. package/build/recommendation/verifiers/expectedValueSourced.js +246 -0
  28. package/build/recommendation/verifiers/issueTraceability.d.ts +52 -0
  29. package/build/recommendation/verifiers/issueTraceability.js +197 -0
  30. package/build/recommendation/verifiers/requirementSourced.d.ts +2 -0
  31. package/build/recommendation/verifiers/requirementSourced.js +168 -0
  32. package/build/services/TestGenerationService.js +3 -1
  33. package/build/tools/code-refactor/codeReuseTool.js +1 -1
  34. package/build/tools/code-refactor/reuse-outcome.d.ts +1 -1
  35. package/build/tools/code-refactor/reuse-state.d.ts +85 -7
  36. package/build/tools/code-refactor/reuse-state.js +239 -34
  37. package/build/tools/code-refactor/utils-verify-gates.d.ts +5 -0
  38. package/build/tools/code-refactor/utils-verify-gates.js +103 -11
  39. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -1
  40. package/build/tools/submitReportTool.js +276 -39
  41. package/build/tools/test-management/actionsTool.js +5 -0
  42. package/build/tools/test-management/analyzeChangesTool.d.ts +53 -0
  43. package/build/tools/test-management/analyzeChangesTool.js +55 -2
  44. package/build/tools/test-management/registerTestPlanTool.d.ts +32 -17
  45. package/build/tools/test-management/registerTestPlanTool.js +105 -16
  46. package/build/types/ReuseOutcome.d.ts +73 -7
  47. package/build/types/TestAnalysis.d.ts +6 -0
  48. package/build/types/TestbotReport.d.ts +22 -4
  49. package/build/utils/AnalysisStateManager.d.ts +7 -1
  50. package/build/utils/AnalysisStateManager.js +5 -1
  51. package/build/utils/assertion-verify/api-shared-lints.js +118 -11
  52. package/build/utils/assertion-verify/format.js +2 -2
  53. package/build/utils/assertion-verify/helper-imports.d.ts +7 -0
  54. package/build/utils/assertion-verify/helper-imports.js +119 -27
  55. package/build/utils/assertion-verify/lint-types.d.ts +31 -2
  56. package/build/utils/assertion-verify/lint-types.js +66 -0
  57. package/build/utils/assertion-verify/metrics.d.ts +13 -0
  58. package/build/utils/assertion-verify/metrics.js +16 -0
  59. package/build/utils/assertion-verify/verify.d.ts +11 -6
  60. package/build/utils/assertion-verify/verify.js +56 -15
  61. package/build/utils/canonicalJson.d.ts +11 -0
  62. package/build/utils/canonicalJson.js +17 -0
  63. package/build/utils/utils-verify/action-key.d.ts +27 -0
  64. package/build/utils/utils-verify/action-key.js +292 -0
  65. package/build/utils/utils-verify/allow.d.ts +8 -1
  66. package/build/utils/utils-verify/allow.js +14 -1
  67. package/build/utils/utils-verify/call-sites.d.ts +76 -8
  68. package/build/utils/utils-verify/call-sites.js +256 -70
  69. package/build/utils/utils-verify/language-spec.d.ts +3 -2
  70. package/build/utils/utils-verify/parse.d.ts +22 -3
  71. package/build/utils/utils-verify/parse.js +123 -52
  72. package/build/utils/utils-verify/verify.d.ts +33 -3
  73. package/build/utils/utils-verify/verify.js +126 -12
  74. package/build/utils/workspaceAuth.d.ts +59 -19
  75. package/build/utils/workspaceAuth.js +228 -31
  76. package/package.json +1 -1
  77. package/plugin/prompts/generate-tests/execution-plan.md +3 -3
  78. package/plugin/prompts/generate-tests/generation.md +1 -0
  79. package/plugin/prompts/plan-tests.md +41 -15
  80. package/plugin/prompts/testbot-task1.md +3 -9
  81. package/build/prompts/testbot/planDeclarations.d.ts +0 -6
  82. package/build/prompts/testbot/planDeclarations.js +0 -9
  83. package/plugin/prompts/declaring-a-plan.md +0 -20
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { SKYRAMP_UTILS_HEADER } from "../utils.js";
4
- import { parseUtilsFile } from "./parse.js";
4
+ import { blankCommentsAndStrings, isSingleLiteral, parseUtilsFile, } from "./parse.js";
5
5
  import { normalizePath } from "./language-spec.js";
6
6
  import { realpath } from "./locate.js";
7
7
  import { escapeRegExp } from "../regex.js";
@@ -12,9 +12,10 @@ export const CODEGEN_MARKER_RE = new RegExp(`${SKYRAMP_UTILS_HEADER.replace(/[.*
12
12
  const HEADER_SCAN_LINES = 5;
13
13
  /**
14
14
  * The Skyramp-generated tests in the spec's directory, each read once — THE sibling
15
- * walk, shared by the inline-call-site scan (which excludes the spec itself) and the
16
- * importer count (which includes it). Utils files carry no codegen version marker,
17
- * so the marker check alone excludes them. Fails open to [] on any read error.
15
+ * walk, shared by the two sibling call-site scans and the importer count (the inline
16
+ * scan excludes the spec itself; the other two include it). Utils files carry no
17
+ * codegen version marker, so the marker check alone excludes them. Fails open to []
18
+ * on any read error.
18
19
  */
19
20
  export async function generatedSiblings(testFile, spec, opts) {
20
21
  let dir;
@@ -55,21 +56,94 @@ export const REQUEST_CALL_RE = /\b(?:send_request|sendRequest)\s*\(/g;
55
56
  * utils helper already wraps — the reuse the prompt asks for and the agent does not
56
57
  * perform (measured 0/2 seeded eval runs with the rule as its own mandatory step).
57
58
  *
58
- * A call is INLINE when it sits in a test function, not in a module-level helper of
59
- * the sibling's own: a sibling that defines `create_order` locally is STEP 4b's
60
- * helper-vs-helper case, not a call site. Equivalence is `c3b9157b`'s: same method
61
- * and same normalised path, nothing else — literals are what the call's arguments
62
- * are for.
59
+ * A call inside a module-level helper of the sibling's own counts too, named by that
60
+ * helper (`localHelper`): a sibling that defines `create_order` locally beside a
61
+ * shared `create_order` is the shared helper not used, however it is wrapped. The
62
+ * first design left that to STEP 4b of the prompt (helper-vs-helper) and nothing
63
+ * detected it across spec files, so the copies a half-done extraction leaves behind
64
+ * — one spec rewired, the others still defining the helper — went silent the moment
65
+ * the route became covered. Equivalence is `c3b9157b`'s: same method and same
66
+ * normalised path, nothing else — literals are what the call's arguments are for.
63
67
  *
64
68
  * Advisory by design: it names a change to a PRE-EXISTING test, which the maintenance
65
69
  * flow owns, so it informs rather than gates. Never throws.
66
70
  */
67
71
  export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers, spec, preReadSiblings) {
68
- // route → helper name + the keyword set of the helper's own request call, so a
69
- // sibling call is reported only when its SHAPE matches too (same keywords once the
70
- // liftable ones are set aside) — criterion 2 of the API near-duplicate definition.
71
- // `fields` and `schemaChecked` carry the two things keyword shape cannot see; see
72
- // substitutable().
72
+ const byRoute = await helperRoutes(utilsFiles, spec);
73
+ if (byRoute.size === 0)
74
+ return [];
75
+ const siblings = await nonUtilsSiblings(testFile, utilsFiles, spec, preReadSiblings, false);
76
+ const self = await realpath(testFile);
77
+ const out = [];
78
+ for (const { file, content } of siblings) {
79
+ if (path.resolve(file) === self)
80
+ continue;
81
+ for (const c of requestCallsIn(content, spec)) {
82
+ const match = byRoute.get(c.key);
83
+ if (!match)
84
+ continue;
85
+ if (!sameShape(callShape(c.call), match.keys))
86
+ continue;
87
+ if (!substitutable(match, {
88
+ fields: requestFields(content, c.call, c.start),
89
+ query: queryKeys(c.call),
90
+ schemaChecked: hasSchemaCheck(content, c.line, c.line + SCHEMA_SCAN_LINES),
91
+ }))
92
+ continue;
93
+ out.push({
94
+ file,
95
+ line: c.line,
96
+ method: c.method,
97
+ path: c.path,
98
+ helper: match.helper,
99
+ utilsFile: match.utilsFile,
100
+ ...(c.localHelper ? { localHelper: c.localHelper } : {}),
101
+ });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ /**
107
+ * Request calls in the Skyramp-generated tests beside `testFile` — the delivered spec
108
+ * INCLUDED, since it is one of the copies — grouped by route and call shape, less the
109
+ * routes a utils helper covers. The same walk as findSiblingInlineCallSites keyed the
110
+ * other way: that scan starts from the helpers and finds a helper not USED; this one
111
+ * starts from the sites and finds a helper not CREATED. Every existing check takes a
112
+ * defined helper as one side of its comparison, so an operation never extracted was
113
+ * invisible to all of them (measured: four per-spec copies of the operation a PR was
114
+ * about, differing only in expected status, beside a module that shared two others).
115
+ *
116
+ * Two sites are one operation when they share method, normalised path and keyword
117
+ * shape — the API near-duplicate definition. Body fields and a schema check do not
118
+ * split a group: with no incumbent helper there is nothing to be substitutable FOR,
119
+ * a body difference is one more literal to lift, and a schema check stays in the spec
120
+ * after the helper call because the response variable is kept.
121
+ *
122
+ * A group must span two or more FILES: a spec repeating one request twice is a
123
+ * different question. Advisory by design — consolidation is a judgement. Never throws.
124
+ */
125
+ export async function findUnextractedDuplicates(testFile, utilsFiles, spec, preReadSiblings) {
126
+ const covered = new Set((await helperRoutes(utilsFiles, spec)).keys());
127
+ const siblings = await nonUtilsSiblings(testFile, utilsFiles, spec, preReadSiblings, true);
128
+ const groups = new Map();
129
+ for (const { file, content } of siblings) {
130
+ for (const c of requestCallsIn(content, spec)) {
131
+ if (covered.has(c.key))
132
+ continue;
133
+ const shape = [...callShape(c.call)].sort().join(",");
134
+ const k = `${c.key}\0${shape}`;
135
+ const group = groups.get(k) ?? {
136
+ method: c.method,
137
+ path: c.path,
138
+ sites: [],
139
+ };
140
+ group.sites.push({ file, line: c.line, localHelper: c.localHelper });
141
+ groups.set(k, group);
142
+ }
143
+ }
144
+ return [...groups.values()].filter((g) => new Set(g.sites.map((s) => s.file)).size >= 2);
145
+ }
146
+ async function helperRoutes(utilsFiles, spec) {
73
147
  const byRoute = new Map();
74
148
  for (const utilsFile of utilsFiles) {
75
149
  let content;
@@ -91,47 +165,56 @@ export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers,
91
165
  const call = callText(content, start + m[0].length - 1);
92
166
  byRoute.set(`${owner.method} ${owner.normalizedPath}`, {
93
167
  helper: owner.name,
94
- keys: shapeKeys(call),
168
+ utilsFile,
169
+ keys: callShape(call),
95
170
  fields: requestFields(content, call, start),
171
+ query: queryKeys(call),
96
172
  schemaChecked: hasSchemaCheck(content, owner.line, owner.endLine),
97
173
  });
98
174
  }
99
175
  }
100
- if (byRoute.size === 0)
101
- return [];
176
+ return byRoute;
177
+ }
178
+ /** The generated siblings less the utils files themselves (a utils file carries no
179
+ * codegen marker, so this is belt and braces). Pre-read siblings are used as given —
180
+ * the caller's walk decides whether the spec itself is among them. */
181
+ async function nonUtilsSiblings(testFile, utilsFiles, spec, preReadSiblings, includeSelf) {
102
182
  const utilsSet = new Set(await Promise.all(utilsFiles.map((f) => realpath(f))));
103
- const siblings = (preReadSiblings ??
104
- (await generatedSiblings(testFile, spec, { includeSelf: false }))).filter((sib) => !utilsSet.has(path.resolve(sib.file)));
105
- const self = await realpath(testFile);
183
+ return (preReadSiblings ??
184
+ (await generatedSiblings(testFile, spec, { includeSelf }))).filter((sib) => !utilsSet.has(path.resolve(sib.file)));
185
+ }
186
+ /** Every keyable request call in a generated test. Calls whose method or path cannot
187
+ * be read are skipped: they have no route to compare. Calls are located, and their
188
+ * parentheses balanced, on a comment/string-blanked copy (offsets preserved), so a
189
+ * commented-out or quoted request is never a site and a `)` inside a string never
190
+ * truncates the argument text, which is read from the original. */
191
+ function requestCallsIn(content, spec) {
192
+ const localHelpers = parseUtilsFile(content, spec).filter((h) => !/^test/i.test(h.name));
193
+ const scan = blankCommentsAndStrings(content, spec);
106
194
  const out = [];
107
- for (const { file, content } of siblings) {
108
- if (path.resolve(file) === self)
195
+ for (const m of scan.matchAll(REQUEST_CALL_RE)) {
196
+ const start = m.index ?? 0;
197
+ const line = content.slice(0, start).split("\n").length;
198
+ const open = start + m[0].length - 1;
199
+ const call = content.slice(open, callEnd(scan, open));
200
+ const method = spec.methodRe.exec(call)?.[1]?.toUpperCase();
201
+ const pm = spec.pathRe.exec(call);
202
+ // Keyed on ONE literal, as helpers are (parseUtilsFile): a concatenated path's
203
+ // first literal is not the operation — it would read as copies of a route that
204
+ // does not exist, or match a helper for the parent route.
205
+ if (!method || !pm || !isSingleLiteral(call, pm))
109
206
  continue;
110
- // Line ranges of the sibling's own non-test helpers: calls inside them are not inline.
111
- const localHelpers = parseUtilsFile(content, spec).filter((h) => !/^test/i.test(h.name));
112
- const inLocalHelper = (line) => localHelpers.some((h) => line >= h.line && line <= h.endLine);
113
- for (const m of content.matchAll(REQUEST_CALL_RE)) {
114
- const start = m.index ?? 0;
115
- const line = content.slice(0, start).split("\n").length;
116
- if (inLocalHelper(line))
117
- continue;
118
- const call = callText(content, start + m[0].length - 1);
119
- const method = spec.methodRe.exec(call)?.[1]?.toUpperCase();
120
- const p = spec.pathRe.exec(call)?.[1];
121
- if (!method || !p)
122
- continue;
123
- const match = byRoute.get(`${method} ${normalizePath(p)}`);
124
- if (!match)
125
- continue;
126
- if (!sameShape(shapeKeys(call), match.keys))
127
- continue;
128
- if (!substitutable(match, {
129
- fields: requestFields(content, call, start),
130
- schemaChecked: hasSchemaCheck(content, line, line + SCHEMA_SCAN_LINES),
131
- }))
132
- continue;
133
- out.push({ file, line, method, path: p, helper: match.helper });
134
- }
207
+ const p = pm[1];
208
+ const owner = localHelpers.find((h) => line >= h.line && line <= h.endLine);
209
+ out.push({
210
+ line,
211
+ start,
212
+ call,
213
+ method,
214
+ path: p,
215
+ key: `${method} ${normalizePath(p)}`,
216
+ localHelper: owner?.name,
217
+ });
135
218
  }
136
219
  return out;
137
220
  }
@@ -147,6 +230,10 @@ export async function findSiblingInlineCallSites(testFile, utilsFiles, _helpers,
147
230
  * `dataOverride` are liftable *keywords*, so their mere presence matched; their
148
231
  * contents never got compared. (ghost `pr-scheduled-publishing`: a scheduled-post
149
232
  * helper offered for a draft-post call, declined for exactly this reason.)
233
+ * - **Query keys the site does not send.** The same as fields, for the query: a
234
+ * helper sending `?page=` offered for a site sending `?status=` adds a parameter.
235
+ * Query keys were in neither the shape (quoted keys never matched) nor the fields
236
+ * (`query_params` is stripped there on purpose), so nothing told the two apart.
150
237
  * - **A schema-checked site.** When the site feeds its response to `check_schema`
151
238
  * against a per-file expected body and the helper wraps no such check, replacing
152
239
  * the block either drops the contract assertion or redesigns the file. That is a
@@ -160,6 +247,11 @@ function substitutable(helper, site) {
160
247
  for (const f of helper.fields)
161
248
  if (!site.fields.has(f))
162
249
  return false;
250
+ // The body rule, applied to the query: a `?page=` helper offered for a `?status=`
251
+ // site would add a parameter to the sibling's request. See queryKeys().
252
+ for (const q of helper.query)
253
+ if (!site.query.has(q))
254
+ return false;
163
255
  return helper.schemaChecked === site.schemaChecked;
164
256
  }
165
257
  /** Lines after a call scanned for its schema assertion — the generated shape puts it
@@ -208,9 +300,18 @@ export function requestFields(content, call, callAt) {
208
300
  // TS shorthand inside an override object — `dataOverride: { status, title }` names
209
301
  // the same fields as `{ "status": status }` but carries neither quotes nor a colon,
210
302
  // so the pattern above cannot see it and the field would compare as absent.
211
- for (const region of overrideObjects(body))
212
- for (const m of region.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(?=[,}])/g))
303
+ for (const region of keyedObjects(body, OVERRIDE_OBJECT_RE))
304
+ for (const m of region.matchAll(SHORTHAND_KEY_RE))
213
305
  add(m[1]);
306
+ // An inline TS body literal — `body: { title: t, status: "draft", tags }` — carries
307
+ // UNQUOTED keys (and shorthand), which the quoted pattern above cannot see either.
308
+ // With no fields read, `substitutable` had nothing to compare and offered a helper
309
+ // whose substitution would ADD a field to the sibling's request — the very case it
310
+ // exists to refuse. Python passes `body=IDENT` (read below) or a quoted dict, so
311
+ // nothing changes there.
312
+ for (const region of keyedObjects(body, BODY_OBJECT_RE))
313
+ for (const k of literalKeys(region))
314
+ add(k);
214
315
  // `body=IDENT` / `body: IDENT` → the identifier's assignment elsewhere in the file.
215
316
  const ref = /\bbody\s*[=:]\s*([A-Za-z_$][\w$]*)/.exec(call);
216
317
  if (ref) {
@@ -253,26 +354,108 @@ export function requestFields(content, call, callAt) {
253
354
  }
254
355
  return out;
255
356
  }
256
- /** The `{…}` text of each `data_override` / `dataOverride` argument in a call. */
257
- function overrideObjects(call) {
357
+ const OVERRIDE_OBJECT_RE = /\b(?:data_override|dataOverride)\s*[=:]\s*\{/g;
358
+ const BODY_OBJECT_RE = /\b(?:body|data)\s*[=:]\s*\{/g;
359
+ const QUERY_OBJECT_RE = /\b(?:query_params|queryParams)\s*[=:]\s*\{/g;
360
+ /** A bare identifier between `{`/`,` and `,`/`}` — TS object shorthand. */
361
+ const SHORTHAND_KEY_RE = /(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*(?=[,}])/g;
362
+ /** One quoted string, any of the three quote kinds, escapes honoured. */
363
+ const STRING_LITERAL_RE = /(["'`])(?:\\.|(?!\1)[^\\])*\1/g;
364
+ /** The key names written in one `{…}` object literal: quoted (`"page":`, either
365
+ * language), unquoted (`page:`, TS) and shorthand (`page,`, TS). The unquoted and
366
+ * shorthand passes run with string CONTENTS blanked, so `q: "a, b: c"` names one
367
+ * key, not two. Nested keys are included; callers decide leaf semantics. */
368
+ function literalKeys(region) {
369
+ const out = [];
370
+ for (const m of region.matchAll(/["'`]([A-Za-z_][\w.]*)["'`]\s*:/g))
371
+ out.push(m[1]);
372
+ const code = blankStringContents(region);
373
+ for (const m of code.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*:(?!=)/g))
374
+ out.push(m[1]);
375
+ for (const m of code.matchAll(SHORTHAND_KEY_RE))
376
+ out.push(m[1]);
377
+ return out;
378
+ }
379
+ /**
380
+ * The query-parameter NAMES a request call sends (`query_params={"page": 1}` /
381
+ * `queryParams: { page }`), or the empty set. Kept apart from requestFields, which
382
+ * strips `query_params` on purpose because it is shared with the retrofit gate and a
383
+ * query key is not a body field; here the question is the advisory's own — whether a
384
+ * helper can stand in for a site without ADDING a query parameter — and the rule is
385
+ * the body rule: every helper key must be present at the site.
386
+ */
387
+ export function queryKeys(call) {
388
+ const out = new Set();
389
+ for (const region of keyedObjects(call, QUERY_OBJECT_RE))
390
+ for (const k of literalKeys(region))
391
+ out.add(k);
392
+ return out;
393
+ }
394
+ /** The `{…}` text of each argument in a call whose opener `keyRe` matches (a
395
+ * `KEY: {` / `KEY={` pattern ending at the brace). */
396
+ function keyedObjects(call, keyRe) {
397
+ // Boundaries come from the string-blanked copy (offsets preserved): a `{` inside
398
+ // a value (`q: "{"`) is payload, and counting it would run the object into the
399
+ // next argument. The text itself is taken from the original.
400
+ const scan = blankStringContents(call);
258
401
  const out = [];
259
- for (const m of call.matchAll(/\b(?:data_override|dataOverride)\s*[=:]\s*\{/g)) {
402
+ for (const m of scan.matchAll(keyRe)) {
260
403
  const open = (m.index ?? 0) + m[0].length - 1;
261
- let depth = 0;
262
- for (let i = open; i < call.length; i++) {
263
- if (call[i] === "{")
264
- depth++;
265
- else if (call[i] === "}") {
266
- depth--;
267
- if (depth === 0) {
268
- out.push(call.slice(open, i + 1));
269
- break;
270
- }
271
- }
272
- }
404
+ out.push(call.slice(open, braceEnd(scan, open)));
405
+ }
406
+ return out;
407
+ }
408
+ /** `text` with the CONTENTS of every string literal replaced by spaces, quotes kept,
409
+ * length preserved — so offsets found on the copy index into the original. */
410
+ function blankStringContents(text) {
411
+ return text.replace(STRING_LITERAL_RE, (q) => q[0] + " ".repeat(Math.max(0, q.length - 2)) + q[0]);
412
+ }
413
+ /** The call shape both sibling scans compare: shapeKeys of the call with the interior
414
+ * of every keyed `{…}` value blanked, so the keys INSIDE a body, path-params or
415
+ * query-params object never read as shape. shapeKeys sees every `name:` in the text,
416
+ * and an unquoted TS object key (`body: { discountPercent: 10 }`) is one: a helper
417
+ * inlining `body: { title }` never matched a site whose body added a field, and two
418
+ * copies whose bodies named different fields were two operations — in both scans the
419
+ * fields are the literals to lift, and `substitutable` is what judges them. Python
420
+ * quoted dict keys (`{"order_id": id}`) never matched the identifier pattern, so
421
+ * this is a TS/JS-only change. */
422
+ export function callShape(call) {
423
+ return shapeKeys(withoutKeyedLiterals(call));
424
+ }
425
+ /** `call` with the interior of every keyed `{…}` value blanked, offsets kept. */
426
+ function withoutKeyedLiterals(call) {
427
+ // Balanced on the string-blanked copy (see keyedObjects); the copy and the output
428
+ // are the same length, so one offset serves both.
429
+ let scan = blankStringContents(call);
430
+ let out = call;
431
+ const re = /\b[A-Za-z_$][\w$]*\s*[=:]\s*\{/g;
432
+ let m;
433
+ while ((m = re.exec(scan)) !== null) {
434
+ const open = m.index + m[0].length - 1;
435
+ const end = braceEnd(scan, open);
436
+ const blank = (t) => t.slice(0, open + 1) +
437
+ " ".repeat(Math.max(0, end - open - 2)) +
438
+ t.slice(Math.max(open + 1, end - 1));
439
+ out = blank(out);
440
+ scan = blank(scan);
441
+ re.lastIndex = end;
273
442
  }
274
443
  return out;
275
444
  }
445
+ /** Index just past the `}` balancing the `{` at `open` (the text's end if unbalanced). */
446
+ function braceEnd(text, open) {
447
+ let depth = 0;
448
+ for (let i = open; i < text.length; i++) {
449
+ if (text[i] === "{")
450
+ depth++;
451
+ else if (text[i] === "}") {
452
+ depth--;
453
+ if (depth === 0)
454
+ return i + 1;
455
+ }
456
+ }
457
+ return text.length;
458
+ }
276
459
  /** Keywords whose presence differs legitimately between two versions of one request:
277
460
  * the literals the merge rule lifts to parameters. Everything else is call shape. */
278
461
  const LIFTABLE_KEYS = new Set([
@@ -305,16 +488,19 @@ function sameShape(a, b) {
305
488
  }
306
489
  /** Text of the parenthesised argument list starting at the `(` at `open`. */
307
490
  export function callText(content, open) {
491
+ return content.slice(open, callEnd(content, open));
492
+ }
493
+ /** Index just past the `)` balancing the `(` at `open` (the text's end if unbalanced). */
494
+ function callEnd(text, open) {
308
495
  let depth = 0;
309
- for (let i = open; i < content.length; i++) {
310
- const ch = content[i];
311
- if (ch === "(")
496
+ for (let i = open; i < text.length; i++) {
497
+ if (text[i] === "(")
312
498
  depth++;
313
- else if (ch === ")") {
499
+ else if (text[i] === ")") {
314
500
  depth--;
315
501
  if (depth === 0)
316
- return content.slice(open, i + 1);
502
+ return i + 1;
317
503
  }
318
504
  }
319
- return content.slice(open);
505
+ return text.length;
320
506
  }
@@ -46,8 +46,9 @@ export declare const UTILS_LANGUAGE_SPECS: Record<UtilsLanguage, UtilsLanguageSp
46
46
  * split the modularization and reuse prompts make (API_HELPERS / BROWSER_HELPERS):
47
47
  * - `api` (integration, contract, load …): one SDK request; equivalence is
48
48
  * method+path; the only assertion is the status code.
49
- * - `browser` (ui, e2e): a Playwright action sequence; no route to key duplicates
50
- * on, so the duplicate check is inert; the only assertions are structural waits.
49
+ * - `browser` (ui, e2e): a Playwright action sequence; equivalence is the action
50
+ * sequence itself (verb + selector per step, see actionKeyOf) and is advised,
51
+ * not enforced; the only assertions are structural waits.
51
52
  */
52
53
  export type HelperFamily = "api" | "browser";
53
54
  export interface HelperFamilySpec {
@@ -25,10 +25,26 @@ export interface UtilsHelper {
25
25
  /** TypeScript only: signature parameters with no type annotation (`page`, not
26
26
  * `page: Page`). Empty for other languages. */
27
27
  untypedParams: string[];
28
+ /** Declared parameter count — defaulted and rest parameters included. Two
29
+ * definitions of one name with different arities are the collision a caller
30
+ * written against either signature hits. `undefined` when no signature could
31
+ * be read. */
32
+ arity?: number;
33
+ /** The helper's Playwright action sequence — the duplicate key of a browser
34
+ * helper, where an API helper has method+path. See actionKeyOf. Read for
35
+ * TypeScript, JavaScript and Python (through the JavaScript spelling); absent
36
+ * for a helper with no readable action. */
37
+ actionKey?: string;
28
38
  }
29
- /** The parameter names in a TS signature that carry no `: type`. Reads the signature
30
- * from the definition line to its `{` (Prettier wraps long signatures), takes the
31
- * first balanced `(...)`, and splits it on top-level commas. */
39
+ /** The declared parameters of a signature, raw text trimmed, empty entries dropped
40
+ * (a trailing comma). Reads the signature from the definition line to its body
41
+ * opener (Prettier wraps long signatures), takes the first balanced `(...)`, and
42
+ * splits it on top-level commas — so a comma inside a default value, a generic, or
43
+ * a binding pattern never splits. `undefined` when the text holds no readable
44
+ * parameter list. Language-neutral: both a TS `{` and a Python `:` terminator lie
45
+ * outside the parentheses this reads. */
46
+ export declare function signatureParams(sigLines: string[]): string[] | undefined;
47
+ /** The parameter names in a TS signature that carry no `: type`. */
32
48
  export declare function untypedParamsOf(sigLines: string[]): string[];
33
49
  /**
34
50
  * Structured extraction of the module-level helpers in a utils file.
@@ -41,6 +57,9 @@ export declare function untypedParamsOf(sigLines: string[]): string[];
41
57
  * the definition returns to zero.
42
58
  */
43
59
  export declare function parseUtilsFile(content: string, spec: UtilsLanguageSpec): UtilsHelper[];
60
+ /** True when the `path` value is a single string literal ending the argument — a
61
+ * concatenation (`"/api/orders/" + str(id) + "/items"`) has no route this can name. */
62
+ export declare function isSingleLiteral(line: string, m: RegExpExecArray): boolean;
44
63
  /** Comments and string CONTENTS blanked (quotes kept, line count preserved). Python
45
64
  * comments are `#…`; TS/JS go through the shared stripper. */
46
65
  /** String CONTENTS blanked (quotes kept, comments kept, line count preserved). */