@skyramp/mcp 0.4.2 → 0.4.3-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 (135) hide show
  1. package/build/execution/wrapperConfig.d.ts +4 -0
  2. package/build/execution/wrapperConfig.js +14 -2
  3. package/build/prompts/code-reuse.d.ts +5 -1
  4. package/build/prompts/code-reuse.js +16 -10
  5. package/build/prompts/modularization/ui-test-modularization.js +9 -2
  6. package/build/prompts/pom-aware-code-reuse.d.ts +4 -1
  7. package/build/prompts/pom-aware-code-reuse.js +38 -6
  8. package/build/prompts/reuse-hand-off.d.ts +4 -0
  9. package/build/prompts/reuse-hand-off.js +10 -5
  10. package/build/prompts/shared-helper-policy.d.ts +5 -0
  11. package/build/prompts/shared-helper-policy.js +5 -0
  12. package/build/prompts/test-maintenance/driftAnalysisShared.js +2 -0
  13. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +2 -1
  14. package/build/prompts/testbot/testbot-prompts.js +33 -16
  15. package/build/recommendation/answers.d.ts +5 -3
  16. package/build/recommendation/answers.js +5 -2
  17. package/build/recommendation/fakeVerifyContext.d.ts +3 -0
  18. package/build/recommendation/fakeVerifyContext.js +16 -0
  19. package/build/recommendation/runVerifiers.js +2 -0
  20. package/build/recommendation/types.d.ts +12 -0
  21. package/build/recommendation/verifierContracts.d.ts +32 -2
  22. package/build/recommendation/verifierContracts.js +45 -4
  23. package/build/recommendation/verifiers/changedLinesClaimed.d.ts +17 -0
  24. package/build/recommendation/verifiers/changedLinesClaimed.js +88 -0
  25. package/build/recommendation/verifiers/coverage.js +143 -4
  26. package/build/recommendation/verifiers/expectedValueSourced.js +8 -0
  27. package/build/recommendation/verifiers/requirementSourced.js +141 -11
  28. package/build/services/TestGenerationService.js +4 -0
  29. package/build/tools/code-refactor/caller-gate.d.ts +105 -0
  30. package/build/tools/code-refactor/caller-gate.js +174 -0
  31. package/build/tools/code-refactor/changed-helper-report.d.ts +62 -0
  32. package/build/tools/code-refactor/changed-helper-report.js +114 -0
  33. package/build/tools/code-refactor/codeReuseTool.js +131 -62
  34. package/build/tools/code-refactor/enhanceAssertionsTool.js +2 -2
  35. package/build/tools/code-refactor/helper-callers.d.ts +65 -0
  36. package/build/tools/code-refactor/helper-callers.js +85 -0
  37. package/build/tools/code-refactor/modularizationTool.js +10 -0
  38. package/build/tools/code-refactor/pom-pass-state.d.ts +51 -0
  39. package/build/tools/code-refactor/pom-pass-state.js +115 -0
  40. package/build/tools/code-refactor/removed-modules.d.ts +33 -0
  41. package/build/tools/code-refactor/removed-modules.js +61 -0
  42. package/build/tools/code-refactor/retrofit-state.d.ts +3 -2
  43. package/build/tools/code-refactor/retrofit-state.js +11 -12
  44. package/build/tools/code-refactor/reuse-outcome.d.ts +33 -1
  45. package/build/tools/code-refactor/reuse-record-store.d.ts +57 -0
  46. package/build/tools/code-refactor/reuse-record-store.js +140 -0
  47. package/build/tools/code-refactor/reuse-state.d.ts +34 -9
  48. package/build/tools/code-refactor/reuse-state.js +140 -135
  49. package/build/tools/code-refactor/utils-verify-gates.js +42 -18
  50. package/build/tools/code-refactor/verify-gates.d.ts +4 -0
  51. package/build/tools/code-refactor/verify-gates.js +8 -1
  52. package/build/tools/generate-tests/generateContractRestTool.js +7 -7
  53. package/build/tools/generate-tests/generateIntegrationRestTool.js +8 -8
  54. package/build/tools/generate-tests/generateUIRestTool.js +3 -3
  55. package/build/tools/submitReportTool.js +121 -30
  56. package/build/tools/test-management/actionsTool.js +7 -0
  57. package/build/tools/test-management/registerTestPlanTool.d.ts +1 -0
  58. package/build/tools/test-management/registerTestPlanTool.js +71 -23
  59. package/build/types/RepositoryAnalysis.d.ts +20 -20
  60. package/build/types/ReuseOutcome.d.ts +123 -0
  61. package/build/types/TestExecution.d.ts +11 -0
  62. package/build/types/TestExecution.js +19 -0
  63. package/build/types/index.d.ts +1 -1
  64. package/build/utils/branchDiff.d.ts +1 -1
  65. package/build/utils/branchDiff.js +1 -1
  66. package/build/utils/changedRuns.d.ts +13 -0
  67. package/build/utils/changedRuns.js +56 -0
  68. package/build/utils/featureFlags.d.ts +8 -10
  69. package/build/utils/featureFlags.js +23 -21
  70. package/build/utils/normalizeSkyrampImports.d.ts +4 -1
  71. package/build/utils/normalizeSkyrampImports.js +24 -17
  72. package/build/utils/pom-scope/ownership.d.ts +19 -0
  73. package/build/utils/pom-scope/ownership.js +31 -0
  74. package/build/utils/pom-scope/pom-files.d.ts +8 -0
  75. package/build/utils/pom-scope/pom-files.js +8 -0
  76. package/build/utils/pom-scope/scoring.js +13 -1
  77. package/build/utils/pom-scope/strip.d.ts +10 -0
  78. package/build/utils/pom-scope/strip.js +9 -0
  79. package/build/utils/pom-verify/verify.js +13 -2
  80. package/build/utils/reuseRouting.d.ts +30 -2
  81. package/build/utils/reuseRouting.js +36 -15
  82. package/build/utils/testDependencyPolicy.js +4 -16
  83. package/build/utils/utils-verify/action-sites.d.ts +2 -2
  84. package/build/utils/utils-verify/allow.d.ts +1 -1
  85. package/build/utils/utils-verify/allow.js +15 -0
  86. package/build/utils/utils-verify/body-reach.d.ts +2 -2
  87. package/build/utils/utils-verify/body-reach.js +4 -1
  88. package/build/utils/utils-verify/call-sites.d.ts +16 -6
  89. package/build/utils/utils-verify/call-sites.js +12 -6
  90. package/build/utils/utils-verify/changed-helpers.d.ts +38 -0
  91. package/build/utils/utils-verify/changed-helpers.js +91 -0
  92. package/build/utils/utils-verify/changed-lines.d.ts +48 -0
  93. package/build/utils/utils-verify/changed-lines.js +106 -0
  94. package/build/utils/utils-verify/head.d.ts +73 -0
  95. package/build/utils/utils-verify/head.js +156 -0
  96. package/build/utils/utils-verify/importers.d.ts +59 -10
  97. package/build/utils/utils-verify/importers.js +121 -31
  98. package/build/utils/utils-verify/in-house.js +26 -31
  99. package/build/utils/utils-verify/index.d.ts +3 -0
  100. package/build/utils/utils-verify/index.js +3 -0
  101. package/build/utils/utils-verify/language-spec.js +10 -2
  102. package/build/utils/utils-verify/locate.d.ts +3 -0
  103. package/build/utils/utils-verify/locate.js +6 -6
  104. package/build/utils/utils-verify/parse.d.ts +13 -1
  105. package/build/utils/utils-verify/parse.js +122 -49
  106. package/build/utils/utils-verify/retrofit-equivalence.d.ts +0 -5
  107. package/build/utils/utils-verify/retrofit-equivalence.js +2 -34
  108. package/build/utils/utils-verify/stage.js +4 -16
  109. package/build/utils/utils-verify/typecheck.d.ts +25 -4
  110. package/build/utils/utils-verify/typecheck.js +95 -13
  111. package/build/utils/utils-verify/verify.d.ts +33 -8
  112. package/build/utils/utils-verify/verify.js +180 -18
  113. package/build/utils/versions.d.ts +3 -3
  114. package/build/utils/versions.js +1 -1
  115. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1 -0
  116. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +1 -0
  117. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +6 -0
  118. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +4 -2
  119. package/node_modules/playwright/lib/mcp/skyramp/snapshotLocators.js +246 -0
  120. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +378 -237
  121. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +21 -0
  122. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  123. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-LNgEKtdV.js → codeMirrorModule-Bou8kKzE.js} +1 -1
  124. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-Bwr1eMKC.js → defaultSettingsView-DTp2-8Si.js} +3 -3
  125. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  126. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.BAkLd5DX.js → index.uiPTFncY.js} +1 -1
  127. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.BPopbasy.js → uiMode.GLqsx5cI.js} +1 -1
  128. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  129. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  130. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  131. package/node_modules/playwright/package.json +1 -1
  132. package/package.json +3 -3
  133. package/plugin/prompts/code-reuse/hand-off.md +13 -1
  134. package/plugin/prompts/plan-tests.md +2 -2
  135. package/plugin/skills/enhance-assertions/reference/shared-rules.md +1 -0
