@skyramp/mcp 0.3.8 → 0.4.0-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 (229) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -1
  2. package/build/commands/commandLibrary.js +3 -3
  3. package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
  4. package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
  5. package/build/commands/testThisEndpointCommand.js +35 -19
  6. package/build/index.js +9 -3
  7. package/build/playwright/blueprintDigest.d.ts +15 -0
  8. package/build/playwright/blueprintDigest.js +152 -0
  9. package/build/playwright/blueprintDigestStore.d.ts +31 -0
  10. package/build/playwright/blueprintDigestStore.js +117 -0
  11. package/build/playwright/registerPlaywrightTools.js +60 -12
  12. package/build/playwright/traceRecordingPrompt.js +8 -7
  13. package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
  14. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
  15. package/build/prompts/promptAssets.d.ts +20 -0
  16. package/build/prompts/promptAssets.js +55 -0
  17. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
  18. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  19. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
  23. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
  24. package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
  25. package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
  26. package/build/prompts/test-recommendation/recommendationSections.js +67 -309
  27. package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
  28. package/build/prompts/test-recommendation/recommendationShared.js +49 -155
  29. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
  30. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
  31. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
  32. package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
  33. package/build/prompts/testbot/planDeclarations.d.ts +6 -0
  34. package/build/prompts/testbot/planDeclarations.js +9 -0
  35. package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
  36. package/build/prompts/testbot/testbot-prompts.js +256 -381
  37. package/build/recommendation/answers.d.ts +35 -0
  38. package/build/recommendation/answers.js +96 -0
  39. package/build/recommendation/registerPlan.d.ts +49 -0
  40. package/build/recommendation/registerPlan.js +117 -0
  41. package/build/recommendation/runVerifiers.d.ts +10 -0
  42. package/build/recommendation/runVerifiers.js +49 -0
  43. package/build/recommendation/subjectStep.d.ts +42 -0
  44. package/build/recommendation/subjectStep.js +86 -0
  45. package/build/recommendation/types.d.ts +163 -0
  46. package/build/recommendation/types.js +20 -0
  47. package/build/recommendation/verifierContracts.d.ts +382 -0
  48. package/build/recommendation/verifierContracts.js +263 -0
  49. package/build/recommendation/verifiers/changedFile.d.ts +2 -0
  50. package/build/recommendation/verifiers/changedFile.js +82 -0
  51. package/build/recommendation/verifiers/citedPath.d.ts +12 -0
  52. package/build/recommendation/verifiers/citedPath.js +35 -0
  53. package/build/recommendation/verifiers/coverage.d.ts +7 -0
  54. package/build/recommendation/verifiers/coverage.js +617 -0
  55. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
  56. package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
  57. package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
  58. package/build/recommendation/verifiers/endpointGrounded.js +128 -0
  59. package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
  60. package/build/recommendation/verifiers/existingCoverage.js +51 -0
  61. package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
  62. package/build/recommendation/verifiers/expectedOutcome.js +105 -0
  63. package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
  64. package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
  65. package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
  66. package/build/recommendation/verifiers/reportedCategory.js +84 -0
  67. package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
  68. package/build/recommendation/verifiers/screenRoute.js +118 -0
  69. package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
  70. package/build/recommendation/verifiers/statedDifference.js +140 -0
  71. package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
  72. package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
  73. package/build/resources/analysisResources.js +1 -114
  74. package/build/resources/testbotResource.js +23 -13
  75. package/build/services/ModularizationService.js +2 -1
  76. package/build/services/TestDiscoveryService.d.ts +3 -72
  77. package/build/services/TestDiscoveryService.js +10 -303
  78. package/build/services/containerEnv.d.ts +1 -1
  79. package/build/services/containerEnv.js +12 -0
  80. package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
  81. package/build/skills/fixTestImportErrorsSkill.js +20 -0
  82. package/build/toolNames.d.ts +1 -0
  83. package/build/toolNames.js +1 -0
  84. package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
  85. package/build/tools/code-refactor/modularizationTool.js +2 -1
  86. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  87. package/build/tools/executeSkyrampTestTool.js +246 -19
  88. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
  89. package/build/tools/generate-tests/generateContractRestTool.js +3 -3
  90. package/build/tools/generate-tests/planGuard.d.ts +2 -2
  91. package/build/tools/generate-tests/planGuard.js +78 -18
  92. package/build/tools/one-click/oneClickTool.d.ts +0 -1
  93. package/build/tools/one-click/oneClickTool.js +0 -5
  94. package/build/tools/submitReportTool.d.ts +48 -42
  95. package/build/tools/submitReportTool.js +576 -193
  96. package/build/tools/test-management/actionsTool.js +72 -4
  97. package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
  98. package/build/tools/test-management/analyzeChangesTool.js +212 -1219
  99. package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
  100. package/build/tools/test-management/index.d.ts +1 -0
  101. package/build/tools/test-management/index.js +1 -0
  102. package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
  103. package/build/tools/test-management/registerTestPlanTool.js +609 -542
  104. package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
  105. package/build/tools/test-management/resolveScreenTool.js +289 -0
  106. package/build/types/BlueprintDigest.d.ts +34 -0
  107. package/build/types/BlueprintDigest.js +1 -0
  108. package/build/types/RepositoryAnalysis.d.ts +20 -1559
  109. package/build/types/RepositoryAnalysis.js +2 -58
  110. package/build/types/StepMethod.d.ts +40 -0
  111. package/build/types/StepMethod.js +77 -0
  112. package/build/types/TestAnalysis.d.ts +12 -0
  113. package/build/types/TestExecution.d.ts +4 -0
  114. package/build/types/TestRecommendation.d.ts +24 -24
  115. package/build/types/TestRecommendation.js +91 -89
  116. package/build/types/TestbotPromptOptions.d.ts +0 -4
  117. package/build/types/TestbotReport.d.ts +64 -2
  118. package/build/utils/AnalysisStateManager.d.ts +79 -113
  119. package/build/utils/AnalysisStateManager.js +147 -57
  120. package/build/utils/assertion-verify/api-shared-lints.js +1 -1
  121. package/build/utils/assertion-verify/metrics.js +85 -36
  122. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  123. package/build/utils/assertion-verify/ui-lints.js +32 -0
  124. package/build/utils/branchDiff.d.ts +63 -31
  125. package/build/utils/branchDiff.js +242 -94
  126. package/build/utils/containedPath.d.ts +18 -0
  127. package/build/utils/containedPath.js +73 -0
  128. package/build/utils/dartRouteExtractor.d.ts +18 -34
  129. package/build/utils/dartRouteExtractor.js +101 -173
  130. package/build/utils/featureFlags.d.ts +12 -0
  131. package/build/utils/featureFlags.js +14 -0
  132. package/build/utils/frontendSelectors.d.ts +48 -27
  133. package/build/utils/frontendSelectors.js +241 -80
  134. package/build/utils/pathMatching.d.ts +2 -4
  135. package/build/utils/pathMatching.js +2 -4
  136. package/build/utils/planMatchKeys.d.ts +38 -47
  137. package/build/utils/planMatchKeys.js +143 -81
  138. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  139. package/build/utils/rebaselineSnapshots.js +65 -0
  140. package/build/utils/removedUiElements.d.ts +22 -0
  141. package/build/utils/removedUiElements.js +106 -0
  142. package/build/utils/reportVerification.d.ts +2 -6
  143. package/build/utils/reportVerification.js +61 -2
  144. package/build/utils/screenRoutes.d.ts +66 -0
  145. package/build/utils/screenRoutes.js +727 -0
  146. package/build/utils/sourceRouteExtractor.js +320 -112
  147. package/build/utils/testFileClassification.d.ts +11 -2
  148. package/build/utils/testFileClassification.js +44 -2
  149. package/build/utils/testFixtures.d.ts +5 -0
  150. package/build/utils/testFixtures.js +13 -0
  151. package/build/utils/utils.d.ts +0 -1
  152. package/build/utils/utils.js +0 -11
  153. package/build/utils/versions.d.ts +3 -3
  154. package/build/utils/versions.js +1 -1
  155. package/build/workspace/workspace.d.ts +12 -12
  156. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  157. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  158. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  160. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  161. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  162. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  168. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  171. package/node_modules/playwright/package.json +1 -1
  172. package/package.json +4 -3
  173. package/plugin/.claude-plugin/plugin.json +8 -0
  174. package/plugin/plugin.json +6 -0
  175. package/plugin/prompts/declaring-a-plan.md +20 -0
  176. package/plugin/prompts/generate-tests/context-fetching.md +4 -0
  177. package/plugin/prompts/generate-tests/execution-plan.md +63 -0
  178. package/plugin/prompts/generate-tests/generation.md +108 -0
  179. package/plugin/prompts/generate-tests/path-parameters.md +1 -0
  180. package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
  181. package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
  182. package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
  183. package/plugin/prompts/plan-tests.md +42 -0
  184. package/plugin/prompts/testbot-task1.md +82 -0
  185. package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
  186. package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
  187. package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
  188. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
  189. package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
  190. package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
  191. package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
  192. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
  193. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
  194. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
  195. package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
  196. package/build/recommendation/budgeters/shared.d.ts +0 -32
  197. package/build/recommendation/budgeters/shared.js +0 -246
  198. package/build/recommendation/discriminators.d.ts +0 -37
  199. package/build/recommendation/discriminators.js +0 -379
  200. package/build/recommendation/diversity.d.ts +0 -47
  201. package/build/recommendation/diversity.js +0 -101
  202. package/build/recommendation/planRanker.d.ts +0 -65
  203. package/build/recommendation/planRanker.js +0 -83
  204. package/build/recommendation/testFixtures.d.ts +0 -25
  205. package/build/recommendation/testFixtures.js +0 -45
  206. package/build/types/FrontendIntegration.d.ts +0 -28
  207. package/build/types/FrontendIntegration.js +0 -22
  208. package/build/types/Recommendation.d.ts +0 -146
  209. package/build/types/Recommendation.js +0 -74
  210. package/build/utils/changedRoutes.d.ts +0 -29
  211. package/build/utils/changedRoutes.js +0 -87
  212. package/build/utils/frontendIntegration.d.ts +0 -9
  213. package/build/utils/frontendIntegration.js +0 -243
  214. package/build/utils/importerHop.d.ts +0 -135
  215. package/build/utils/importerHop.js +0 -489
  216. package/build/utils/pathAffinityClassification.d.ts +0 -49
  217. package/build/utils/pathAffinityClassification.js +0 -180
  218. package/build/utils/pythonMountPrefixes.d.ts +0 -25
  219. package/build/utils/pythonMountPrefixes.js +0 -347
  220. package/build/utils/repoScanner.d.ts +0 -34
  221. package/build/utils/repoScanner.js +0 -300
  222. package/build/utils/routeParsers.d.ts +0 -95
  223. package/build/utils/routeParsers.js +0 -951
  224. package/build/utils/scenarioDrafting.d.ts +0 -92
  225. package/build/utils/scenarioDrafting.js +0 -951
  226. package/build/utils/subjectEndpoints.d.ts +0 -19
  227. package/build/utils/subjectEndpoints.js +0 -98
  228. package/build/utils/uiPageEnumerator.d.ts +0 -172
  229. package/build/utils/uiPageEnumerator.js +0 -474
