@skyramp/mcp 0.3.6 → 0.3.7

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 (89) hide show
  1. package/build/prompts/code-reuse.js +17 -2
  2. package/build/prompts/enhance-assertions/sharedAssertionRules.js +1 -1
  3. package/build/prompts/modularization/ui-test-modularization.js +9 -6
  4. package/build/prompts/pom-aware-code-reuse.js +1 -1
  5. package/build/prompts/shared-helper-policy.js +5 -5
  6. package/build/services/TestGenerationService.js +15 -1
  7. package/build/tools/code-refactor/assertion-state.d.ts +91 -0
  8. package/build/tools/code-refactor/assertion-state.js +375 -0
  9. package/build/tools/code-refactor/codeReuseTool.js +6 -4
  10. package/build/tools/code-refactor/enhanceAssertionsTool.js +73 -18
  11. package/build/tools/code-refactor/retrofit-state.d.ts +53 -0
  12. package/build/tools/code-refactor/retrofit-state.js +162 -0
  13. package/build/tools/code-refactor/reuse-outcome.d.ts +7 -0
  14. package/build/tools/code-refactor/reuse-state.d.ts +9 -0
  15. package/build/tools/code-refactor/reuse-state.js +42 -4
  16. package/build/tools/code-refactor/utils-verify-gates.js +69 -15
  17. package/build/tools/executeSkyrampTestTool.js +19 -14
  18. package/build/tools/submitReportTool.js +81 -8
  19. package/build/types/AssertionOutcome.d.ts +68 -0
  20. package/build/types/AssertionOutcome.js +1 -0
  21. package/build/types/ReuseOutcome.d.ts +16 -0
  22. package/build/types/TestTypes.d.ts +4 -0
  23. package/build/types/TestTypes.js +8 -0
  24. package/build/types/TestbotReport.d.ts +13 -0
  25. package/build/types/index.d.ts +1 -1
  26. package/build/utils/AnalysisStateManager.d.ts +20 -7
  27. package/build/utils/assertion-verify/api-shared-lints.d.ts +5 -0
  28. package/build/utils/assertion-verify/api-shared-lints.js +315 -0
  29. package/build/utils/assertion-verify/contract-lints.d.ts +3 -0
  30. package/build/utils/assertion-verify/contract-lints.js +87 -0
  31. package/build/utils/assertion-verify/format.d.ts +5 -0
  32. package/build/utils/assertion-verify/format.js +65 -0
  33. package/build/utils/assertion-verify/helper-imports.d.ts +6 -0
  34. package/build/utils/assertion-verify/helper-imports.js +178 -0
  35. package/build/utils/assertion-verify/index.d.ts +3 -0
  36. package/build/utils/assertion-verify/index.js +7 -0
  37. package/build/utils/assertion-verify/integration-lints.d.ts +3 -0
  38. package/build/utils/assertion-verify/integration-lints.js +36 -0
  39. package/build/utils/assertion-verify/js-regex-blank.d.ts +1 -0
  40. package/build/utils/assertion-verify/js-regex-blank.js +153 -0
  41. package/build/utils/assertion-verify/lint-types.d.ts +33 -0
  42. package/build/utils/assertion-verify/lint-types.js +57 -0
  43. package/build/utils/assertion-verify/marker.d.ts +27 -0
  44. package/build/utils/assertion-verify/marker.js +61 -0
  45. package/build/utils/assertion-verify/metrics.d.ts +30 -0
  46. package/build/utils/assertion-verify/metrics.js +341 -0
  47. package/build/utils/assertion-verify/python-strip.d.ts +6 -0
  48. package/build/utils/assertion-verify/python-strip.js +75 -0
  49. package/build/utils/assertion-verify/strip-dispatch.d.ts +19 -0
  50. package/build/utils/assertion-verify/strip-dispatch.js +42 -0
  51. package/build/utils/assertion-verify/ui-lints.d.ts +8 -0
  52. package/build/utils/assertion-verify/ui-lints.js +244 -0
  53. package/build/utils/assertion-verify/verify.d.ts +61 -0
  54. package/build/utils/assertion-verify/verify.js +215 -0
  55. package/build/utils/featureFlags.d.ts +12 -2
  56. package/build/utils/featureFlags.js +33 -3
  57. package/build/utils/reportVerification.d.ts +4 -0
  58. package/build/utils/reportVerification.js +32 -4
  59. package/build/utils/utils-verify/allow.d.ts +22 -4
  60. package/build/utils/utils-verify/allow.js +8 -2
  61. package/build/utils/utils-verify/call-sites.d.ts +40 -1
  62. package/build/utils/utils-verify/call-sites.js +196 -30
  63. package/build/utils/utils-verify/importers.d.ts +31 -0
  64. package/build/utils/utils-verify/importers.js +78 -0
  65. package/build/utils/utils-verify/index.d.ts +1 -0
  66. package/build/utils/utils-verify/index.js +1 -0
  67. package/build/utils/utils-verify/language-spec.d.ts +13 -2
  68. package/build/utils/utils-verify/language-spec.js +12 -2
  69. package/build/utils/utils-verify/parse.d.ts +31 -3
  70. package/build/utils/utils-verify/parse.js +190 -9
  71. package/build/utils/utils-verify/retrofit-equivalence.d.ts +43 -0
  72. package/build/utils/utils-verify/retrofit-equivalence.js +218 -0
  73. package/build/utils/utils-verify/stage.d.ts +6 -0
  74. package/build/utils/utils-verify/stage.js +12 -2
  75. package/build/utils/utils-verify/verify.d.ts +54 -4
  76. package/build/utils/utils-verify/verify.js +224 -12
  77. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  78. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-CZfp96qZ.js → codeMirrorModule-LNgEKtdV.js} +1 -1
  79. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-gpLo02E0.js → defaultSettingsView-Bwr1eMKC.js} +135 -135
  80. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.Bq1r1URj.js → index.-Id052Lr.js} +1 -1
  81. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  82. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.VEfqi1qN.js → uiMode.BPopbasy.js} +1 -1
  83. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  84. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  86. package/node_modules/playwright/package.json +1 -1
  87. package/package.json +2 -2
  88. package/build/tools/code-refactor/enhance-state.d.ts +0 -49
  89. package/build/tools/code-refactor/enhance-state.js +0 -109