@@ -1,5 +1,8 @@
1
1
  import * as path from "path";
2
2
  import { generatedSiblings } from "./call-sites.js";
3
+ import { realpath } from "./locate.js";
4
+ import { blankComments, blankCommentsAndStrings, } from "./parse.js";
5
+ import { stringSpans } from "../pom-scope/strip.js";
3
6
  /** The strings a sibling's source would contain if it imports the module at
4
7
  * `modulePath` (relative to the sibling's directory): the path form for TS/JS
5
8
  * specifiers, and the dotted Python form — `../shared/X` → `..shared.X`,
@@ -13,29 +16,36 @@ export function moduleMentionCandidates(modulePath) {
13
16
  return [posix, "./" + posix, rest, dotted, "." + rest];
14
17
  }
15
18
  /**
16
- * Count, per utils helper, how many Skyramp-generated tests in the spec's directory
17
- * import it — the spec under verification included.
19
+ * The Skyramp-generated tests in the spec's directory that import each utils helper —
20
+ * the spec under verification included. THE importer walk: the single-importer
21
+ * advisory and the changed-helper caller set both read it, so one definition decides
22
+ * what counts as an importing test.
18
23
  *
19
- * Findings:
20
- * - 0 importers: nothing imports the helper, not even the spec that wrote it — a
21
- * dead helper, actionable on any run.
22
- * - 1 importer: not (yet) shared. Reported only when the directory holds at least
23
- * TWO delivered tests: on a first run every helper has exactly one importer by
24
- * construction (it seeds the utils file), so listing them would be guaranteed
25
- * noise on the most common path.
26
- * Fails open to "no findings" on any read error.
24
+ * Fails open to an unread scan on any error. That is a silence, not an empty answer;
25
+ * `read` is what tells the two apart.
26
+ *
27
+ * SCOPE, which every caller inherits: siblings are the spec's OWN DIRECTORY. A
28
+ * generated test importing the module from another directory is not found here, so
29
+ * the importer list is a LOWER BOUND on the callers, never the complete set.
27
30
  */