@@ -1,20 +1,16 @@
1
1
  /**
2
- * SKYR-3879 Path B — shared match-key computation for the register-plan
3
- * checkpoint. `computeMatchKeys` runs ONCE, at approval time (inside
4
- * registerTestPlanTool / the analyze_changes pre-seed), so the two
5
- * consumers — planGuard (generation-tool hard gate) and submitReportTool
6
- * (report cross-check) — only ever do a Set/array membership check, never
7
- * re-derive normalization logic against a live candidate.
2
+ * Shared match-key computation for the generation-tool plan gate. The generation
3
+ * tools are called with a scenario name and an endpoint, never a `plannedTestId`,
4
+ * so this joins a call back to a plan planned test; the report compares ids.
8
5
  *
9
6
  * Two kinds of key:
10
7
  * - `name:<slug>` — normalized scenarioName, for multi-step
11
- * candidates (batch-scenario / integration) where the agent's own name is
8
+ * planned tests (batch-scenario / integration) where the agent's own name is
12
9
  * the natural join key.
13
10
  * - `ep:<testType>:<METHOD>:<path>` — one per step, for single-endpoint
14
11
  * matching (contract tests, or a fallback when names drift between
15
12
  * register and generate).
16
13
  */
17
- import { isParamSegment, isOpaqueIdSegment } from "./routeParsers.js";
18
14
  /** Lowercase, collapse non-alphanumeric runs to '-', trim leading/trailing '-'. */