@@ -125,20 +125,20 @@ export const HELPER_FAMILIES = {
125
125
  api: {
126
126
  family: "api",
127
127
  routeDuplicates: true,
128
- allowedAssertion: (spec) => spec.allowedAssertion,
129
128
  isAllowed: (spec, expr) => spec.allowedAssertion.test(expr),
130
129
  assertionRule: "a shared API helper asserts only the status code; move this assertion to the test body, applied to the response the helper returns",
130
+ fixRule: "a helper that wraps an SDK request asserts only the status code — one helper per method+path, extended with defaulted parameters, named method + resource",
131
131
  },
132
132
  browser: {
133
133
  family: "browser",
134
134
  routeDuplicates: false,
135
- allowedAssertion: (spec) => spec.allowedBrowserAssertion,
136
135
  // A structural wait on a locator that carries a scenario LITERAL
137
136
  // (`getByText("Total: $899.98")`) is a value assertion in disguise: digits or
138
137
  // currency in any string inside the expect(...) arguments disqualify it.
139
138
  isAllowed: (spec, expr) => spec.allowedBrowserAssertion.test(expr) &&
140
139
  !SCENARIO_LITERAL_RE.test(expectArgs(expr)),
141
140
  assertionRule: "a shared browser helper holds actions and structural waits only (toBeVisible, toBeEnabled, …); move this value assertion to the test body immediately after the helper call, selector verbatim",
141
+ fixRule: "every other helper holds actions and structural waits only, named for the user intent",
142
142
  },
143
143
  };
144
144
  /** The helper family a test type's shared helpers follow. Unlike `isBrowserTestType`,
@@ -149,6 +149,16 @@ export function helperFamilyFor(testType) {
149
149
  const t = (testType ?? "").toLowerCase();
150
150
  return HELPER_FAMILIES[t === "ui" || t === "e2e" ? "browser" : "api"];
151
151
  }
152
+ /** The family ONE helper is judged by. A helper that issues an SDK request follows
153
+ * the api rules whatever the test type: UI flows routinely keep a sign-in or seed
154
+ * request beside their browser helpers, and judging that request by the browser
155
+ * rules made its status assertion a false violation. Keyed on the request itself,
156
+ * not the duplicate key — a two-request sign-in, a concatenated path, and a
157
+ * parameterised route are all API helpers with no keyable route. A helper with no
158
+ * request falls back to the test type's family. */
159
+ export function helperFamilyOf(helper, fallback) {
160
+ return helper.wrapsRequest ? HELPER_FAMILIES.api : fallback;
161
+ }
152
162
  /** The spec for a language string as the tools receive it, or `undefined` for a
153
163
  * language this verifier does not cover (Java: utils reuse is disabled for it). */