28
- export async function singleImporterHelpers(testFile, utilsFiles, helpersByFile, spec, preReadSiblings) {
31
+ export async function helperImporters(testFile, utilsFiles, helpersByFile, spec, preReadSiblings) {
29
32
  let dir;
30
33
  let siblings;
31
34
  try {
32
- dir = path.dirname(testFile);
35
+ // CANONICAL, because the module paths this is compared against are: they come
36
+ // from locateUtilsFiles, which realpaths everything. A spec named through a
37
+ // symlinked parent (macOS `/var` → `/private/var`, and every worktree under a
38
+ // linked temporary directory) otherwise relativises to a `../../…` path no
39
+ // sibling's import can contain, so every helper in the module read as imported
40
+ // by nothing — silently, since an empty importer list is also the honest answer
41
+ // for a helper nobody calls.
42
+ dir = path.dirname(await realpath(testFile));
33
43
  siblings =
34
44
  preReadSiblings ??
35
45
  (await generatedSiblings(testFile, spec, { includeSelf: true }));
36
46
  }
37
47
  catch {
38
- return [];
48
+ return { helpers: [], siblings: [], read: false };
39
49
  }
40
50
  // `importedNames` matches by basename stem, so a bare `from SkyrampUtils import …`
41
51
  // in a sibling would also be credited to `../shared/SkyrampUtils`. A sibling's
@@ -49,30 +59,110 @@ export async function singleImporterHelpers(testFile, utilsFiles, helpersByFile,
49
59
  inDir: path.dirname(f) === dir,
50
60
  mentions: moduleMentionCandidates(path.relative(dir, f).replace(/\.[^.]+$/, "")),
51
61
  }));
52
- // helper key = `${utilsFile}\0${name}` → number of importing tests
53
- const counts = new Map();
62
+ // helper key = `${utilsFile}\0${name}` → the tests importing it
63
+ const importers = new Map();
54
64
  for (const [file, helpers] of helpersByFile)
55
65
  for (const h of helpers)
56
- counts.set(`${file}\0${h.name}`, 0);
57
- for (const { content } of siblings) {
66
+ importers.set(`${file}\0${h.name}`, []);
67
+ for (const { file: sibling, content } of siblings.files) {
68
+ // Imports are read from the CODE, never from prose. A docstring or a comment
69
+ // quoting `from SkyrampUtils import …` would otherwise make that test a caller of
70
+ // a helper it cannot reach, and the gate would refuse the report over it — a
71
+ // false block, which this design treats as the worst outcome available to it.
72
+ const code = importScanSource(content, spec);
58
73
  for (const { file: utilsFile, stem, inDir, mentions } of stems) {
59
74
  if (!inDir && !mentions.some((c) => content.includes(c)))
60
75
  continue;
61
- const imported = new Set(spec.importedNames(content, stem));
76
+ const imported = new Set(spec.importedNames(code, stem));
62
77
  for (const h of helpersByFile.get(utilsFile) ?? [])
63
- if (imported.has(h.name)) {
64
- const k = `${utilsFile}\0${h.name}`;
65
- counts.set(k, (counts.get(k) ?? 0) + 1);
66
- }
78
+ if (imported.has(h.name))
79
+ importers.get(`${utilsFile}\0${h.name}`)?.push(sibling);
67
80
  }
68
81
  }
69
- const secondRunExists = siblings.length >= 2;
70
- const out = [];
71
- for (const [file, helpers] of helpersByFile)
72
- for (const h of helpers) {
73
- const n = counts.get(`${file}\0${h.name}`) ?? 0;
74
- if (n === 0 || (n === 1 && secondRunExists))
75
- out.push({ file, helper: h.name, line: h.line, importers: n });
76
- }
77
- return out;
82
+ const helpers = [];
83
+ for (const [file, hs] of helpersByFile)
84
+ for (const h of hs)
85
+ helpers.push({
86
+ file,
87
+ helper: h.name,
88
+ line: h.line,
89
+ importers: importers.get(`${file}\0${h.name}`) ?? [],
90
+ });
91
+ return {
92
+ helpers,
93
+ siblings: siblings.files.map((s) => s.file),
94
+ read: siblings.complete,
95
+ };
96
+ }
97
+ /**
98
+ * Helpers with 0 or 1 importing test — the advisory slice of {@link helperImporters}.
99
+ *
100
+ * Findings:
101
+ * - 0 importers: nothing imports the helper, not even the spec that wrote it — a
102
+ * dead helper, actionable on any run.
103
+ * - 1 importer: not (yet) shared. Reported only when the directory holds at least
104
+ * TWO delivered tests: on a first run every helper has exactly one importer by
105
+ * construction (it seeds the utils file), so listing them would be guaranteed
106
+ * noise on the most common path.
107
+ *
108
+ * Reports NOTHING from an incomplete walk. This is a claim, not a gate — it tells the
109
+ * agent a helper is dead and to call it or delete it — and a walk that could not read
110
+ * every candidate cannot support it: the file it failed to read may be the importer.
111
+ * Silence on doubt, so the finding stays trustworthy when it is made.
112
+ */
113
+ export async function singleImporterHelpers(testFile, utilsFiles, helpersByFile, spec, preReadSiblings) {
114
+ const scan = await helperImporters(testFile, utilsFiles, helpersByFile, spec, preReadSiblings);
115
+ if (!scan.read)
116
+ return [];
117
+ const secondRunExists = scan.siblings.length >= 2;
118
+ return scan.helpers.filter((h) => h.importers.length === 0 || (h.importers.length === 1 && secondRunExists));
119
+ }
120
+ /**
121
+ * `content` prepared for an import scan: the copy `importedNames` should read.
122
+ *
123
+ * Comments go in both languages — an import quoted in prose is not an import, and
124
+ * counting one makes a test a "caller" of a helper it cannot reach, which a gate then
125
+ * blocks on. Python string CONTENTS go too, since a Python import names no string, so
126
+ * the usage example in a docstring stops counting.
127
+ *
128
+ * TS/JS cannot blank every string, because the module specifier IS a string
129
+ * (`from "./skyrampUtils"`). Only the specifiers are kept: a string that directly
130
+ * follows `from` or `require(` stays, and every other string is blanked. Requiring
131
+ * the import to start a line is not enough on its own — a template literal holds
132
+ * whole lines, and the `require` form is bounded by a declaration keyword rather than
133
+ * by the line start — so quoted code kept reading as executable code.
134
+ */
135
+ export function importScanSource(content, spec) {
136
+ if (spec.language === "python")
137
+ return blankCommentsAndStrings(content, spec);
138
+ const noComments = blankComments(content, spec);
139
+ const out = noComments.split("");
140
+ for (const { start, end } of stringSpans(noComments)) {
141
+ // A module specifier is a string in exactly one position: straight after `from`
142
+ // or after `require(`. Anything else is data, whatever it spells.
143
+ if (isSpecifierPosition(noComments, start))
144
+ continue;
145
+ for (let i = start; i < end; i++)
146
+ out[i] = noComments[i] === "\n" ? "\n" : " ";
147
+ }
148
+ return out.join("");
149
+ }
150
+ /** Whether the string opening at `at` sits where a module specifier does: after
151
+ * `from`, or after `require(`, with only whitespace between. Read backwards over a
152
+ * fixed window rather than by matching a regex against `src.slice(0, at)` — that
153
+ * copied the file once per literal, quadratic in a spec with thousands of them. */
154
+ function isSpecifierPosition(src, at) {
155
+ let i = at;
156
+ while (i > 0 && /\s/.test(src[i - 1]))
157
+ i--;
158
+ const word = (w) => src.slice(Math.max(0, i - w.length), i) === w &&
159
+ !/[\w$]/.test(src[i - w.length - 1] ?? "");
160
+ if (word("from"))
161
+ return true;
162
+ if (src[i - 1] !== "(")
163
+ return false;
164
+ i--;
165
+ while (i > 0 && /\s/.test(src[i - 1]))
166
+ i--;
167
+ return word("require");
78
168
  }