19
15
  export function slugifyName(name) {
20
16
  return (name ?? "")
@@ -23,25 +19,45 @@ export function slugifyName(name) {
23
19
  .replace(/^-+|-+$/g, "");
24
20
  }
25
21
  /**
26
- * Normalize a path so param/id segments collapse: `{brace}`, `:colon`,
27
- * `[bracket]` params in any of routeParsers.ts's syntaxes, plus numeric,
28
- * UUID, and long-hex id VALUES, all become `:p`. Delegates to
29
- * routeParsers.ts's `isParamSegment`/`isOpaqueIdSegment` (SKYR-4214 task 5)
30
- * rather than a second inline copy — this module previously had its own
31
- * looser hex rule (`/^[0-9a-fA-F-]{16,}$/`, accepting `-`) and no
32
- * bracket-syntax rule at all, so a Next.js `[id]` segment never collapsed
33
- * and kept a match key from matching its `{id}`-spelled counterpart (the
34
- * SKYR-4123 failure mode: a generated test's key stops matching its plan
35
- * item). The characterization test in
36
- * `pathSegmentClassification.characterization.test.ts` confirms every
37
- * previously-agreeing input (UUID, numeric, 16+/24-char hex) still
38
- * collapses to `:p` after this change — safe within one run because the
39
- * register-time key and the generation-time query are computed by the same
40
- * build; a plan file is never persisted across builds. Mirrors
41
- * discriminators.ts's normalizePath (duplicated rather than imported —
42
- * that function verifies discriminator claims against a diff, a different
43
- * concern from plan matching, and keeping them independent avoids coupling
44
- * this module's stability to phase 1's internal helper).
22
+ * One path segment that names a parameter rather than a resource, in any of the
23
+ * route syntaxes a repository writes: OpenAPI "{id}", Express/NestJS ":id", and Next.js
24
+ * "[id]" / "[...id]" / "[[...id]]" (the double-bracket form needs its own
25
+ * alternative — it contains an inner "]", which the single-bracket form's
26
+ * [^\]]+ can never span).
27
+ */
28
+ // The colon form may carry an Express/Fastify constraint or modifier —
29
+ // `:id(\d+)`, `:id?`, `:id*`, `:id+`. Those are still parameters, and a
30
+ // resource name is never one, so the predicate has to accept them, or a
31
+ // constrained param stops collapsing and its route stops matching.
32
+ const PARAM_SEGMENT_RE = /^\{[^}]+\}$|^:[A-Za-z_]\w*(?:\([^)]*\))?[?*+]?$|^\[\[[^\]]+\]\]$|^\[[^\]]+\]$/;
33
+ export function isParamSegment(segment) {
34
+ return PARAM_SEGMENT_RE.test(segment);
35
+ }
36
+ /**
37
+ * One path segment that is an id VALUE rather than a name: a UUID, digits only,
38
+ * or a long hex run (a Mongo ObjectId is 24). A caller writes a literal id in a
39
+ * path where the route declares a parameter, and an id can never be a resource
40
+ * name, so resource derivation must skip it exactly as it skips a parameter.
41
+ * SKYR-4214: an accepted UUID produced the key
42
+ * "DELETE::a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11::integration", which matches
43
+ * nothing, so the planned test escaped dedup entirely.
44
+ *
45
+ * The 16-character floor on the hex rule keeps real resource names safe: a name
46
+ * built only from the letters a-f and digits, 16 characters or longer, is not a
47
+ * word.
48
+ */
49
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
50
+ export function isOpaqueIdSegment(segment) {
51
+ // The sign belongs to the value: an edge-case step writes `/orders/-1` to
52
+ // probe a negative id, and without the sign that segment became the resource
53
+ // name, so the key could never match real order coverage.
54
+ return UUID_RE.test(segment) || /^-?\d+$/.test(segment) || /^[0-9a-f]{16,}$/i.test(segment);
55
+ }
56
+ /**
57
+ * Normalize a path so param and id segments collapse to `:p`.
58
+ *
59
+ * The two predicates above are the only rule for it — an earlier inline copy had
60
+ * no bracket rule, so a Next.js `[id]` stopped matching its `{id}` spelling.
45
61
  */