154
164
  export function utilsSpecFor(language) {
@@ -2,8 +2,6 @@ import { type UtilsLanguageSpec } from "./language-spec.js";
2
2
  export interface HelperAssertion {
3
3
  line: number;
4
4
  text: string;
5
- /** True for the one permitted shape — a status-code comparison. */
6
- allowed: boolean;
7
5
  }
8
6
  export interface UtilsHelper {
9
7
  name: string;
@@ -15,8 +13,23 @@ export interface UtilsHelper {
15
13
  path?: string;
16
14
  /** `path` with every path parameter collapsed to `{}`; the duplicate key with `method`. */
17
15
  normalizedPath?: string;
16
+ /** The helper issues at least one SDK request — what makes it an API helper,
17
+ * whatever the test type, and whether or not it has a keyable single route
18
+ * (a two-request sign-in, a concatenated path, and a parameterised route all
19
+ * wrap requests without one). */
20
+ wrapsRequest: boolean;
21
+ /** The body holds a live `page.on('pageerror', …)` registration. A guard moved
22
+ * into a shared helper is still delivered — see hasPageErrorGuard. */
23
+ hasGuard: boolean;
18
24
  assertions: HelperAssertion[];
25
+ /** TypeScript only: signature parameters with no type annotation (`page`, not
26
+ * `page: Page`). Empty for other languages. */
27
+ untypedParams: string[];
19
28
  }
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. */
32
+ export declare function untypedParamsOf(sigLines: string[]): string[];
20
33
  /**
21
34
  * Structured extraction of the module-level helpers in a utils file.
22
35
  *
@@ -27,8 +40,23 @@ export interface UtilsHelper {
27
40
  * next non-blank column-0 line; TS/JS bodies end when the brace depth opened by
28
41
  * the definition returns to zero.
29
42
  */
30
- export declare function parseUtilsFile(content: string, spec: UtilsLanguageSpec, allowedAssertion?: RegExp | ((expr: string) => boolean)): UtilsHelper[];
43
+ export declare function parseUtilsFile(content: string, spec: UtilsLanguageSpec): UtilsHelper[];
31
44
  /** Comments and string CONTENTS blanked (quotes kept, line count preserved). Python
32
45
  * comments are `#…`; TS/JS go through the shared stripper. */
33
46
  /** String CONTENTS blanked (quotes kept, comments kept, line count preserved). */
34
47
  export declare function blankStrings(src: string, spec: UtilsLanguageSpec): string;
48
+ /** Assertions in a source file: lines (Python) / occurrences (TS) matching the
49
+ * language's assertion pattern, with comments and string contents blanked so a
50
+ * commented-out or quoted `expect(` never counts. */
51
+ export declare function countAssertions(content: string, spec: UtilsLanguageSpec): number;
52
+ /**
53
+ * A live `page.on('pageerror', …)` registration — the guard the enhancer adds and
54
+ * modularization must not drop. Exactly `pageerror`: a `console` listener is not an
55
+ * error collector, and accepting it would let an unrelated listener mask the removal.
56
+ * The call is located on a copy with comments AND string contents blanked (so a
57
+ * commented-out or quoted example never satisfies the check) and its argument is read
58
+ * from the comment-blanked copy, where the `'pageerror'` literal is still legible.
59
+ * Both copies preserve offsets, so a Prettier-wrapped registration matches too.
60
+ */
61
+ export declare function hasPageErrorGuard(content: string, spec: UtilsLanguageSpec): boolean;
62
+ export declare function blankCommentsAndStrings(src: string, spec: UtilsLanguageSpec): string;
@@ -1,5 +1,96 @@
1
1
  import { normalizePath } from "./language-spec.js";
2
- import { stripCommentsAndStrings, stripStrings } from "../pom-scope/strip.js";
2
+ import { stripComments, stripCommentsAndStrings, stripStrings, } from "../pom-scope/strip.js";
3
+ /** The parameter names in a TS signature that carry no `: type`. Reads the signature
4
+ * from the definition line to its `{` (Prettier wraps long signatures), takes the
5
+ * first balanced `(...)`, and splits it on top-level commas. */
6
+ export function untypedParamsOf(sigLines) {
7
+ const sig = sigLines.join(" ");
8
+ const open = sig.indexOf("(");
9
+ if (open === -1) {
10
+ // Bare-arrow form: `export const open = async page => …` — one parameter,
11
+ // untyped by construction (a typed single parameter requires parentheses).
12
+ const bare = /(?:^|=)\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/.exec(sig);
13
+ return bare ? [bare[1]] : [];
14
+ }
15
+ // `>` closes a nesting level only when it is not the arrow token's tail:
16
+ // `(callback: (x: string) => void, page)` must not close at `=>`.
17
+ const closes = (ch, prev) => ch === ")" || ch === "]" || ch === "}" || (ch === ">" && prev !== "=");
18
+ let depth = 0;
19
+ let close = -1;
20
+ for (let i = open; i < sig.length; i++) {
21
+ const ch = sig[i];
22
+ if ("([{<".includes(ch))
23
+ depth++;
24
+ else if (closes(ch, sig[i - 1] ?? "")) {
25
+ depth--;
26
+ if (depth === 0) {
27
+ close = i;
28
+ break;
29
+ }
30
+ }
31
+ }
32
+ if (close === -1)
33
+ return [];
34
+ const params = [];
35
+ let cur = "";
36
+ depth = 0;
37
+ for (let i = open + 1; i < close; i++) {
38
+ const ch = sig[i];
39
+ if ("([{<".includes(ch))
40
+ depth++;
41
+ else if (closes(ch, sig[i - 1] ?? ""))
42
+ depth--;
43
+ if (ch === "," && depth === 0) {
44
+ params.push(cur);
45
+ cur = "";
46
+ }
47
+ else
48
+ cur += ch;
49
+ }
50
+ params.push(cur);
51
+ const out = [];
52
+ for (const raw of params) {
53
+ const param = raw.trim().replace(/^\.\.\./, "");
54
+ if (!param)
55
+ continue;
56
+ // A default value means TypeScript infers the type — not a finding, even
57
+ // unannotated (`expectedStatus = 200`). `=>` inside a function-type
58
+ // annotation is not a default.
59
+ if (/=(?!>)/.test(param.replace(/\([^)]*\)/g, "")))
60
+ continue;
61
+ // A binding pattern's inner colons are renames, not annotations:
62
+ // `{ page: browserPage }` is implicitly any unless a `:` follows the
63
+ // complete pattern.
64
+ if (param.startsWith("{") || param.startsWith("[")) {
65
+ let d = 0;
66
+ let after = -1;
67
+ for (let i = 0; i < param.length; i++) {
68
+ if ("([{<".includes(param[i]))
69
+ d++;
70
+ else if (closes(param[i], param[i - 1] ?? "")) {
71
+ d--;
72
+ if (d === 0) {
73
+ after = i + 1;
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ if (after === -1 || !param.slice(after).trimStart().startsWith(":"))
79
+ out.push(param.replace(/\s+/g, " "));
80
+ continue;
81
+ }
82
+ if (param.includes(":")) {
83
+ // `page: any` compiles clean under noImplicitAny — the customer's compiler
84
+ // will NEVER report it, which makes it the one case this advisory can catch
85
+ // that tsc cannot. It is also the exact legacy convention SKYR-3324 wrote.
86
+ if (/:\s*any\s*$/.test(param))
87
+ out.push(param.split(":")[0].trim().replace(/\?$/, "") + ": any");
88
+ continue;
89
+ }
90
+ out.push(param.replace(/\?$/, ""));
91
+ }
92
+ return out;
93
+ }
3
94
  /**
4
95
  * Structured extraction of the module-level helpers in a utils file.
5
96
  *
@@ -10,10 +101,7 @@ import { stripCommentsAndStrings, stripStrings } from "../pom-scope/strip.js";
10
101
  * next non-blank column-0 line; TS/JS bodies end when the brace depth opened by
11
102
  * the definition returns to zero.
12
103
  */
13
- export function parseUtilsFile(content, spec, allowedAssertion = spec.allowedAssertion) {
14
- const isAllowed = typeof allowedAssertion === "function"
15
- ? allowedAssertion
16
- : (expr) => allowedAssertion.test(expr);
104
+ export function parseUtilsFile(content, spec) {
17
105
  const lines = content.split("\n");
18
106
  const helpers = [];
19
107
  let i = 0;
@@ -37,7 +125,14 @@ export function parseUtilsFile(content, spec, allowedAssertion = spec.allowedAss
37
125
  name,
38
126
  line: start + 1,
39
127
  endLine: Math.max(end, start + 1),
128
+ wrapsRequest: false,
129
+ hasGuard: hasPageErrorGuard(body.join("\n"), spec),
40
130
  assertions: [],
131
+ untypedParams: spec.language === "typescript"
132
+ ? // The blanked copy: a comment inside a wrapped signature must not
133
+ // reach the agent as a "parameter".
134
+ untypedParamsOf(signatureLines(scan))
135
+ : [],
41
136
  };
42
137
  let requests = 0;
43
138
  for (let k = 0; k < body.length; k++) {
@@ -77,13 +172,13 @@ export function parseUtilsFile(content, spec, allowedAssertion = spec.allowedAss
77
172
  helper.assertions.push({
78
173
  line: start + k + 1,
79
174
  text: original,
80
- allowed: isAllowed(original),
81
175
  });
82
176
  k = last;
83
177
  }
84
178
  }
85
179
  // A helper wrapping two requests is a sequence, not "one SDK call": it has no single
86
180
  // route to be a duplicate of, and the shared-helper rules do not apply to it.
181
+ helper.wrapsRequest = requests > 0;
87
182
  if (requests > 1) {
88
183
  helper.method = undefined;
89
184
  helper.path = undefined;
@@ -122,16 +217,88 @@ export function blankStrings(src, spec) {
122
217
  .map((line) => line.replace(/(["'])(?:\\.|(?!\1).)*\1/g, (q) => q[0] + " ".repeat(Math.max(0, q.length - 2)) + q[0]))
123
218
  .join("\n");
124
219
  }
125
- function blankCommentsAndStrings(src, spec) {
220
+ /** Assertions in a source file: lines (Python) / occurrences (TS) matching the
221
+ * language's assertion pattern, with comments and string contents blanked so a
222
+ * commented-out or quoted `expect(` never counts. */
223
+ export function countAssertions(content, spec) {
224
+ const scan = blankCommentsAndStrings(content, spec);
225
+ if (spec.language === "python")
226
+ return scan.split("\n").filter((l) => spec.assertionRe.test(l)).length;
227
+ return (scan.match(new RegExp(spec.assertionRe.source, "g")) ?? []).length;
228
+ }
229
+ /**
230
+ * A live `page.on('pageerror', …)` registration — the guard the enhancer adds and
231
+ * modularization must not drop. Exactly `pageerror`: a `console` listener is not an
232
+ * error collector, and accepting it would let an unrelated listener mask the removal.
233
+ * The call is located on a copy with comments AND string contents blanked (so a
234
+ * commented-out or quoted example never satisfies the check) and its argument is read
235
+ * from the comment-blanked copy, where the `'pageerror'` literal is still legible.
236
+ * Both copies preserve offsets, so a Prettier-wrapped registration matches too.
237
+ */
238
+ export function hasPageErrorGuard(content, spec) {
239
+ const noComments = blankComments(content, spec);
240
+ const code = blankStrings(noComments, spec);
241
+ for (const m of code.matchAll(/\bpage\.on\(/g)) {
242
+ const at = (m.index ?? 0) + m[0].length;
243
+ if (/^\s*["']pageerror["']/.test(noComments.slice(at)))
244
+ return true;
245
+ }
246
+ return false;
247
+ }
248
+ /** Comments blanked, strings preserved, offsets kept. TS/JS use the shared stripper;
249
+ * Python blanks each line from its first `#` outside a string. */
250
+ function blankComments(src, spec) {
251
+ if (spec.language !== "python")
252
+ return stripComments(src);
253
+ const blanked = blankStrings(src, spec).split("\n");
254
+ return src
255
+ .split("\n")
256
+ .map((line, i) => {
257
+ const at = blanked[i].indexOf("#");
258
+ return at === -1
259
+ ? line
260
+ : line.slice(0, at) + " ".repeat(line.length - at);
261
+ })
262
+ .join("\n");
263
+ }
264
+ export function blankCommentsAndStrings(src, spec) {
126
265
  if (spec.language !== "python")
127
266
  return stripCommentsAndStrings(src);
128
267
  return src
129
268
  .split("\n")
130
269
  .map((line) => line
131
270
  .replace(/(["'])(?:\\.|(?!\1).)*\1/g, (q) => q[0] + " ".repeat(Math.max(0, q.length - 2)) + q[0])
132
- .replace(/#.*$/, ""))
271
+ // Blanked, not deleted: callers scan this copy for OFFSETS and read the text
272
+ // back out of the original, so every line must keep its length.
273
+ .replace(/#.*$/, (c) => " ".repeat(c.length)))
133
274
  .join("\n");
134
275
  }
276
+ function signatureLines(body) {
277
+ // The signature runs until its parameter parentheses close: a `{` inside a
278
+ // wrapped object-type parameter (`opts: { a: string },`) is not the body
279
+ // opener, so a bare `/[{]|=>/` test truncated such signatures.
280
+ const out = [];
281
+ let depth = 0;
282
+ let opened = false;
283
+ for (const l of body) {
284
+ out.push(l);
285
+ for (let i = 0; i < l.length; i++) {
286
+ if (l[i] === "(") {
287
+ depth++;
288
+ opened = true;
289
+ }
290
+ else if (l[i] === ")")
291
+ depth--;
292
+ }
293
+ if (opened && depth <= 0)
294
+ break;
295
+ if (!opened && /=>/.test(l))
296
+ break; // bare-arrow form has no parentheses
297
+ if (out.length > 12)
298
+ break;
299
+ }
300
+ return out;
301
+ }
135
302
  /** Exclusive end index of a Python def starting at `start`: the next non-blank line at
136
303
  * column 0 (a following def, import, or module statement). */
137
304
  function pythonBodyEnd(lines, start) {
@@ -147,12 +314,26 @@ function pythonBodyEnd(lines, start) {
147
314
  /** Exclusive end index of a brace-delimited definition starting at `start`. Strings and
148
315
  * comments are not tracked — helper bodies contain request paths, not braces in
149
316
  * strings — and an unbalanced body runs to end of file rather than throwing. */
317
+ /** The definition line through the one that opens the body (`{` or `=>`). */
150
318
  function braceBodyEnd(lines, start) {
151
319
  let depth = 0;
152
320
  let opened = false;
321
+ // A `{` inside the signature's still-open parentheses is an object-type
322
+ // parameter (`opts: { a: string },` wrapped by Prettier), not the body opener.
323
+ let parens = 0;
153
324
  for (let i = start; i < lines.length; i++) {
154
325
  for (const ch of lines[i]) {
155
- if (ch === "{") {
326
+ // Parens matter only until the body opens: they exist to get past a wrapped
327
+ // signature. Tracking them afterwards let one unbalanced `(` in a string or
328
+ // comment blind the brace count for the rest of the file — swallowing every
329
+ // later helper and misattributing its violations (the gate then never clears).
330
+ if (!opened && ch === "(")
331
+ parens++;
332
+ else if (!opened && ch === ")")
333
+ parens = Math.max(0, parens - 1);
334
+ else if (!opened && parens > 0)
335
+ continue;
336
+ else if (ch === "{") {
156
337
  depth++;
157
338
  opened = true;
158
339
  }
@@ -0,0 +1,43 @@
1
+ import { type UtilsLanguageSpec } from "./language-spec.js";
2
+ export interface RetrofitMismatch {
3
+ /** Absolute path of the rewired pre-existing test. */
4
+ file: string;
5
+ /** The shared helper the file now calls in place of its own request block. */
6
+ helper: string;
7
+ method: string;
8
+ path: string;
9
+ /** What the substitution changed about the request. */
10
+ detail: string;
11
+ }
12
+ /**
13
+ * Whether a retrofit changed the request the rewired test sends.
14
+ *
15
+ * STEP 5c edits a PRE-EXISTING test — code the customer already owns and whose
16
+ * behaviour nothing in this run chose — to call a shared helper instead of its own
17
+ * inline request block. That is only safe if the helper sends the same request. It
18
+ * usually does, because the helper was extracted from an identical block; but nothing
19
+ * checked it, and a helper that hardcodes a field the block never sent (or omits one
20
+ * it did) silently changes what the customer's test exercises. The failure is quiet:
21
+ * the test still runs, and a red result reads like any other red.
22
+ *
23
+ * The advisory path already refuses to OFFER such a site (see `substitutable` in
24
+ * call-sites.ts). This is the same question asked after the fact, about the edit that
25
+ * was actually made, and the rule is stricter: offering tolerates a site with an extra
26
+ * field, because extending the helper with a defaulted parameter is the sanctioned
27
+ * move; a completed retrofit must leave the request byte-equivalent in shape, so the
28
+ * field sets must match exactly in both directions.
29
+ *
30
+ * LIMIT: this compares the request's SHAPE — which fields and options it carries —
31
+ * not the values passed. A baseline sending `status: "draft"` rewired to
32
+ * `createPost(..., "published")` has the same field set and is not reported. Value
33
+ * equivalence needs argument-to-parameter binding, default resolution and expression
34
+ * normalisation; a wrong answer here REFUSES the report, so it is deliberately left
35
+ * out rather than approximated. The value case is also visible in the rewired file's
36
+ * own diff, where a hardcoded helper field is not.
37
+ *
38
+ * Compares against the file's committed baseline (`git show HEAD:<path>`), which is
39
+ * the only record of what the block sent before the edit. Fails OPEN — an untracked
40
+ * file, a detached worktree, or an unreadable baseline yields no mismatches rather
41
+ * than a refusal the agent cannot act on.
42
+ */
43
+ export declare function retrofitMismatches(retrofitFile: string, utilsFiles: string[], spec: UtilsLanguageSpec): Promise<RetrofitMismatch[]>;
@@ -0,0 +1,218 @@
1
+ import { execFile } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { promisify } from "util";
5
+ import { logger } from "../logger.js";
6
+ import { detectGitRoot } from "../gitStaging.js";
7
+ import { blankCommentsAndStrings, parseUtilsFile } from "./parse.js";
8
+ import { normalizePath } from "./language-spec.js";
9
+ import { callText, hasSchemaCheck, requestFields, REQUEST_CALL_RE, SCHEMA_SCAN_LINES, shapeKeys, } from "./call-sites.js";
10
+ import { escapeRegExp } from "../regex.js";
11
+ const exec = promisify(execFile);
12
+ /**
13
+ * Whether a retrofit changed the request the rewired test sends.
14
+ *
15
+ * STEP 5c edits a PRE-EXISTING test — code the customer already owns and whose
16
+ * behaviour nothing in this run chose — to call a shared helper instead of its own
17
+ * inline request block. That is only safe if the helper sends the same request. It
18
+ * usually does, because the helper was extracted from an identical block; but nothing
19
+ * checked it, and a helper that hardcodes a field the block never sent (or omits one
20
+ * it did) silently changes what the customer's test exercises. The failure is quiet:
21
+ * the test still runs, and a red result reads like any other red.
22
+ *
23
+ * The advisory path already refuses to OFFER such a site (see `substitutable` in
24
+ * call-sites.ts). This is the same question asked after the fact, about the edit that
25
+ * was actually made, and the rule is stricter: offering tolerates a site with an extra
26
+ * field, because extending the helper with a defaulted parameter is the sanctioned
27
+ * move; a completed retrofit must leave the request byte-equivalent in shape, so the
28
+ * field sets must match exactly in both directions.
29
+ *
30
+ * LIMIT: this compares the request's SHAPE — which fields and options it carries —
31
+ * not the values passed. A baseline sending `status: "draft"` rewired to
32
+ * `createPost(..., "published")` has the same field set and is not reported. Value
33
+ * equivalence needs argument-to-parameter binding, default resolution and expression
34
+ * normalisation; a wrong answer here REFUSES the report, so it is deliberately left
35
+ * out rather than approximated. The value case is also visible in the rewired file's
36
+ * own diff, where a hardcoded helper field is not.
37
+ *
38
+ * Compares against the file's committed baseline (`git show HEAD:<path>`), which is
39
+ * the only record of what the block sent before the edit. Fails OPEN — an untracked
40
+ * file, a detached worktree, or an unreadable baseline yields no mismatches rather
41
+ * than a refusal the agent cannot act on.
42
+ */
43
+ export async function retrofitMismatches(retrofitFile, utilsFiles, spec) {
44
+ const baseline = await committedBaseline(retrofitFile);
45
+ if (baseline === undefined)
46
+ return [];
47
+ let delivered;
48
+ try {
49
+ delivered = await fs.promises.readFile(retrofitFile, "utf8");
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const byRoute = new Map();
55
+ for (const utilsFile of utilsFiles) {
56
+ let content;
57
+ try {
58
+ content = await fs.promises.readFile(utilsFile, "utf8");
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ const helpers = parseUtilsFile(content, spec);
64
+ // Positions come from a comment/string-blanked copy (offsets preserved), so a
65
+ // commented-out or quoted request is never read as a real one; the text itself
66
+ // is taken from the original.
67
+ const scan = blankCommentsAndStrings(content, spec);
68
+ for (const m of scan.matchAll(new RegExp(REQUEST_CALL_RE))) {
69
+ const start = m.index ?? 0;
70
+ const line = content.slice(0, start).split("\n").length;
71
+ const owner = helpers.find((h) => h.line <= line && line <= h.endLine);
72
+ if (!owner?.method || !owner.normalizedPath)
73
+ continue;
74
+ const call = callText(content, start + m[0].length - 1);
75
+ const route = `${owner.method} ${owner.normalizedPath}`;
76
+ const list = byRoute.get(route) ?? [];
77
+ // One helper may issue several requests; judge it by its first for this route.
78
+ if (!list.some((h) => h.helper === owner.name))
79
+ list.push({
80
+ helper: owner.name,
81
+ keys: shapeKeys(call),
82
+ fields: requestFields(content, call, start),
83
+ schemaChecked: hasSchemaCheck(content, owner.line, owner.endLine),
84
+ });
85
+ byRoute.set(route, list);
86
+ }
87
+ }
88
+ if (byRoute.size === 0)
89
+ return [];
90
+ // The requests the file used to make itself, before the edit.
91
+ const baselineByRoute = new Map();
92
+ const ambiguous = new Set();
93
+ const baselineScan = blankCommentsAndStrings(baseline, spec);
94
+ for (const m of baselineScan.matchAll(new RegExp(REQUEST_CALL_RE))) {
95
+ const start = m.index ?? 0;
96
+ const line = baseline.slice(0, start).split("\n").length;
97
+ const call = callText(baseline, start + m[0].length - 1);
98
+ const method = spec.methodRe.exec(call)?.[1]?.toUpperCase();
99
+ const p = spec.pathRe.exec(call)?.[1];
100
+ if (!method || !p)
101
+ continue;
102
+ const key = `${method} ${normalizePath(p)}`;
103
+ const seen = baselineByRoute.get(key);
104
+ const here = {
105
+ keys: shapeKeys(call),
106
+ fields: requestFields(baseline, call, start),
107
+ schemaChecked: hasSchemaCheck(baseline, line, line + SCHEMA_SCAN_LINES),
108
+ };
109
+ if (!seen) {
110
+ baselineByRoute.set(key, here);
111
+ continue;
112
+ }
113
+ // The same route more than once: only one of them was replaced, and nothing here
114
+ // says which. Identical blocks compare the same either way; DIFFERING ones are
115
+ // ambiguous, so the route is dropped rather than judged against a block that may
116
+ // not be the one the helper took over. Occurrence-level attribution would need to
117
+ // know which block disappeared, and getting that wrong refuses a safe edit.
118
+ if (!sameSet(seen.fields, here.fields) || !sameSet(seen.keys, here.keys))
119
+ ambiguous.add(key);
120
+ }
121
+ for (const key of ambiguous)
122
+ baselineByRoute.delete(key);
123
+ if (baselineByRoute.size === 0)
124
+ return [];
125
+ const calls = blankCommentsAndStrings(delivered, spec);
126
+ const out = [];
127
+ for (const [route, helpers] of byRoute) {
128
+ const before = baselineByRoute.get(route);
129
+ if (!before)
130
+ continue; // the file never made this request; nothing was replaced
131
+ for (const helper of helpers) {
132
+ // Only a helper this file actually calls now can have replaced anything —
133
+ // under whatever local name it was imported as (`import create_order as mk`).
134
+ const local = localBindings(calls, helper.helper);
135
+ if (!local.some((n) => new RegExp(`\\b${escapeRegExp(n)}\\s*\\(`).test(calls)))
136
+ continue;
137
+ const added = [...helper.fields].filter((f) => !before.fields.has(f));
138
+ const dropped = [...before.fields].filter((f) => !helper.fields.has(f));
139
+ const reasons = [];
140
+ // Non-liftable call options (query_params, timeout, …): the advisory compares
141
+ // these before offering a site, so the completed edit must be held to them too.
142
+ const optAdded = [...helper.keys].filter((k) => !before.keys.has(k));
143
+ const optDropped = [...before.keys].filter((k) => !helper.keys.has(k));
144
+ if (optAdded.length > 0 || optDropped.length > 0)
145
+ reasons.push(`the request options changed (${[
146
+ ...optAdded.map((k) => `+\`${k}\``),
147
+ ...optDropped.map((k) => `-\`${k}\``),
148
+ ].join(", ")})`);
149
+ if (added.length > 0)
150
+ reasons.push(`it now also sends ${added.map((f) => `\`${f}\``).join(", ")}`);
151
+ if (dropped.length > 0)
152
+ reasons.push(`it no longer sends ${dropped.map((f) => `\`${f}\``).join(", ")}`);
153
+ if (before.schemaChecked && !helper.schemaChecked)
154
+ reasons.push("the response-schema check the block carried is gone");
155
+ if (reasons.length === 0)
156
+ continue;
157
+ const [method, ...rest] = route.split(" ");
158
+ out.push({
159
+ file: retrofitFile,
160
+ helper: helper.helper,
161
+ method,
162
+ path: rest.join(" "),
163
+ detail: reasons.join("; "),
164
+ });
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+ /**
170
+ * The local names a module's export can be called by in this file: the export's own
171
+ * name, plus any `import X as Y` / `{ X as Y }` alias. A retrofit that imported the
172
+ * helper under an alias would otherwise look uncalled and fail open.
173
+ */
174
+ function localBindings(content, exported) {
175
+ const esc = escapeRegExp(exported);
176
+ const names = new Set([exported]);
177
+ // Python `from M import x as y`; TS `import { x as y }` / `{ x: y } = require(...)`.
178
+ for (const re of [
179
+ new RegExp(`\\b${esc}\\s+as\\s+([A-Za-z_$][\\w$]*)`, "g"),
180
+ new RegExp(`\\b${esc}\\s*:\\s*([A-Za-z_$][\\w$]*)`, "g"),
181
+ ])
182
+ for (const m of content.matchAll(re))
183
+ names.add(m[1]);
184
+ return [...names];
185
+ }
186
+ function sameSet(a, b) {
187
+ if (a.size !== b.size)
188
+ return false;
189
+ for (const v of a)
190
+ if (!b.has(v))
191
+ return false;
192
+ return true;
193
+ }
194
+ /** The file's content at HEAD, or undefined when there is nothing to compare against
195
+ * (untracked, no git root, or git unavailable). Never throws. */
196
+ async function committedBaseline(file) {
197
+ const root = detectGitRoot(file);
198
+ if (!root)
199
+ return undefined;
200
+ const rel = path.relative(root, file);
201
+ if (!rel || rel.startsWith(".."))
202
+ return undefined;
203
+ try {
204
+ const { stdout } = await exec("git", ["show", `HEAD:${rel}`], {
205
+ cwd: root,
206
+ maxBuffer: 10 * 1024 * 1024,
207
+ });
208
+ return stdout;
209
+ }
210
+ catch (err) {
211
+ // A file added by this run has no HEAD version — the common case, not an error.
212
+ logger.debug("No committed baseline for a retrofit; skipping equivalence", {
213
+ file,
214
+ error: String(err),
215
+ });
216
+ return undefined;
217
+ }
218
+ }
@@ -16,6 +16,12 @@
16
16
  export interface StagingResult {
17
17
  /** Files that reached the index. */
18
18
  staged: string[];
19
+ /** Utils files located for the test (absolute), staged or not. */
20
+ utilsFiles: string[];
21
+ /** Pre-existing generated siblings this run edited to import from those files
22
+ * (absolute) — the caller records them as retrofits so the report must disclose
23
+ * them. */
24
+ retrofits: string[];
19
25
  /** Utils files that `git add` refused (gitignored, outside the repo, git failure) —
20
26
  * the tests importing them will fail in the output commit, so the verify pass
21
27
  * treats a non-empty list as a failed verification. */
@@ -12,7 +12,9 @@ const execFileAsync = promisify(execFile);
12
12
  export async function stageUtilsArtifacts(testFile, language) {
13
13
  const staged = [];
14
14
  const failed = [];
15
- const result = { staged, failed };
15
+ const utilsFilesOut = [];
16
+ const retrofits = [];
17
+ const result = { staged, failed, utilsFiles: utilsFilesOut, retrofits };
16
18
  // Outside a testbot run stageGeneratedPaths is a no-op; report nothing as staged
17
19
  // rather than a list of files nothing touched.
18
20
  if (!isTestbotEnabled())
@@ -46,7 +48,9 @@ export async function stageUtilsArtifacts(testFile, language) {
46
48
  // file, so without this the sibling's edit drops out of the output commit while the
47
49
  // utils change lands — the same class of hole as the unstaged utils file.
48
50
  if (files.length > 0) {
51
+ utilsFilesOut.push(...files);
49
52
  for (const sibling of await modifiedSiblingsImporting(testFile, files, language)) {
53
+ retrofits.push(sibling);
50
54
  try {
51
55
  await stageGeneratedPaths(sibling);
52
56
  staged.push(sibling);
@@ -69,7 +73,13 @@ async function modifiedSiblingsImporting(testFile, utilsFiles, language) {
69
73
  let modified;
70
74
  try {
71
75
  // `-z`: unquoted, NUL-separated — git C-quotes non-ASCII paths otherwise.
72
- const { stdout } = await execFileAsync("git", ["diff", "-z", "--name-only"], {
76
+ const { stdout } = await execFileAsync("git",
77
+ // Against HEAD, modified only: a test THIS run created is staged at discovery, so
78
+ // its later STEP 5b edits show in a plain `git diff` and it would masquerade as a
79
+ // pre-existing sibling (observed: the report gate then demanded an execution no
80
+ // record could satisfy, and the agent un-shared the helpers to escape). Files
81
+ // added since HEAD are excluded; they are staged by their own tool calls.
82
+ ["diff", "-z", "--name-only", "--diff-filter=M", "HEAD"], {
73
83
  cwd: root,
74
84
  encoding: "utf8",
75
85
  });