@skyramp/mcp 0.3.7 → 0.3.9-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/build/playwright/registerPlaywrightTools.js +1 -0
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +22 -1
  3. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  4. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  5. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +4 -1
  6. package/build/prompts/test-recommendation/diffExecutionPlan.js +41 -9
  7. package/build/prompts/test-recommendation/fullRepoCatalog.js +3 -2
  8. package/build/prompts/test-recommendation/recommendationSections.js +3 -3
  9. package/build/prompts/test-recommendation/scopeAssessment.d.ts +2 -2
  10. package/build/prompts/test-recommendation/scopeAssessment.js +58 -76
  11. package/build/prompts/test-recommendation/test-recommendation-prompt.js +14 -1
  12. package/build/prompts/testbot/testbot-prompts.js +35 -15
  13. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +39 -5
  14. package/build/recommendation/budgeters/shared.d.ts +21 -8
  15. package/build/recommendation/budgeters/shared.js +134 -56
  16. package/build/recommendation/planRanker.d.ts +5 -3
  17. package/build/recommendation/planRanker.js +1 -1
  18. package/build/services/containerEnv.d.ts +1 -1
  19. package/build/services/containerEnv.js +12 -0
  20. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  21. package/build/tools/executeSkyrampTestTool.js +246 -19
  22. package/build/tools/submitReportTool.d.ts +5 -5
  23. package/build/tools/submitReportTool.js +11 -2
  24. package/build/tools/test-management/actionsTool.js +71 -3
  25. package/build/tools/test-management/analyzeChangesTool.d.ts +10 -0
  26. package/build/tools/test-management/analyzeChangesTool.js +73 -0
  27. package/build/tools/test-management/registerTestPlanTool.d.ts +3 -3
  28. package/build/tools/test-management/registerTestPlanTool.js +29 -8
  29. package/build/types/RepositoryAnalysis.d.ts +10 -10
  30. package/build/types/TestAnalysis.d.ts +12 -0
  31. package/build/types/TestExecution.d.ts +4 -0
  32. package/build/types/TestRecommendation.d.ts +11 -1
  33. package/build/types/TestRecommendation.js +34 -0
  34. package/build/utils/AnalysisStateManager.d.ts +7 -0
  35. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  36. package/build/utils/assertion-verify/ui-lints.js +32 -0
  37. package/build/utils/frontendSelectors.d.ts +33 -0
  38. package/build/utils/frontendSelectors.js +196 -5
  39. package/build/utils/pathSignatures.d.ts +4 -1
  40. package/build/utils/pathSignatures.js +14 -2
  41. package/build/utils/planOnlyMode.d.ts +33 -0
  42. package/build/utils/planOnlyMode.js +40 -0
  43. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  44. package/build/utils/rebaselineSnapshots.js +65 -0
  45. package/build/utils/removedUiElements.d.ts +34 -0
  46. package/build/utils/removedUiElements.js +153 -0
  47. package/build/utils/reportVerification.d.ts +7 -6
  48. package/build/utils/reportVerification.js +68 -4
  49. package/build/utils/scenarioDrafting.d.ts +1 -1
  50. package/build/utils/scenarioDrafting.js +1 -1
  51. package/build/utils/versions.d.ts +3 -3
  52. package/build/utils/versions.js +1 -1
  53. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  54. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  55. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  56. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  57. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  58. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  59. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  60. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  61. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  62. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  63. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  64. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  65. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  66. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  67. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  68. package/node_modules/playwright/package.json +1 -1
  69. package/package.json +3 -3
@@ -1,8 +1,3 @@
1
1
  import type { AssertionLanguage } from "./metrics.js";
2
2
  import { type LintFinding, type LintOptions } from "./lint-types.js";
3
- /** UI (Playwright) lints. Python (playwright-python) UI specs deliberately get
4
- * the site-scoped lints only: the structural rules' patterns and accept-lists
5
- * are JS/TS-specific (imports, waitForResponse shapes, expect matcher accept
6
- * forms), and porting them without a python golden corpus to validate against
7
- * would recreate the false-block class review round 2 removed. Pinned by test. */
8
3
  export declare function lintUiSpec(raw: string, language: AssertionLanguage, opts?: LintOptions): LintFinding[];