@@ -1,14 +1,12 @@
1
- import { execFile } from "child_process";
2
- import { promisify } from "util";
3
1
  import * as fs from "fs";
4
2
  import * as path from "path";
5
3
  import { detectGitRoot } from "../gitStaging.js";
6
4
  import { logger } from "../logger.js";
7
5
  import { escapeRegExp } from "../regex.js";
8
- import { importedModules, realpath, UTILS_FILE_HEADER_RE } from "./locate.js";
6
+ import { hasUtilsHeaderContent, importedModules, realpath } from "./locate.js";
7
+ import { contentAtHead } from "./head.js";
9
8
  import { blankComments, blankCommentsAndStrings, defaultAt, hasDefault, parseUtilsFile, } from "./parse.js";
10
9
  import { blankStringContents, matchingClose } from "./action-key.js";
11
- const execFileAsync = promisify(execFile);
12
10
  export async function locateInHouseModules(params) {
13
11
  const root = params.cwd ?? detectGitRoot(params.testFile);
14
12
  if (!root)
@@ -26,7 +24,7 @@ export async function locateInHouseModules(params) {
26
24
  const rel = path.relative(realRoot, real);
27
25
  if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
28
26
  continue;
29
- const head = await headContent(realRoot, rel.split(path.sep).join("/"));
27
+ const head = await contentAtHead(real, realRoot);
30
28
  if (head === undefined)
31
29
  continue; // not tracked: created this run, or never a file
32
30
  // Ownership is read at HEAD and nowhere else. A Skyramp module from an earlier
@@ -34,31 +32,12 @@ export async function locateInHouseModules(params) {
34
32
  // did to its first lines; a module headerless at HEAD is the customer's, and a
35
33
  // header the working copy gained since is a change the additive gate refuses —
36
34
  // reading the working copy here would let that stamp reclassify their file.
37
- if (hasHeader(head))
35
+ if (hasUtilsHeaderContent(head))
38
36
  continue;
39
37
  out.push({ file: real, relPath: rel.split(path.sep).join("/") });
40
38
  }
41
39
  return out.sort((a, b) => a.file.localeCompare(b.file));
42
40
  }
43
- /** Whether a module's first lines carry the Skyramp utils header. */
44
- function hasHeader(content) {
45
- return UTILS_FILE_HEADER_RE.test(content.split("\n").slice(0, 5).join("\n"));
46
- }
47
- /** The module's content at HEAD, or `undefined` when HEAD does not hold it (or git
48
- * cannot be asked). */
49
- async function headContent(root, rel) {
50
- try {
51
- const { stdout } = await execFileAsync("git", ["show", `HEAD:${rel}`], {
52
- cwd: root,
53
- encoding: "utf8",
54
- maxBuffer: 16 * 1024 * 1024,
55
- });
56
- return stdout;
57
- }
58
- catch {
59
- return undefined;
60
- }
61
- }
62
41
  /** The number of arguments a helper accepts: from the position of its last required
63
42
  * parameter to its total, a rest parameter opening the top. A parameter with a
64
43
  * default, an optional `x?`, or a Python `*args`/`**kw` is not required — but a
@@ -224,20 +203,36 @@ function definitionLineOf(moduleLines, name) {
224
203
  const re = new RegExp(`\\b(?:class|const|let|var|enum|interface|type|function|def)\\s+${escapeRegExp(name)}\\b`);
225
204
  return moduleLines.find((l) => re.test(l));
226
205
  }
227
- /** `export { name } from "…"`, or `export * from "…"` — a binding of another module
228
- * exported here. Exists as an export; not readable as a helper of this module. */
206
+ /** `export { name } from "…"`, `export * from "…"`, or a binding this module IMPORTS
207
+ * and then lists in a local `export { … }` — a binding of another module exported
208
+ * here. Exists as an export; not readable as a helper of this module. The last form
209
+ * is how a fixtures module passes Playwright's `expect` through beside its extended
210
+ * `test`: `import { test as base, expect } from "@playwright/test"; … export { expect };`. */
229
211
  function isReExported(moduleCode, name) {
230
212
  // `export * from "./x"` may legally provide any name; nothing here can read it.
231
213
  if (/\bexport\s*\*\s*from\b/.test(moduleCode))
232
214
  return true;
233
- for (const m of moduleCode.matchAll(/\bexport\s*\{([^}]*)\}\s*from\b/g))
215
+ for (const m of moduleCode.matchAll(/\bexport\s*\{([^}]*)\}(\s*from\b)?/g))
234
216
  for (const raw of m[1].split(",")) {
235
217
  const [local, exported] = raw.trim().split(/\s+as\s+/);
236
- if ((exported ?? local) === name)
218
+ if ((exported ?? local) !== name)
219
+ continue;
220
+ // `export { x } from "…"` names the other module's binding directly; a local
221
+ // list exports this module's binding, which is another module's only when
222
+ // an import brought it in.
223
+ if (m[2] || isImportedBinding(moduleCode, local))
237
224
  return true;
238
225
  }