46
62
  export function normalizeMatchPath(rawPath) {
47
63
  const pathPart = (rawPath ?? "").split("?")[0];
@@ -50,58 +66,43 @@ export function normalizeMatchPath(rawPath) {
50
66
  .map((seg) => {
51
67
  if (seg === "")
52
68
  return seg;
53
- // `isParamSegment` covers the well-formed colon family — `:id`,
54
- // `:id(\d+)`, `:id?`, `:id*`, `:id+`. The `/^:/` test in front of it is
55
- // deliberate breadth for the colon segments that regex rejects, such as
56
- // `:1abc` or `:id-x`: a malformed param must still normalize to `:p`,
57
- // or a registered `/users/:1abc` stops endpoint-matching `/users/42`.
69
+ // `isParamSegment` covers only well-formed colon params. The broader
70
+ // `/^:/` test in front of it catches the ones that regex rejects, such as
71
+ // `:1abc` or `:id-x`: a malformed param must still normalize to `:p`, or
72
+ // a registered `/users/:1abc` stops endpoint-matching `/users/42`.
58
73
  if (/^:/.test(seg) || isParamSegment(seg) || isOpaqueIdSegment(seg))
59
74
  return ":p";
60
75
  return seg.toLowerCase();
61
76
  })
62
77
  .join("/");
63
78
  }
79
+ /** The scenario is read back from the stored plan, so `steps` is an array only on
80
+ * the validated path. `?? []` keeps a malformed plan from costing the call. */
64
81
  function inferTestType(scenario) {
65
- return (scenario.testType ?? (scenario.steps.length === 1 ? "contract" : "integration")).toLowerCase();
82
+ const steps = Array.isArray(scenario?.steps) ? scenario.steps : [];
83
+ return (scenario?.testType ?? (steps.length === 1 ? "contract" : "integration")).toLowerCase();
66
84
  }
67
- /** Name key (if a name exists) plus one endpoint key per step. */
85
+ /** Name key (if a name exists) plus one endpoint key per step. BOTH kinds carry
86
+ * the planned test's test type, or a contract call matches an integration-only
87
+ * planned test sharing its name. A slug holds no `:`. */
68
88
  export function computeMatchKeys(scenario) {
69
89
  const keys = [];
70
- const slug = slugifyName(scenario.scenarioName);
71
- if (slug)
72
- keys.push(`name:${slug}`);
73
90
  const testType = inferTestType(scenario);
74
- for (const step of scenario.steps ?? []) {
91
+ const slug = slugifyName(scenario?.scenarioName);
92
+ if (slug)
93
+ keys.push(`name:${testType}:${slug}`);
94
+ for (const step of Array.isArray(scenario?.steps) ? scenario.steps : []) {
75
95
  if (!step?.method || !step?.path)
76
96
  continue;
77
97
  keys.push(`ep:${testType}:${step.method.toUpperCase()}:${normalizeMatchPath(step.path)}`);
78
98
  }
79
99
  return [...new Set(keys)];
80
100
  }
81
- /** Build the persisted plan item from a ranked Candidate. */
82
- export function buildApprovedPlanItem(candidate) {
83
- return {
84
- candidateId: candidate.candidateId,
85
- scenarioName: candidate.scenario.scenarioName,
86
- testType: inferTestType(candidate.scenario),
87
- category: candidate.scenario.category,
88
- source: candidate.source,
89
- ...(candidate.verifiedDiscriminator ? { verifiedDiscriminator: candidate.verifiedDiscriminator } : {}),
90
- ...(candidate.scenario.subjectEndpoints !== undefined
91
- ? { subjectEndpoints: candidate.scenario.subjectEndpoints }
92
- : {}),
93
- matchKeys: computeMatchKeys(candidate.scenario),
94
- };
95
- }
96
101
  /**
97
- * Segment-aligned comparison of two normalized paths that tolerates a
98
- * base-path prefix on either side (SKYR-4019). Register-time keys and
99
- * generation-time queries derive their paths from different origins:
100
- * server-drafted scenario steps can carry router-relative paths
101
- * (`/orders/{id}`) while the generation tool's query comes from the live
102
- * endpointURL (`/api/v1/orders/{id}`). The shorter path matches when its
103
- * segments are a suffix of the longer path's segments — never mid-segment,
104
- * and never on an all-parameter path (`/:p` would match everything).
102
+ * Segment-aligned comparison of two normalized paths, tolerating a MOUNT prefix
103
+ * on either side. The shorter must be the whole tail of the longer, never
104
+ * mid-segment and never all-parameter, and the extra leading segments must hold
105
+ * no parameter — which separates `/api/v1` + `/orders` from `/tenants/{id}` + it.
105
106
  */
106
107
  function normalizedPathsMatch(a, b) {
107
108
  if (a === b)
@@ -114,21 +115,55 @@ function normalizedPathsMatch(a, b) {
114
115
  if (shorter.every((seg) => seg === ":p"))
115
116
  return false;
116
117
  const offset = longer.length - shorter.length;
117
- return shorter.every((seg, i) => longer[offset + i] === seg);
118
+ if (!shorter.every((seg, i) => longer[offset + i] === seg))
119
+ return false;
120
+ // The extra leading segments are the planned test mount. A parameter among them
121
+ // means the two paths address different resources.
122
+ return longer.slice(0, offset).every((seg) => seg !== ":p");
123
+ }
124
+ /** Whether `keys` hold this query's name channel — the same test
125
+ * `matchesMatchKeys` applies before it consults the endpoint. */
126
+ function nameAgrees(keys, query) {
127
+ const nameSlug = query.scenarioName ? slugifyName(query.scenarioName) : "";
128
+ if (!nameSlug)
129
+ return false;
130
+ const wanted = query.testType ? `name:${query.testType.toLowerCase()}:${nameSlug}` : undefined;
131
+ return keys.some((key) => wanted !== undefined ? key === wanted : key.startsWith("name:") && key.endsWith(`:${nameSlug}`));
132
+ }
133
+ /** Whether the plan holds this query's endpoint, ignoring the name. Shared by
134
+ * the name channel — which may no longer answer alone on an endpoint query —
135
+ * and by the endpoint channel below. */
136
+ function endpointAgrees(keys, query) {
137
+ if (!query.path)
138
+ return false;
139
+ const normPath = normalizeMatchPath(query.path);
140
+ const methodFilter = query.method ? query.method.toUpperCase() : undefined;
141
+ return keys.some((key) => {
142
+ if (!key.startsWith("ep:"))
143
+ return false;
144
+ const parts = key.split(":");
145
+ if (methodFilter && parts[2] !== methodFilter)
146
+ return false;
147
+ return normalizedPathsMatch(parts.slice(3).join(":"), normPath);
148
+ });
118
149
  }
119
150
  /**
120
- * True when `query` matches at least one of `items`' matchKeys:
121
- * - by scenarioName slug (`name:<slug>`), or
151
+ * True when `query` matches `keys`:
152
+ * - by testType + scenarioName slug (`name:<testType>:<slug>`), or
122
153
  * - by testType + path (`ep:<testType>:*:<path>`), method-exact when
123
154
  * `query.method` is given, method-agnostic (any method) otherwise — the
124
155
  * contract tool's `method: ""` ("test all methods") case. Path comparison
125
156
  * tolerates a base-path prefix on either side (see normalizedPathsMatch).
157
+ * The planned test's test type gates BOTH channels: gating the endpoint channel
158
+ * alone let a contract call pass against an integration-only planned test of the
159
+ * same name. Where the query carries an endpoint the name match must agree on it
160
+ * too, since `slugifyName` folds case and every run of non-alphanumerics.
126
161
  */
127
- export function matchesApprovedItem(items, query) {
128
- const nameSlug = query.scenarioName ? slugifyName(query.scenarioName) : "";
129
- if (nameSlug) {
130
- const nameKey = `name:${nameSlug}`;
131
- if (items.some((item) => item.matchKeys.includes(nameKey)))
162
+ export function matchesMatchKeys(keys, query) {
163
+ if (nameAgrees(keys, query)) {
164
+ if (!query.path)
165
+ return true;
166
+ if (endpointAgrees(keys, query))
132
167
  return true;
133
168
  }
134
169
  if (query.testType && query.path) {
@@ -150,22 +185,49 @@ export function matchesApprovedItem(items, query) {
150
185
  return false;
151
186
  return normalizedPathsMatch(keyPath, normPath);
152
187
  };
153
- if (items.some((item) => item.matchKeys.some(epKeyMatches)))
188
+ if (keys.some(epKeyMatches))
154
189
  return true;
155
190
  }
156
191
  return false;
157
192
  }
158
193
  /**
159
- * True when `query` is admissible under the approved plan: it matches a
160
- * GENERATE item, or it matches an ADDITIONAL item while the plan has at least
161
- * one GENERATE item. ADDITIONAL is strictly a backfill pool for failed
162
- * GENERATE items — on a zero-budget plan (`generate: []`, e.g. maxGenerate=0
163
- * for a config-only PR) there is nothing to backfill, so ADDITIONAL must not
164
- * admit generation. Shared by planGuard and submitReportTool so the gate and
165
- * the report cross-check can never disagree on admissibility.
194
+ * The planned tests whose scenarioName and testType match but whose planned steps
195
+ * name no endpoint the query addresses — the near miss a boolean cannot report,
196
+ * where the real disagreement is one step's path spelling. Empty when the query
197
+ * names no path.
166
198
  */
167
- export function matchesApprovedPlan(plan, query) {
168
- if (matchesApprovedItem(plan.generate, query))
169
- return true;
170
- return plan.generate.length > 0 && matchesApprovedItem(plan.additional, query);
199
+ export function nameMatchedEndpointMismatches(plannedTests, query) {
200
+ if (!query.path)
201
+ return [];
202
+ return plannedTests.filter((plannedTest) => {
203
+ if (!plannedTest?.scenario)
204
+ return false;
205
+ const keys = computeMatchKeys(plannedTest.scenario);
206
+ return nameAgrees(keys, query) && !endpointAgrees(keys, query);
207
+ });
208
+ }
209
+ /** The planned tests whose scenarioName matches but whose testType does not — the
210
+ * plan holds this scenario and the call asked a different generator for it, so
211
+ * "not in the plan" is the wrong thing to say. Empty when the query names no
212
+ * type, since `nameAgrees` then matches on the name alone. */
213
+ export function nameMatchedTypeMismatches(plannedTests, query) {
214
+ const nameSlug = query.scenarioName ? slugifyName(query.scenarioName) : "";
215
+ if (!nameSlug || !query.testType)
216
+ return [];
217
+ const calledType = query.testType.toLowerCase();
218
+ return plannedTests.filter((plannedTest) => {
219
+ if (!plannedTest?.scenario)
220
+ return false;
221
+ const keys = computeMatchKeys(plannedTest.scenario);
222
+ // The name channel, minus the type: a key ending in this slug means the plan
223
+ // holds the scenario, and the segment before it is the type it holds it as.
224
+ const named = keys.filter((key) => key.startsWith("name:") && key.endsWith(`:${nameSlug}`));
225
+ return named.length > 0 && !named.some((key) => key === `name:${calledType}:${nameSlug}`);
226
+ });
227
+ }
228
+ /** True when `query` matches at least one planned test of the registered plan.
229
+ * The keys are computed per call rather than stored with the plan, so there is
230
+ * one description of a planned test and no second shape to drift from it. */
231
+ export function matchesPlanCandidates(plannedTests, query) {
232
+ return plannedTests.some((plannedTest) => !!plannedTest?.scenario && matchesMatchKeys(computeMatchKeys(plannedTest.scenario), query));
171
233
  }
@@ -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,22 @@
1
+ export interface RemovedUiElement {
2
+ /** Lower-cased identifying attribute: `data-testid`, `data-cy`, `data-qa`, `data-section`, … */
3
+ attribute: string;
4
+ /** Its literal value. `attribute` + `value` is unique across the result. */
5
+ value: string;
6
+ /** Repo-relative frontend file the diff removed it from. */
7
+ file: string;
8
+ }
9
+ export interface CollectRemovedUiElementsInput {
10
+ changedFrontendFiles: string[];
11
+ /** Files with `+++ /dev/null` in the diff — their page is gone, no guard. */
12
+ deletedFiles: string[];
13
+ /** Per-file diff slices for the WHOLE diff keyed by repo-relative path (see diffBlocksByPath). */
14
+ diffByFile: Map<string, string>;
15
+ /** Absolute paths of discovered existing tests (skyramp + relevant external). */
16
+ existingTestFiles: string[];
17
+ /** True when test discovery threw — the existing-test check cannot run, so report nothing. */
18
+ discoveryFailed?: boolean;
19
+ /** Injectable for tests; defaults to a tolerant fs read that logs failures. */
20
+ readFile?: (absPath: string) => string | undefined;
21
+ }
22
+ export declare function collectRemovedUiElements(input: CollectRemovedUiElementsInput): RemovedUiElement[];
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Removed UI elements (SKYR-4305) — grounding for the removal guard.
3
+ *
4
+ * The recommendation prompt already requires a candidate for every element a PR
5
+ * removes ("the removal is what this PR ships, so the removal is what gets tested").
6
+ * A blueprint can never contain a removed element, so this module supplies what the
7
+ * agent otherwise has to guess: WHICH identifying `data-*` attributes (`data-testid`,
8
+ * `data-cy`, `data-qa`, `data-test-id`, or another static `data-*`) the diff removed.
9
+ * The agent finds the page they rendered on. `data-testid` is asserted with `getByTestId`; every
10
+ * other attribute with `page.locator('[<attribute>="<value>"]')`, so the guard never
11
+ * silently resolves against the wrong attribute. It drafts nothing and gates nothing — it reduces noise:
12
+ *
13
+ * - an id is reported only when its file survives the change (not in
14
+ * `deletedFiles`) — a deleted route/component is maintenance of the tests that
15
+ * covered it, not a page to assert on;
16
+ * - renames, moves and extractions to another file are excluded by
17
+ * `extractRemovedAttributes` (added values are collected across the WHOLE diff);
18
+ * - a value an existing discovered test already references is skipped — that test is
19
+ * the one to update or delete, a new guard would duplicate it. The match is on the
20
+ * VALUE regardless of attribute (a test naming `user-menu-row` as a testid also
21
+ * suppresses a `data-section="user-menu-row"` guard) — deliberate over-suppression,
22
+ * the safe direction, even though entries are keyed on attribute + value elsewhere. When discovery
23
+ * itself failed the list is empty and nothing is reported (fail-closed), because
24
+ * "no test references it" cannot be known;
25
+ *
26
+ * Every skip is logged at debug with its reason so a missing guard is diagnosable.
27
+ */
28
+ import * as fs from "fs";
29
+ import { extractAddedAttributeValues, extractRemovedAttributes } from "./frontendSelectors.js";
30
+ import { escapeRegExp } from "./regex.js";
31
+ import { logger } from "./logger.js";
32
+ function defaultReadFile(absPath) {
33
+ try {
34
+ return fs.readFileSync(absPath, "utf8");
35
+ }
36
+ catch (err) {
37
+ logger.warning("removedUiElements: could not read existing test (treated as not referencing any removed id)", {
38
+ file: absPath,
39
+ error: err instanceof Error ? err.message : String(err),
40
+ });
41
+ return undefined;
42
+ }
43
+ }
44
+ /**
45
+ * Whole-literal match: `order-total` must not be satisfied by `order-total-row`, and
46
+ * `foo:bar` not by `foo:bar:baz`. The identifier alphabet mirrors the extractor's
47
+ * value grammar (`[\w:-]`), so every character a value may contain is a
48
+ * non-boundary here.
49
+ */
50
+ function referencesLiteral(content, value) {
51
+ return new RegExp(`(^|[^\\w:-])${escapeRegExp(value)}(?![\\w:-])`).test(content);
52
+ }
53
+ export function collectRemovedUiElements(input) {
54
+ if (input.discoveryFailed) {
55
+ logger.warning("removedUiElements: test discovery failed — cannot tell whether an existing test covers a removed id; reporting none");
56
+ return [];
57
+ }
58
+ const readFile = input.readFile ?? defaultReadFile;
59
+ const deleted = new Set(input.deletedFiles);
60
+ const skip = (file, reason, extra = {}) => logger.debug(`removedUiElements: skipped ${file} — ${reason}`, extra);
61
+ // Added values across the ENTIRE diff, so an element extracted into another file is a
62
+ // move, not a removal.
63
+ const addedAcrossDiff = new Set();
64
+ for (const slice of input.diffByFile.values()) {
65
+ for (const v of extractAddedAttributeValues(slice))
66
+ addedAcrossDiff.add(v);
67
+ }
68
+ // Existing-test contents are read lazily and once: most PRs remove nothing.
69
+ let testContents;
70
+ const referencedByExistingTest = (value) => {
71
+ if (!testContents) {
72
+ testContents = input.existingTestFiles
73
+ .map((f) => readFile(f) ?? "")
74
+ .filter((c) => c.length > 0);
75
+ }
76
+ return testContents.some((c) => referencesLiteral(c, value));
77
+ };
78
+ const out = [];
79
+ const seen = new Set();
80
+ for (const file of input.changedFrontendFiles) {
81
+ if (deleted.has(file)) {
82
+ skip(file, "file deleted by the diff (whole surface removed)");
83
+ continue;
84
+ }
85
+ const slice = input.diffByFile.get(file);
86
+ if (!slice) {
87
+ skip(file, "no diff slice");
88
+ continue;
89
+ }
90
+ const attrs = extractRemovedAttributes(slice, addedAcrossDiff);
91
+ if (attrs.length === 0)
92
+ continue;
93
+ for (const { attribute, value } of attrs) {
94
+ const key = `${attribute}=${value}`;
95
+ if (seen.has(key))
96
+ continue;
97
+ if (referencedByExistingTest(value)) {
98
+ skip(file, `${attribute}="${value}" already referenced by an existing test (maintenance, not a guard)`);
99
+ continue;
100
+ }
101
+ seen.add(key);
102
+ out.push({ attribute, value, file });
103
+ }
104
+ }
105
+ return out;
106
+ }
@@ -1,4 +1,4 @@
1
- import { DriftAction } from "../types/TestAnalysis.js";
1
+ import { MaintenanceActionCore } from "../types/TestAnalysis.js";
2
2
  /**
3
3
  * Enumerate every path that differs from HEAD in the working tree — staged,
4
4
  * unstaged, or untracked — as repo-relative paths, via `git status --porcelain`.
@@ -42,11 +42,7 @@ export interface UnchangedFileClaimsInput {
42
42
  }[];
43
43
  /** Maintenance verdicts (server-derived from skyramp_actions). `pomFile`, when set, is
44
44
  * the file the edit actually lands in — see the UPDATE rule below. */
45
- verdicts: {
46
- action: DriftAction;
47
- testFilePath: string;
48
- pomFile?: string;
49
- }[];
45
+ verdicts: Pick<MaintenanceActionCore, "action" | "testFilePath" | "pomFile" | "rebaselineSnapshots" | "rebaselineOnly">[];
50
46
  /** owner/repo of the primary repo — used to exempt cross-repo newTest claims
51
47
  * whose files live in a different checkout. */
52
48
  primaryRepository?: string;
@@ -1,9 +1,11 @@
1
1
  import path from "path";
2
2
  import * as fs from "fs/promises";
3
+ import { existsSync } from "fs";
3
4
  import { execFile } from "child_process";
4
5
  import { promisify } from "util";
5
6
  import { testFileMatches } from "./utils.js";
6
7
  import { DriftAction } from "../types/TestAnalysis.js";
8
+ import { baselineFileMatchesStem, baselineStem } from "./rebaselineSnapshots.js";
7
9
  const execFileAsync = promisify(execFile);
8
10
  /**
9
11
  * Enumerate every path that differs from HEAD in the working tree — staged,
@@ -137,6 +139,40 @@ export function findUnchangedFileClaims(input) {
137
139
  }
138
140
  return changedFiles.some((c) => testFileMatches(c, candidate));
139
141
  };
142
+ // A visual-baseline UPDATE (SKYR-4298) edits no source: SmartPlaywright rewrites
143
+ // the PNG Playwright keeps beside the spec, `<spec>-snapshots/<name>-<project>-
144
+ // <platform>.png` (the default layout — see baselineFileMatchesStem for why that
145
+ // is safe to assume). Backed when one of the named baselines changed there AND
146
+ // still exists: `git status` lists a deleted file as changed too, and a destroyed
147
+ // baseline is the opposite of a refreshed one. Scoped to the spec's own snapshot
148
+ // directory so a refreshed baseline of some other spec cannot vouch for this one.
149
+ const isBackedBySnapshot = (specPath, baselines) => {
150
+ let specRel = specPath;
151
+ if (path.isAbsolute(specPath)) {
152
+ const rel = path.relative(repoRoot, specPath);
153
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
154
+ return true;
155
+ specRel = rel;
156
+ }
157
+ // git reports paths with "/" on every platform; path.relative uses the host
158
+ // separator, so a Windows run would otherwise never match its own snapshot dir.
159
+ specRel = specRel.split(path.sep).join("/");
160
+ const snapshotDir = `${specRel}-snapshots/`;
161
+ const stems = baselines.map(baselineStem).filter(Boolean);
162
+ return changedFiles.some((c) => {
163
+ // Same anchoring as testFileMatches: a path-qualified spec must match as a
164
+ // path segment; a bare basename can only match by directory basename.
165
+ const inDir = specRel.includes("/")
166
+ ? c.startsWith(snapshotDir) || c.includes("/" + snapshotDir)
167
+ : path.basename(path.dirname(c)) === snapshotDir.slice(0, -1);
168
+ if (!inDir)
169
+ return false;
170
+ const base = path.basename(c);
171
+ if (!stems.some((stem) => baselineFileMatchesStem(base, stem)))
172
+ return false;
173
+ return existsSync(path.resolve(repoRoot, c));
174
+ });
175
+ };
140
176
  // A plan-only run declares its GENERATE list instead of writing files, so
141
177
  // skip the claim scan wholesale rather than per-claim: checking it rejects
142
178
  // every declaration the guard can see, and the ones it CANNOT see (cross-repo
@@ -165,9 +201,32 @@ export function findUnchangedFileClaims(input) {
165
201
  // The edit target, not the spec: for a POM-based test the page object is what
166
202
  // gets patched while testFilePath stays the spec (SKYR-4129).
167
203
  const editTarget = v.pomFile ?? v.testFilePath;
168
- if (!isBacked(editTarget)) {
169
- unbacked.push(`testMaintenance ${v.action} claims an edit to "${path.basename(editTarget)}", but that file has no working-tree change`);
204
+ // Baseline refresh (SKYR-4298): the verdict promises a replaced PNG next to the
205
+ // spec, and the report says "visual baseline refreshed" on its strength. Two rules:
206
+ // - The PNG must have been rewritten. An edit to the spec does not stand in for
207
+ // it, otherwise a run that forgot rebaselineSnapshots (or loosened
208
+ // maxDiffPixelRatio instead) would report a refresh that never happened.
209
+ // - Listing a baseline never exempts the edit the same verdict claimed. Unless the
210
+ // verdict is rebaselineOnly (no updateInstructions), the edit target — the POM
211
+ // when named, else the spec — still needs its own working-tree change; a
212
+ // baseline attached to a selector-rename UPDATE cannot wave the rename through.
213
+ // A plan-only run executes nothing, so no PNG can be rewritten there; the edit
214
+ // half is still verified, as for every other UPDATE (SKYR-4250).
215
+ const baselines = v.rebaselineSnapshots ?? [];
216
+ if (baselines.length > 0) {
217
+ const editOk = v.rebaselineOnly === true || isBacked(editTarget);
218
+ const pngOk = planOnly || isBackedBySnapshot(v.testFilePath, baselines);
219
+ if (!pngOk) {
220
+ unbacked.push(`testMaintenance ${v.action} claims a refreshed visual baseline (${baselines.join(", ")}) for "${path.basename(v.testFilePath)}", but no matching PNG was rewritten under its -snapshots directory — the final skyramp_execute_test run must pass rebaselineSnapshots and report it as refreshed; editing the spec does not stand in for the refresh`);
221
+ }
222
+ if (!editOk) {
223
+ unbacked.push(`testMaintenance ${v.action} claims an edit to "${path.basename(editTarget)}" alongside a baseline refresh, but that file has no working-tree change — listing a baseline does not exempt the edit`);
224
+ }
225
+ continue;
170
226
  }
227
+ if (isBacked(editTarget))
228
+ continue;
229
+ unbacked.push(`testMaintenance ${v.action} claims an edit to "${path.basename(editTarget)}", but that file has no working-tree change`);
171
230
  }
172
231
  return unbacked;
173
232
  }