@@ -222,6 +222,36 @@ function lintUnassertedTrailingAction(stripped) {
222
222
  * are JS/TS-specific (imports, waitForResponse shapes, expect matcher accept
223
223
  * forms), and porting them without a python golden corpus to validate against
224
224
  * would recreate the false-block class review round 2 removed. Pinned by test. */
225
+ // SKYR-4305 — a removal guard (`toBeHidden()` / `not.toBeVisible()` on an element a
226
+ // PR removed) is only meaningful next to a positive assertion on the same page: alone
227
+ // it passes on a blank page, a 404, a typo'd testid, or a page the element never
228
+ // rendered on. `toBeHidden` is a PARTIAL_MATCHER (weight 2), so the strength gate does
229
+ // not catch a negative-only spec; this lint does.
230
+ const ABSENCE_ASSERT_RE = /\.\s*(?:toBeHidden|to_be_hidden)\s*\(|\.\s*not\s*\.\s*(?:toBeVisible|toBeAttached)\s*\(|\.\s*not_to_be_(?:visible|attached)\s*\(/g;
231
+ // Only Playwright LOCATOR/page matchers count as the positive half of the pair —
232
+ // they prove something rendered. Generic Jest matchers (toBe/toEqual/toHaveLength)
233
+ // are deliberately absent: `expect(resp.status()).toBe(200)` next to a lone
234
+ // toBeHidden() proves nothing about the page.
235
+ const POSITIVE_ASSERT_RE = /\.\s*(?:toHaveText|toContainText|toHaveValue|toHaveValues|toHaveAttribute|toHaveCount|toHaveURL|toHaveTitle|toHaveId|toHaveClass|toHaveCSS|toHaveJSProperty|toHaveAccessibleName|toHaveAccessibleDescription|toHaveRole|toHaveScreenshot|toBeVisible|toBeChecked|toBeEnabled|toBeDisabled|toBeEditable|toBeEmpty|toBeFocused|toBeInViewport|toContainClass|to_have_text|to_contain_text|to_have_value|to_have_values|to_have_attribute|to_have_count|to_have_url|to_have_title|to_have_id|to_have_class|to_have_css|to_have_js_property|to_have_accessible_name|to_have_accessible_description|to_have_role|to_have_screenshot|to_be_visible|to_be_checked|to_be_enabled|to_be_disabled|to_be_editable|to_be_empty|to_be_focused|to_be_in_viewport|to_contain_class)\s*\(/;
236
+ function lintNegativeOnlySpec(commentless) {
237
+ ABSENCE_ASSERT_RE.lastIndex = 0;
238
+ const first = ABSENCE_ASSERT_RE.exec(commentless);
239
+ if (!first)
240
+ return [];
241
+ // Strip the absence assertions themselves, then look for a Playwright matcher
242
+ // on a rendered element (generic Jest matchers do not count — see POSITIVE_ASSERT_RE).
243
+ const remainder = commentless.replace(ABSENCE_ASSERT_RE, "");
244
+ ABSENCE_ASSERT_RE.lastIndex = 0;
245
+ if (POSITIVE_ASSERT_RE.test(remainder))
246
+ return [];
247
+ return [{
248
+ rule: "negative-only-spec",
249
+ severity: "hard",
250
+ line: lineOfOffset(commentless, first.index),
251
+ message: "Every locator assertion in this spec asserts absence (toBeHidden / not.toBeVisible); nothing proves the page rendered, so it passes on a blank page, a 404 or a typo'd selector.",
252
+ remediation: "Add at least one positive assertion on a retained element of the same page (toHaveText / toHaveValue / toHaveCount / toHaveURL) next to the absence assertions.",
253
+ }];
254
+ }
225
255
  export function lintUiSpec(raw, language, opts) {
226
256
  if (language === "java")
227
257
  return [];
@@ -240,5 +270,7 @@ export function lintUiSpec(raw, language, opts) {
240
270
  findings.push(...lintUnassertedTrailingAction(stripped));
241
271
  }
242
272
  findings.push(...lintTautologies(commentless, opts));
273
+ if (!maintenance)
274
+ findings.push(...lintNegativeOnlySpec(commentless));
243
275
  return findings;
244
276
  }
@@ -32,3 +32,36 @@ export declare const MAX_CHANGED_SELECTORS = 80;
32
32
  * `ads-button--primary`).
33
33
  */
34
34
  export declare function extractChangedSelectors(fileDiff: string): string[];
35
+ /** A removed element's identity: the `data-*` attribute and its literal value. */
36
+ export interface RemovedAttribute {
37
+ /** Lower-cased attribute name, e.g. `data-testid`, `data-cy`, `data-section`. */
38
+ attribute: string;
39
+ value: string;
40
+ }
41
+ /** Identifying attribute VALUES that appear on `+` lines of a diff (any number of files). */
42
+ export declare function extractAddedAttributeValues(diff: string): string[];
43
+ /**
44
+ * Identifying `data-*` attributes one file's diff REMOVED: literals on `-` lines, minus the
45
+ * cases where the element still renders somewhere:
46
+ *
47
+ * - **moved / extracted** — the same value appears on a `+` line of this file or, via
48
+ * `addedValuesElsewhere`, anywhere else in the diff (a component extracted into a new
49
+ * file is the common shape);
50
+ * - **not an element** — a `-` line counts only when it opens a JSX/HTML tag or is a
51
+ * wrapped attribute line of a tag opened above it (the tag is carried until the `>`
52
+ * that closes it). A deleted comment, docs snippet, story arg or page-object
53
+ * `getByTestId(...)` call that merely mentions the attribute is not an element
54
+ * removal — the element itself may well survive on an unchanged context line;
55
+ * - **renamed** — a rename cannot be told apart by literal identity (old and new
56
+ * value differ by definition), so it is paired structurally: within the same hunk,
57
+ * each `-` line is paired with AT MOST ONE `+` line carrying a NEW identifying
58
+ * attribute — first by identical shape with the values blanked, else by the same
59
+ * opening tag — and a paired `+` line is consumed. So one added element suppresses
60
+ * one removal, not every removal in the hunk. This still errs toward suppressing a
61
+ * guard when one same-tag element is swapped for an unrelated new one.
62
+ *
63
+ * Feeds `uiContext.removedElements` (SKYR-4305) — the grounding for a removal guard
64
+ * (`toBeHidden()` on `getByTestId(value)` for `data-testid`, on
65
+ * `locator('[<attribute>="<value>"]')` for any other `data-*`).
66
+ */
67
+ export declare function extractRemovedAttributes(fileDiff: string, addedValuesElsewhere?: Iterable<string>): RemovedAttribute[];
@@ -52,6 +52,28 @@ function isDistinctiveSelector(token, trusted) {
52
52
  }
53
53
  return hasBEM || (hyphens >= 2 && !COMMON_CSS_SELECTOR_TOKENS.test(token));
54
54
  }
55
+ /**
56
+ * The test-facing selector grammar, shared by every extractor in this module so the
57
+ * two cannot drift: `data-testid` / `data-test` / `data-test-id` / `data-cy` / `data-qa`
58
+ * attribute values (string literals only) and `getByTestId(...)` arguments.
59
+ * Case-insensitive on purpose: JSX authors write `data-testId` (demoshop) and the
60
+ * browser lowercases attribute names, so `getByTestId` matches either spelling.
61
+ */
62
+ const TESTID_ATTR_RE = /\b(data-(?:test-?id|test|cy|qa))\b\s*=\s*\{?\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/gi;
63
+ const GET_BY_TEST_ID_RE = /getByTestId\(\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/gi;
64
+ /**
65
+ * Any other `data-*` attribute with a static string value (`data-section="session-id-debug"`).
66
+ * Untrusted: the value must pass the stricter distinctiveness rule, and attributes that carry
67
+ * component STATE rather than identity are excluded — they flip at runtime, so "absent" is
68
+ * meaningless for them.
69
+ */
70
+ const GENERIC_DATA_ATTR_RE = /\b(data-[a-z][a-z0-9]*(?:-[a-z0-9]+)*)\s*=\s*\{?\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/gi;
71
+ const STATE_DATA_ATTRS = new Set([
72
+ "data-state", "data-theme", "data-orientation", "data-side", "data-align", "data-value",
73
+ "data-index", "data-slot", "data-disabled", "data-checked", "data-active", "data-open",
74
+ "data-loading", "data-size", "data-variant", "data-selected", "data-highlighted",
75
+ "data-focus", "data-hover", "data-pressed", "data-expanded", "data-placeholder", "data-status",
76
+ ]);
55
77
  /**
56
78
  * Extract selector literals added or removed in a single file's diff slice.
57
79
  *
@@ -74,8 +96,8 @@ export function extractChangedSelectors(fileDiff) {
74
96
  // `data-testid="x"`, `data-testid={'x'}`, `data-testid={`x`}`. The closing delimiter
75
97
  // deliberately rejects interpolated template literals (`data-testid={`members-${id}`}`),
76
98
  // which would otherwise leak a partial prefix (`members-`) into the selector set.
77
- const ATTR = /\bdata-(?:test-?id|test|cy|qa)\b\s*=\s*\{?\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/g;
78
- const GET_BY_TEST_ID = /getByTestId\(\s*[`'"]([A-Za-z][\w:-]{2,})[`'"]/g;
99
+ const ATTR = TESTID_ATTR_RE;
100
+ const GET_BY_TEST_ID = GET_BY_TEST_ID_RE;
79
101
  const KEBAB = /[a-z][a-z0-9]*(?:-{1,2}[a-z0-9]+)+/g;
80
102
  // BEM base whose modifier is composed DYNAMICALLY, so no concrete class literal exists
81
103
  // in the diff to grep: `ads-button--${kind}`, "ads-button--" + kind, clsx(base && `x--${y}`).
@@ -92,12 +114,12 @@ export function extractChangedSelectors(fileDiff) {
92
114
  if (raw.startsWith("+++") || raw.startsWith("---"))
93
115
  continue;
94
116
  const line = raw.slice(1);
95
- for (const re of [ATTR, GET_BY_TEST_ID]) {
117
+ for (const [re, valueGroup] of [[ATTR, 2], [GET_BY_TEST_ID, 1]]) {
96
118
  re.lastIndex = 0;
97
119
  let m;
98
120
  while ((m = re.exec(line)) !== null) {
99
- if (isDistinctiveSelector(m[1], true))
100
- out.add(m[1]);
121
+ if (isDistinctiveSelector(m[valueGroup], true))
122
+ out.add(m[valueGroup]);
101
123
  if (out.size >= MAX_CHANGED_SELECTORS)
102
124
  return [...out];
103
125
  }
@@ -121,3 +143,172 @@ export function extractChangedSelectors(fileDiff) {
121
143
  }
122
144
  return [...out];
123
145
  }
146
+ /**
147
+ * Identifying `data-*` attributes on one source line: the trusted test-facing set (any
148
+ * casing), `getByTestId(...)` arguments (attributed to `data-testid`), and any other
149
+ * static `data-*` whose value is distinctive and whose attribute is not a state carrier.
150
+ */
151
+ function attributesOnLine(line) {
152
+ const out = [];
153
+ const seen = new Set();
154
+ const push = (attribute, value) => {
155
+ const key = `${attribute}=${value}`;
156
+ if (!seen.has(key)) {
157
+ seen.add(key);
158
+ out.push({ attribute, value });
159
+ }
160
+ };
161
+ let m;
162
+ TESTID_ATTR_RE.lastIndex = 0;
163
+ while ((m = TESTID_ATTR_RE.exec(line)) !== null) {
164
+ if (isDistinctiveSelector(m[2], true))
165
+ push(m[1].toLowerCase(), m[2]);
166
+ }
167
+ GET_BY_TEST_ID_RE.lastIndex = 0;
168
+ while ((m = GET_BY_TEST_ID_RE.exec(line)) !== null) {
169
+ if (isDistinctiveSelector(m[1], true))
170
+ push("data-testid", m[1]);
171
+ }
172
+ GENERIC_DATA_ATTR_RE.lastIndex = 0;
173
+ while ((m = GENERIC_DATA_ATTR_RE.exec(line)) !== null) {
174
+ const attribute = m[1].toLowerCase();
175
+ if (STATE_DATA_ATTRS.has(attribute))
176
+ continue;
177
+ if (seen.has(`${attribute}=${m[2]}`))
178
+ continue; // already captured by the trusted pass
179
+ if (isDistinctiveSelector(m[2], false))
180
+ push(attribute, m[2]);
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * Tag name of the JSX/HTML element a diff line opens, or "" — also "" for a comment
186
+ * line (`//`, `/*`, `*`, `{/*`, `<!--`), whose `<div …>` is text, not an element.
187
+ */
188
+ function openingTagOf(line) {
189
+ const t = line.trim();
190
+ if (/^(?:\/\/|\/\*|\*|\{\s*\/\*|<!--)/.test(t))
191
+ return "";
192
+ return /<\s*([A-Za-z][\w.-]*)/.exec(t)?.[1]?.toLowerCase() ?? "";
193
+ }
194
+ /** Identifying attribute VALUES that appear on `+` lines of a diff (any number of files). */
195
+ export function extractAddedAttributeValues(diff) {
196
+ const out = new Set();
197
+ for (const raw of diff.split("\n")) {
198
+ if (!raw.startsWith("+") || raw.startsWith("+++"))
199
+ continue;
200
+ for (const a of attributesOnLine(raw.slice(1)))
201
+ out.add(a.value);
202
+ }
203
+ return [...out];
204
+ }
205
+ /**
206
+ * Identifying `data-*` attributes one file's diff REMOVED: literals on `-` lines, minus the
207
+ * cases where the element still renders somewhere:
208
+ *
209
+ * - **moved / extracted** — the same value appears on a `+` line of this file or, via
210
+ * `addedValuesElsewhere`, anywhere else in the diff (a component extracted into a new
211
+ * file is the common shape);
212
+ * - **not an element** — a `-` line counts only when it opens a JSX/HTML tag or is a
213
+ * wrapped attribute line of a tag opened above it (the tag is carried until the `>`
214
+ * that closes it). A deleted comment, docs snippet, story arg or page-object
215
+ * `getByTestId(...)` call that merely mentions the attribute is not an element
216
+ * removal — the element itself may well survive on an unchanged context line;
217
+ * - **renamed** — a rename cannot be told apart by literal identity (old and new
218
+ * value differ by definition), so it is paired structurally: within the same hunk,
219
+ * each `-` line is paired with AT MOST ONE `+` line carrying a NEW identifying
220
+ * attribute — first by identical shape with the values blanked, else by the same
221
+ * opening tag — and a paired `+` line is consumed. So one added element suppresses
222
+ * one removal, not every removal in the hunk. This still errs toward suppressing a
223
+ * guard when one same-tag element is swapped for an unrelated new one.
224
+ *
225
+ * Feeds `uiContext.removedElements` (SKYR-4305) — the grounding for a removal guard
226
+ * (`toBeHidden()` on `getByTestId(value)` for `data-testid`, on
227
+ * `locator('[<attribute>="<value>"]')` for any other `data-*`).
228
+ */
229
+ export function extractRemovedAttributes(fileDiff, addedValuesElsewhere = []) {
230
+ const addedAnywhere = new Set(addedValuesElsewhere);
231
+ for (const v of extractAddedAttributeValues(fileDiff))
232
+ addedAnywhere.add(v);
233
+ // Split into hunks so rename pairing stays local.
234
+ const hunks = [];
235
+ for (const raw of fileDiff.split("\n")) {
236
+ if (raw.startsWith("@@")) {
237
+ hunks.push([]);
238
+ continue;
239
+ }
240
+ if (hunks.length === 0)
241
+ hunks.push([]);
242
+ hunks[hunks.length - 1].push(raw);
243
+ }
244
+ const shapeOf = (line, attrs) => {
245
+ let shape = line.trim();
246
+ for (const a of attrs)
247
+ shape = shape.split(a.value).join("\u0000");
248
+ return shape;
249
+ };
250
+ const out = [];
251
+ const seen = new Set();
252
+ for (const hunk of hunks) {
253
+ const removedLines = [];
254
+ const addedNew = [];
255
+ // A JSX attribute wrapped onto its own line (`+ data-testid="x"`) belongs to the
256
+ // element opened on an earlier line of the same side; carry that tag forward
257
+ // until the `>` that closes the opening tag, so a later comment or unrelated
258
+ // line in the hunk does not inherit it.
259
+ let lastMinusTag = "";
260
+ let lastPlusTag = "";
261
+ for (const raw of hunk) {
262
+ if (raw.startsWith("+++") || raw.startsWith("---"))
263
+ continue;
264
+ if (!(raw.startsWith("+") || raw.startsWith("-")))
265
+ continue;
266
+ const line = raw.slice(1);
267
+ const ownTag = openingTagOf(line);
268
+ const minus = raw.startsWith("-");
269
+ if (ownTag) {
270
+ if (minus)
271
+ lastMinusTag = ownTag;
272
+ else
273
+ lastPlusTag = ownTag;
274
+ }
275
+ const tag = ownTag || (minus ? lastMinusTag : lastPlusTag);
276
+ if (line.includes(">")) {
277
+ if (minus)
278
+ lastMinusTag = "";
279
+ else
280
+ lastPlusTag = "";
281
+ }
282
+ const attrs = attributesOnLine(line);
283
+ if (attrs.length === 0)
284
+ continue;
285
+ // Only an element (a tag opened on this line or carried from above) can be a
286
+ // removed element; a bare mention in a comment / docs / page object is not.
287
+ if (minus && tag === "")
288
+ continue;
289
+ if (minus)
290
+ removedLines.push({ attrs, tag, shape: shapeOf(line, attrs) });
291
+ else
292
+ addedNew.push({ tag, shape: shapeOf(line, attrs), consumed: false });
293
+ }
294
+ // Pair each removed line with at most ONE new-side line: identical blanked shape
295
+ // first, else the same opening tag. A paired + line is consumed, so one added
296
+ // element suppresses one removal (a moved value is handled by addedAnywhere).
297
+ for (const r of removedLines) {
298
+ const partner = addedNew.find((a) => !a.consumed && a.shape === r.shape)
299
+ ?? addedNew.find((a) => !a.consumed && a.tag === r.tag);
300
+ if (partner) {
301
+ partner.consumed = true;
302
+ continue;
303
+ }
304
+ for (const a of r.attrs) {
305
+ const key = `${a.attribute}=${a.value}`;
306
+ if (!addedAnywhere.has(a.value) && !seen.has(key)) {
307
+ seen.add(key);
308
+ out.push(a);
309
+ }
310
+ }
311
+ }
312
+ }
313
+ return out;
314
+ }
@@ -22,7 +22,10 @@ export declare function isIndexModule(file: string): boolean;
22
22
  * (`@/x`, `~/x`, `~~/x`, `#/x`, `src/x`) are accepted when the module directory
23
23
  * ends with the aliased path AND the importer lives under the same root that the
24
24
  * alias maps to — `packages/store/Page.tsx` importing `@/app/shop/_constants` does
25
- * not match `packages/admin/app/shop/_constants/index.ts`. All paths are
25
+ * not match `packages/admin/app/shop/_constants/index.ts`. Prefix-less
26
+ * multi-segment specifiers (`Components/Foo` under tsconfig `baseUrl: "src"` —
27
+ * SKYR-4287) go through the same suffix + shared-root check; only single-segment
28
+ * prefix-less specifiers (`react`) are treated as npm packages. All paths are
26
29
  * repo-relative with forward slashes.
27
30
  */
28
31
  export declare function importsIndexModule(importerFile: string, importerContent: string, indexFile: string): boolean;
@@ -60,7 +60,10 @@ export function isIndexModule(file) {
60
60
  * (`@/x`, `~/x`, `~~/x`, `#/x`, `src/x`) are accepted when the module directory
61
61
  * ends with the aliased path AND the importer lives under the same root that the
62
62
  * alias maps to — `packages/store/Page.tsx` importing `@/app/shop/_constants` does
63
- * not match `packages/admin/app/shop/_constants/index.ts`. All paths are
63
+ * not match `packages/admin/app/shop/_constants/index.ts`. Prefix-less
64
+ * multi-segment specifiers (`Components/Foo` under tsconfig `baseUrl: "src"` —
65
+ * SKYR-4287) go through the same suffix + shared-root check; only single-segment
66
+ * prefix-less specifiers (`react`) are treated as npm packages. All paths are
64
67
  * repo-relative with forward slashes.
65
68
  */
66
69
  export function importsIndexModule(importerFile, importerContent, indexFile) {
@@ -76,7 +79,16 @@ export function importsIndexModule(importerFile, importerContent, indexFile) {
76
79
  continue;
77
80
  }
78
81
  const aliased = spec.replace(/^(@\/|~~\/|~\/|#\/|src\/)/, "");
79
- if (aliased === spec)
82
+ // A prefix-less specifier is only a bare npm package when it has a single
83
+ // segment ('react'). Multi-segment prefix-less specifiers are how tsconfig
84
+ // `baseUrl` repos import internal modules ('Components/Foo' with
85
+ // baseUrl: "src" — SKYR-4287: every folder-per-component index.tsx in such
86
+ // repos was misreported as no-importers). Those flow into the same
87
+ // suffix + shared-root check as aliases; a deep npm import ('lodash/get')
88
+ // is unlikely to match unless the repo actually contains a `lodash/get`
89
+ // directory under the same root, and a scoped package ('@scope/pkg') would
90
+ // require a source directory literally named '@scope'.
91
+ if (aliased === spec && !spec.includes("/"))
80
92
  continue; // bare package specifier — not a repo path
81
93
  if (moduleDir === aliased)
82
94
  return true; // alias maps to the repo root
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Plan-only mode capture for `skyramp_submit_report` (SKYR-4250).
3
+ *
4
+ * A plan-only eval run (SKYR-3879) writes no test files: `newTestsCreated`
5
+ * DECLARES the approved GENERATE selection instead of recording generated
6
+ * files, and the declaration itself is the deliverable. The SKYR-3883 guard
7
+ * (`findUnchangedFileClaims`) verifies every `newTestsCreated` claim against
8
+ * the working tree, so in that lane EVERY declaration is unbacked by
9
+ * construction and the report is rejected.
10
+ *
11
+ * That rejection is not evenly distributed. `findUnchangedFileClaims` exempts a
12
+ * claim attributed to a non-primary repo, so on a cross-repo fixture the
13
+ * related repo's declarations pass and only the primary repo's are checked.
14
+ * On a frontend-primary pair that makes the mandatory UI item (SKYR-4204's
15
+ * floor) the one claim that always fails, and the agent's only compliant move
16
+ * is to demote it to `additionalRecommendations` — which is exactly the
17
+ * under-declaration SKYR-4250 exists to fix (run 32903633247).
18
+ *
19
+ * `planOnly` reaches the server only as a testbot prompt/resource arg, so this
20
+ * mirrors the SKYR-4185 report-language capture: the prompt render is the
21
+ * single capture point, and the report tool reads the captured mode later in
22
+ * the same process.
23
+ *
24
+ * Capture is fail-open by design: if the testbot prompt was never served, the
25
+ * mode stays false and the guard behaves exactly as it does today.
26
+ */
27
+ /** Capture plan-only mode at prompt-serve time. Last render wins: a later
28
+ * non-plan-only render disarms a mode captured earlier in a long-lived server
29
+ * process, so an ordinary run never silently skips the guard. */
30
+ export declare function setPlanOnlyMode(planOnly: boolean): void;
31
+ export declare function isPlanOnlyMode(): boolean;
32
+ /** Test isolation only — module state persists across tests in one process. */
33
+ export declare function resetPlanOnlyMode(): void;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Plan-only mode capture for `skyramp_submit_report` (SKYR-4250).
3
+ *
4
+ * A plan-only eval run (SKYR-3879) writes no test files: `newTestsCreated`
5
+ * DECLARES the approved GENERATE selection instead of recording generated
6
+ * files, and the declaration itself is the deliverable. The SKYR-3883 guard
7
+ * (`findUnchangedFileClaims`) verifies every `newTestsCreated` claim against
8
+ * the working tree, so in that lane EVERY declaration is unbacked by
9
+ * construction and the report is rejected.
10
+ *
11
+ * That rejection is not evenly distributed. `findUnchangedFileClaims` exempts a
12
+ * claim attributed to a non-primary repo, so on a cross-repo fixture the
13
+ * related repo's declarations pass and only the primary repo's are checked.
14
+ * On a frontend-primary pair that makes the mandatory UI item (SKYR-4204's
15
+ * floor) the one claim that always fails, and the agent's only compliant move
16
+ * is to demote it to `additionalRecommendations` — which is exactly the
17
+ * under-declaration SKYR-4250 exists to fix (run 32903633247).
18
+ *
19
+ * `planOnly` reaches the server only as a testbot prompt/resource arg, so this
20
+ * mirrors the SKYR-4185 report-language capture: the prompt render is the
21
+ * single capture point, and the report tool reads the captured mode later in
22
+ * the same process.
23
+ *
24
+ * Capture is fail-open by design: if the testbot prompt was never served, the
25
+ * mode stays false and the guard behaves exactly as it does today.
26
+ */
27
+ let sessionPlanOnly = false;
28
+ /** Capture plan-only mode at prompt-serve time. Last render wins: a later
29
+ * non-plan-only render disarms a mode captured earlier in a long-lived server
30
+ * process, so an ordinary run never silently skips the guard. */
31
+ export function setPlanOnlyMode(planOnly) {
32
+ sessionPlanOnly = planOnly;
33
+ }
34
+ export function isPlanOnlyMode() {
35
+ return sessionPlanOnly;
36
+ }
37
+ /** Test isolation only — module state persists across tests in one process. */
38
+ export function resetPlanOnlyMode() {
39
+ sessionPlanOnly = false;
40
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Visual-snapshot baseline names as a Skyramp test passes them to
4
+ * `toHaveScreenshot()` — e.g. `page-001.png` — and as `skyramp_actions` and
5
+ * `skyramp_execute_test` accept them in `rebaselineSnapshots` (SKYR-4298).
6
+ *
7
+ * Bare filename only: no path separators, and no commas or whitespace, because the
8
+ * list is joined with commas into `SKYRAMP_UPDATE_SNAPSHOTS` and split again in the
9
+ * executor, where `a,b.png` would read as two baselines. One definition shared by
10
+ * both tools so they can never drift apart.
11
+ */
12
+ export declare const REBASELINE_SNAPSHOT_NAME_RE: RegExp;
13
+ export declare const rebaselineSnapshotNameSchema: z.ZodString;
14
+ /** `page-001.png` → `page-001`. */
15
+ export declare function baselineStem(name: string): string;
16
+ export declare function baselineFileMatchesStem(fileBasename: string, stem: string): boolean;
17
+ /** The directory Playwright keeps a spec's baselines in under the default layout. */
18
+ export declare function snapshotDirFor(specFile: string): string;
19
+ /** Looks like an on-disk baseline filename (`page-001-chromium-linux.png`) rather than the
20
+ * name the test passes (`page-001.png`) — the likeliest wrong input, and one the name
21
+ * schema accepts. Advisory only: `hero-linux.png` is also a perfectly valid name a test
22
+ * can pass, so callers warn and keep the entry; execution authorizes it against the
23
+ * verdict and reports whether anything was actually rewritten. */
24
+ export declare function looksLikeOnDiskBaselineName(name: string): boolean;
@@ -0,0 +1,65 @@
1
+ import path from "path";
2
+ import { z } from "zod";
3
+ /**
4
+ * Visual-snapshot baseline names as a Skyramp test passes them to
5
+ * `toHaveScreenshot()` — e.g. `page-001.png` — and as `skyramp_actions` and
6
+ * `skyramp_execute_test` accept them in `rebaselineSnapshots` (SKYR-4298).
7
+ *
8
+ * Bare filename only: no path separators, and no commas or whitespace, because the
9
+ * list is joined with commas into `SKYRAMP_UPDATE_SNAPSHOTS` and split again in the
10
+ * executor, where `a,b.png` would read as two baselines. One definition shared by
11
+ * both tools so they can never drift apart.
12
+ */
13
+ export const REBASELINE_SNAPSHOT_NAME_RE = /^[^/\\,\s]+\.png$/i;
14
+ export const rebaselineSnapshotNameSchema = z
15
+ .string()
16
+ .trim()
17
+ .regex(REBASELINE_SNAPSHOT_NAME_RE, {
18
+ message: 'rebaselineSnapshots entries must be bare .png filenames as passed to toHaveScreenshot(), e.g. "page-001.png" — no path, commas, or whitespace',
19
+ });
20
+ /** `page-001.png` → `page-001`. */
21
+ export function baselineStem(name) {
22
+ return path.basename(name).replace(/\.png$/i, "");
23
+ }
24
+ /**
25
+ * Playwright's default snapshot layout, which is what the executor produces: the
26
+ * baseline for `<stem>.png` lives in `<spec>-snapshots/` as `<stem>-<project>-<platform>.png`
27
+ * (or `<stem>-<platform>.png` with no project, or `<stem>.png` under a custom
28
+ * `snapshotPathTemplate`). Safe to assume here because TestExecutionService mounts
29
+ * its own generated Playwright config over any the repo carries, so a repo cannot
30
+ * relocate its snapshots out from under this rule.
31
+ *
32
+ * The executor's generated config declares no `projects`, but runner.sh passes
33
+ * `--browser=chromium`, so Playwright names the implicit project "chromium" and a
34
+ * refresh writes `<stem>-chromium-linux.png` (observed end to end on demoshop).
35
+ * Baselines the Skyramp executor produced therefore round-trip exactly. A baseline
36
+ * a repo's own CI wrote under another project name (`<stem>-Desktop-Chrome-linux.png`)
37
+ * is a different file: the executor's refresh adds its own and leaves that one as
38
+ * it was — the tool reports what it rewrote, and the repo's own suite is out of
39
+ * scope for skyramp_execute_test (which never runs it).
40
+ *
41
+ * Anchored so a longer baseline cannot vouch for a shorter one: `page-001-wide-chromium-linux.png`
42
+ * belongs to `page-001-wide.png` and must not satisfy a claim on `page-001.png`.
43
+ */
44
+ const SNAPSHOT_PLATFORMS = "(?:darwin|linux|win32)";
45
+ export function baselineFileMatchesStem(fileBasename, stem) {
46
+ // A degenerate stem ("" or dots only, from a name like "..png") would match a wide
47
+ // set of files; nothing legitimate has one.
48
+ if (!stem || /^\.+$/.test(stem))
49
+ return false;
50
+ const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ const re = new RegExp(`^${escaped}(?:-[^-]+-${SNAPSHOT_PLATFORMS}|-${SNAPSHOT_PLATFORMS})?\\.png$`, "i");
52
+ return re.test(fileBasename);
53
+ }
54
+ /** The directory Playwright keeps a spec's baselines in under the default layout. */
55
+ export function snapshotDirFor(specFile) {
56
+ return `${specFile}-snapshots`;
57
+ }
58
+ /** Looks like an on-disk baseline filename (`page-001-chromium-linux.png`) rather than the
59
+ * name the test passes (`page-001.png`) — the likeliest wrong input, and one the name
60
+ * schema accepts. Advisory only: `hero-linux.png` is also a perfectly valid name a test
61
+ * can pass, so callers warn and keep the entry; execution authorizes it against the
62
+ * verdict and reports whether anything was actually rewritten. */
63
+ export function looksLikeOnDiskBaselineName(name) {
64
+ return /-(?:[^-]+-)?(?:darwin|linux|win32)\.png$/i.test(name);
65
+ }
@@ -0,0 +1,34 @@
1
+ import type { CandidateUiPage } from "./uiPageEnumerator.js";
2
+ import type { FrontendFileIntegration } from "../types/FrontendIntegration.js";
3
+ export interface RemovedUiElement {
4
+ /** Lower-cased identifying attribute: `data-testid`, `data-cy`, `data-qa`, `data-section`, … */
5
+ attribute: string;
6
+ /** Its literal value. `attribute` + `value` is unique across the result. */
7
+ value: string;
8
+ /** Repo-relative frontend file the diff removed it from. */
9
+ file: string;
10
+ /** Candidate UI pages attributed to that file (directly, or via its importers). */
11
+ pageUrls: string[];
12
+ }
13
+ /**
14
+ * Bound the list: each entry is a guard assertion the agent records on one page, and
15
+ * the plan budget is small (`MAX_TESTS_TO_GENERATE`), so a large cleanup PR gets its
16
+ * first few ids, not a flood. Truncation is logged at info with the dropped count.
17
+ */
18
+ export declare const MAX_REMOVED_UI_ELEMENTS = 5;
19
+ export interface CollectRemovedUiElementsInput {
20
+ changedFrontendFiles: string[];
21
+ /** Files with `+++ /dev/null` in the diff — their page is gone, no guard. */
22
+ deletedFiles: string[];
23
+ /** Per-file diff slices for the WHOLE diff keyed by repo-relative path (see sliceDiffByFile). */
24
+ diffByFile: Map<string, string>;
25
+ frontendFileIntegration?: FrontendFileIntegration[];
26
+ candidateUiPages: CandidateUiPage[];
27
+ /** Absolute paths of discovered existing tests (skyramp + relevant external). */
28
+ existingTestFiles: string[];
29
+ /** True when test discovery threw — the existing-test check cannot run, so report nothing. */
30
+ discoveryFailed?: boolean;
31
+ /** Injectable for tests; defaults to a tolerant fs read that logs failures. */
32
+ readFile?: (absPath: string) => string | undefined;
33
+ }
34
+ export declare function collectRemovedUiElements(input: CollectRemovedUiElementsInput): RemovedUiElement[];