239
226
  return false;
240
227
  }
228
+ /** Whether an import statement or a CommonJS destructure binds `local` in this
229
+ * module: `import { local }`, `import { x as local }`, `import local from`,
230
+ * `import * as local from`, `const { local } = require(…)`. */
231
+ function isImportedBinding(moduleCode, local) {
232
+ const n = escapeRegExp(local);
233
+ return (new RegExp(`\\bimport\\s+(?:type\\s+)?[^;]*?(?:[{,]\\s*(?:\\w+\\s+as\\s+)?${n}\\s*[,}]|\\*\\s+as\\s+${n}\\b|\\b${n}\\s*(?:,|\\bfrom\\b))[^;]*?\\bfrom\\b`).test(moduleCode) ||
234
+ new RegExp(`\\b(?:const|let|var)\\s*\\{[^}]*\\b${n}\\b[^}]*\\}\\s*=\\s*require\\s*\\(`).test(moduleCode));
235
+ }
241
236
  function importLine(lines, name) {
242
237
  const re = new RegExp(`\\b${escapeRegExp(name)}\\b`);
243
238
  const i = lines.findIndex((l) => /\b(?:import|require|from)\b/.test(l) && re.test(l));
@@ -306,7 +301,7 @@ export async function additiveChanges(file, root, spec) {
306
301
  duplicates: [],
307
302
  };
308
303
  const rel = path.relative(root, file).split(path.sep).join("/");
309
- const head = await headContent(root, rel);
304
+ const head = await contentAtHead(file, root);
310
305
  if (head === undefined) {
311
306
  logger.warning("Could not read an in-house module at HEAD — the additive gate did not run", { file });
312
307
  return result;
@@ -335,7 +330,7 @@ export async function additiveChanges(file, root, spec) {
335
330
  }
336
331
  result.ran = true;
337
332
  // Rule 5 of the design, enforced: the file stays the customer's file.
338
- if (hasHeader(work) && !hasHeader(head))
333
+ if (hasUtilsHeaderContent(work) && !hasUtilsHeaderContent(head))
339
334
  result.violations.push({
340
335
  kind: "in-house-modified",
341
336
  file,
@@ -2,9 +2,12 @@ export * from "./language-spec.js";
2
2
  export * from "./parse.js";
3
3
  export * from "./allow.js";
4
4
  export * from "./locate.js";
5
+ export * from "./head.js";
5
6
  export * from "./verify.js";
6
7
  export * from "./stage.js";
7
8
  export * from "./call-sites.js";
9
+ export * from "./importers.js";
10
+ export * from "./changed-helpers.js";
8
11
  export * from "./action-sites.js";
9
12
  export * from "./retrofit-equivalence.js";
10
13
  export * from "./in-house.js";
@@ -2,9 +2,12 @@ export * from "./language-spec.js";
2
2
  export * from "./parse.js";
3
3
  export * from "./allow.js";
4
4
  export * from "./locate.js";
5
+ export * from "./head.js";
5
6
  export * from "./verify.js";
6
7
  export * from "./stage.js";
7
8
  export * from "./call-sites.js";
9
+ export * from "./importers.js";
10
+ export * from "./changed-helpers.js";
8
11
  export * from "./action-sites.js";
9
12
  export * from "./retrofit-equivalence.js";
10
13
  export * from "./in-house.js";
@@ -115,7 +115,10 @@ function tsModuleQualifiers(spec, stem) {
115
115
  const stemRe = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
116
116
  const from = `["'][^"']*\\/${stemRe}(?:\\.[cm]?[jt]s)?["']`;
117
117
  const out = new Set();
118
- for (const m of spec.matchAll(new RegExp(`import\\s*\\*\\s*as\\s+([A-Za-z_$][\\w$]*)\\s*from\\s*${from}`, "g")))
118
+ // Anchored to the START of a line, for the same reason the named-import scan is: a
119
+ // TypeScript specifier is itself a string, so the scan cannot blank strings to keep
120
+ // prose out — it has to require the import to BE a statement.
121
+ for (const m of spec.matchAll(new RegExp(`^\\s*import\\s*\\*\\s*as\\s+([A-Za-z_$][\\w$]*)\\s*from\\s*${from}`, "gm")))
119
122
  out.add(m[1]);
120
123
  for (const m of spec.matchAll(new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*${from}\\s*\\)`, "g")))
121
124
  out.add(m[1]);
@@ -125,7 +128,12 @@ function tsImportedNames(spec, stem) {
125
128
  const names = new Set();
126
129
  const stemRe = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
127
130
  const from = `["'][^"']*\\/${stemRe}(?:\\.[cm]?[jt]s)?["']`;
128
- for (const m of spec.matchAll(new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*${from}`, "g"))) {
131
+ // Anchored to the START of a line: a TypeScript specifier is itself a string, so
132
+ // the scan cannot blank strings to keep prose out — it has to require the import to
133
+ // BE a statement. Without this, ordinary data (`const sample = 'import { x } from
134
+ // "./skyrampUtils"'`) counted as an import, and a gate reading importers as callers
135
+ // would block a report over a test that cannot reach the helper.
136
+ for (const m of spec.matchAll(new RegExp(`^\\s*import\\s*\\{([^}]*)\\}\\s*from\\s*${from}`, "gm"))) {
129
137
  for (const raw of m[1].split(",")) {
130
138
  // The EXPORTED name (before `as`): the count checks what the utils file defines,
131
139
  // so an alias would under-count. Same choice as the Python `from … import x as y`.
@@ -3,6 +3,9 @@ import { type UtilsLanguageSpec } from "./language-spec.js";
3
3
  * <timestamp>`. Deliberately NOT the bare `SKYRAMP_UTILS_HEADER`, which every generated
4
4
  * TEST also carries as its codegen marker (`Generated by Skyramp v<version>`). */
5
5
  export declare const UTILS_FILE_HEADER_RE: RegExp;
6
+ /** Whether `content`'s first lines carry the utils header — the test that makes a
7
+ * file Skyramp's shared module, for a caller that already holds the content. */
8
+ export declare function hasUtilsHeaderContent(content: string): boolean;
6
9
  export interface LocateUtilsParams {
7
10
  /** Absolute path of the delivered test file. */
8
11
  testFile: string;
@@ -15,6 +15,11 @@ export const UTILS_FILE_HEADER_RE = new RegExp(`${SKYRAMP_UTILS_HEADER.replace(/
15
15
  const HEADER_SCAN_LINES = 5;
16
16
  /** Enough bytes for five header lines; a utils file is never read whole to find them. */
17
17
  const HEADER_SCAN_BYTES = 2048;
18
+ /** Whether `content`'s first lines carry the utils header — the test that makes a
19
+ * file Skyramp's shared module, for a caller that already holds the content. */
20
+ export function hasUtilsHeaderContent(content) {
21
+ return UTILS_FILE_HEADER_RE.test(content.split("\n").slice(0, HEADER_SCAN_LINES).join("\n"));
22
+ }
18
23
  /**
19
24
  * Every shared utils file relevant to `testFile`, by CONTENT, never by name.
20
25
  *
@@ -130,12 +135,7 @@ export async function hasUtilsHeader(file) {
130
135
  fh = await fs.promises.open(file, "r");
131
136
  const buf = Buffer.alloc(HEADER_SCAN_BYTES);
132
137
  const { bytesRead } = await fh.read(buf, 0, HEADER_SCAN_BYTES, 0);
133
- const head = buf
134
- .toString("utf8", 0, bytesRead)
135
- .split("\n")
136
- .slice(0, HEADER_SCAN_LINES)
137
- .join("\n");
138
- return UTILS_FILE_HEADER_RE.test(head);
138
+ return hasUtilsHeaderContent(buf.toString("utf8", 0, bytesRead));
139
139
  }
140
140
  catch (err) {
141
141
  // A missing file is the common, silent case. Anything else means a file exists
@@ -1,13 +1,21 @@
1
1
  import { type UtilsLanguageSpec } from "./language-spec.js";
2
2
  import { type HelperSleep } from "./fixed-sleep.js";
3
+ import { type SourceSpan } from "../pom-scope/strip.js";
3
4
  export interface HelperAssertion {
4
5
  line: number;
5
6
  text: string;
6
7
  }
7
8
  export interface UtilsHelper {
8
9
  name: string;
9
- /** 1-based line of the definition. */
10
+ /** 1-based line of the definition — the `def`/`function` line, which is where a
11
+ * finding is reported. */
10
12
  line: number;
13
+ /** 1-based line where the definition's SOURCE begins: the first line of its
14
+ * decorator stack, or `line` when it has none. A decorator is part of what the
15
+ * helper does — `@retry(3)` against `@retry(1)` changes every call site — so a
16
+ * reader comparing implementations has to start here, while a reader pointing at
17
+ * the helper still uses `line`. */
18
+ startLine: number;
11
19
  /** 1-based line of the last body line (inclusive). */
12
20
  endLine: number;
13
21
  method?: string;
@@ -104,6 +112,10 @@ export declare function parseUtilsFile(content: string, spec: UtilsLanguageSpec)
104
112
  /** True when the `path` value is a single string literal ending the argument — a
105
113
  * concatenation (`"/api/orders/" + str(id) + "/items"`) has no route this can name. */
106
114
  export declare function isSingleLiteral(line: string, m: RegExpExecArray): boolean;
115
+ /** The string literals in `src`, quotes included, in order. TS/JS go through the
116
+ * shared scanner; Python goes through the one below, so a caller sees one definition
117
+ * of a literal for each language. */
118
+ export declare function stringSpansOf(src: string, spec: UtilsLanguageSpec): SourceSpan[];
107
119
  /** Comments and string CONTENTS blanked (quotes kept, line count preserved). Python
108
120
  * comments are `#…`; TS/JS go through the shared stripper. */
109
121
  /** String CONTENTS blanked (quotes kept, comments kept, line count preserved). */
@@ -7,7 +7,7 @@ function actionSequence(body, language) {
7
7
  return keys ? { actionKey: keys.join("; "), actionSteps: keys } : {};
8
8
  }
9
9
  import { fixedSleepsIn, pythonSleepBindings, } from "./fixed-sleep.js";
10
- import { stripComments, stripCommentsAndStrings, stripStrings, } from "../pom-scope/strip.js";
10
+ import { stringSpans, stripComments, stripCommentsAndStrings, stripStrings, } from "../pom-scope/strip.js";
11
11
  /** Whether the `<` at `i` opens a generic (`Record<string, …>`, `Array<{…}>`) rather
12
12
  * than comparing (`i < 3`): a generic follows an identifier or `>` and is followed
13
13
  * by a type start; a comparison is followed by a number, a space and a value, or
@@ -266,6 +266,7 @@ export function parseUtilsFile(content, spec) {
266
266
  const helper = {
267
267
  name,
268
268
  line: start + 1,
269
+ startLine: decoratorStart(lines, structure, start) + 1,
269
270
  endLine: Math.max(end, start + 1),
270
271
  wrapsRequest: false,
271
272
  hasGuard: hasPageErrorGuard(body.join("\n"), spec),
@@ -355,69 +356,100 @@ export function isSingleLiteral(line, m) {
355
356
  after.startsWith(")") ||
356
357
  after.startsWith("}"));
357
358
  }
358
- /** Comments and string CONTENTS blanked (quotes kept, line count preserved). Python
359
- * comments are `#…`; TS/JS go through the shared stripper. */
360
- /** String CONTENTS blanked (quotes kept, comments kept, line count preserved). */
361
- export function blankStrings(src, spec) {
362
- if (spec.language !== "python")
363
- return stripStrings(src);
364
- return blankPythonStrings(src);
359
+ /** The string literals in `src`, quotes included, in order. TS/JS go through the
360
+ * shared scanner; Python goes through the one below, so a caller sees one definition
361
+ * of a literal for each language. */
362
+ export function stringSpansOf(src, spec) {
363
+ return spec.language === "python" ? pythonStringSpans(src) : stringSpans(src);
365
364
  }
366
365
  /**
367
- * Blank every Python string literal, triple-quoted ones included, preserving offsets.
366
+ * The string literals in Python source, quotes included.
368
367
  *
369
- * A line-by-line regex cannot do this: a `"""…"""` block spans lines, so a `#` inside a
370
- * docstring or an embedded SQL block read as a comment. Every scanner built on this
371
- * then treated quoted text as code the file executes — and the delivery-time marker
372
- * strip deleted a line out of a value rather than out of a comment.
368
+ * Triple quotes are tested BEFORE single ones, and this is the whole reason the scan
369
+ * is not a regex: a `"""` read one quote at a time matches an empty `""` and leaves
370
+ * the payload to be treated as code. A multiline payload's own indentation is then
371
+ * read as formatting, and a change to it disappears.
372
+ *
373
+ * A single-quoted string that never closes before the end of its line is a stray
374
+ * apostrophe in code (`# don't`, or a regex), not an opener — the same rule the TS
375
+ * scanner applies. A prefix (`r`, `f`, `b`, `rb`) is code; only the quote opens the
376
+ * literal.
377
+ *
378
+ * COMMENTS ARE SKIPPED while looking for an opener, in the same pass rather than a
379
+ * prior one: a comment that merely mentions a quote (`# a docstring starts with """`)
380
+ * would otherwise open a span that runs to the next real delimiter, blanking every
381
+ * definition in between so its helpers vanish from every check that reads the
382
+ * structure. A `#` inside a literal is not a comment, which is why one pass has to
383
+ * decide both.
373
384
  */
374
- function blankPythonStrings(src) {
375
- const out = src.split("");
376
- const blank = (from, to) => {
377
- for (let k = from; k < to; k++)
378
- if (out[k] !== "\n")
379
- out[k] = " ";
380
- };
381
- let i = 0;
382
- const n = src.length;
383
- while (i < n) {
384
- const c = src[i];
385
- // A comment runs to the end of the line and holds no string opener.
386
- if (c === "#") {
387
- while (i < n && src[i] !== "\n")
385
+ function pythonStringSpans(src) {
386
+ const out = [];
387
+ for (let i = 0; i < src.length; i++) {
388
+ const q = src[i];
389
+ if (q === "#") {
390
+ // Outside a literal by construction — a literal advances `i` past its end.
391
+ while (i < src.length && src[i] !== "\n")
388
392
  i++;
389
393
  continue;
390
394
  }
391
- if (c !== '"' && c !== "'") {
392
- i++;
395
+ if (q !== '"' && q !== "'")
393
396
  continue;
394
- }
395
- // A string prefix (r, b, f, u, rb, …) is part of the literal, not of the quote.
396
- const triple = src.startsWith(c.repeat(3), i);
397
- const quote = triple ? c.repeat(3) : c;
398
- const start = i;
399
- i += quote.length;
400
- let closed = false;
401
- while (i < n) {
402
- if (src[i] === "\\") {
403
- i += 2;
397
+ const triple = src.startsWith(q + q + q, i);
398
+ const close = triple ? q + q + q : q;
399
+ let j = i + close.length;
400
+ let end = -1;
401
+ while (j < src.length) {
402
+ if (src[j] === "\\") {
403
+ j += 2;
404
404
  continue;
405
405
  }
406
- if (src.startsWith(quote, i)) {
407
- i += quote.length;
408
- closed = true;
406
+ if (src.startsWith(close, j)) {
407
+ end = j + close.length;
409
408
  break;
410
409
  }
411
410
  // Only a triple-quoted literal may hold a raw newline.
412
- if (src[i] === "\n" && !triple)
411
+ if (src[j] === "\n" && !triple)
413
412
  break;
414
- i++;
415
- }
416
- if (!closed) {
417
- i = start + 1;
418
- continue;
413
+ j++;
419
414
  }
420
- blank(start + quote.length, i - quote.length);
415
+ if (end === -1)
416
+ continue; // never closed: not a literal
417
+ out.push({ start: i, end });
418
+ i = end - 1;
419
+ }
420
+ return out;
421
+ }
422
+ /** Comments and string CONTENTS blanked (quotes kept, line count preserved). Python
423
+ * comments are `#…`; TS/JS go through the shared stripper. */
424
+ /** String CONTENTS blanked (quotes kept, comments kept, line count preserved). */
425
+ export function blankStrings(src, spec) {
426
+ if (spec.language !== "python")
427
+ return stripStrings(src);
428
+ return blankPythonStrings(src);
429
+ }
430
+ /**
431
+ * String CONTENTS blanked in Python source, quotes kept, offsets and line count
432
+ * preserved.
433
+ *
434
+ * Reads its literals from `pythonStringSpans` — the same scan `stringSpansOf` serves —
435
+ * so ONE definition decides what a Python literal is, for the callers that blank a
436
+ * literal and the callers that must copy one through unchanged.
437
+ *
438
+ * A line-by-line regex cannot do this. A `"""…"""` block spans lines, so a `#` inside a
439
+ * docstring or an embedded SQL block read as a comment, and every scanner built on this
440
+ * then treated quoted text as code the file executes — the delivery-time marker strip
441
+ * deleted a line out of a value rather than out of a comment, and a `def` at column 0
442
+ * inside a docstring ended a helper early and reappeared as a second definition of the
443
+ * same name.
444
+ */
445
+ function blankPythonStrings(src) {
446
+ const out = src.split("");
447
+ for (const { start, end } of pythonStringSpans(src)) {
448
+ // The quote delimiters stay: callers read this copy for offsets and expect a
449
+ // literal to still look like one.
450
+ const q = src.startsWith(src[start].repeat(3), start) ? 3 : 1;
451
+ for (let i = start + q; i < end - q; i++)
452
+ out[i] = src[i] === "\n" ? "\n" : " ";
421
453
  }
422
454
  return out.join("");
423
455
  }
@@ -508,6 +540,47 @@ function signatureLines(body) {
508
540
  }
509
541
  return out;
510
542
  }
543
+ /** The index of the first line of the decorator stack above `start`, or `start` when
544
+ * there is none. Comments and blank lines may sit between decorators; anything else
545
+ * ends the stack.
546
+ *
547
+ * A decorator may span lines — `@retry(` … `)` — so the walk goes up by EXPRESSION,
548
+ * not by line: from a line that closes more brackets than it opens, it keeps going
549
+ * until the brackets balance, and only then asks whether that line starts with `@`.
550
+ * A line-at-a-time walk sees `)` above the definition, stops there, and drops the
551
+ * whole decorator out of the comparison.
552
+ *
553
+ * Brackets are counted on `structure`, the copy with comments and string contents
554
+ * blanked, for the same reason every other structural read here uses it: a bracket
555
+ * typed inside a string (`retry_if_message(")")`) is not a bracket. The `@` itself is
556
+ * read from `lines`, where it is still legible. Both copies keep the same line count,
557
+ * so one index walks both.
558
+ *
559
+ * Language-neutral: a definition with no `@` line simply gets `start` back. */
560
+ function decoratorStart(lines, structure, start) {
561
+ let first = start;
562
+ let depth = 0;
563
+ for (let i = start - 1; i >= 0; i--) {
564
+ const l = lines[i].trim();
565
+ // Blank, or a comment: `structure` has comments blanked already, so the test
566
+ // is one and the same in every language — no prefix list to keep in step.
567
+ if (depth === 0 && (structure[i] ?? "").trim() === "")
568
+ continue;
569
+ for (const ch of structure[i] ?? "") {
570
+ if (")]}".includes(ch))
571
+ depth++;
572
+ else if ("([{".includes(ch))
573
+ depth--;
574
+ }
575
+ if (depth > 0)
576
+ continue; // inside a wrapped expression; keep walking up
577
+ depth = 0;
578
+ if (!l.startsWith("@"))
579
+ break;
580
+ first = i;
581
+ }
582
+ return first;
583
+ }
511
584
  /** Whether the def at `start` carries `@overload` / `@typing.overload` anywhere in
512
585
  * its decorator stack (comments and other decorators may sit between). */
513
586
  function isOverloadStub(lines, start) {