@skyramp/mcp 0.4.0-rc.1 → 0.4.0

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.
@@ -40,7 +40,7 @@ Matching none does **not** mean IGNORE — first run the selector safety net: ma
40
40
 
41
41
  Build a detection list first: for each matched diff line, write one line: \`{pattern type} — "{diff line}" — affects {route/component}\`.
42
42
 
43
- **Visual-snapshot baselines.** A test that calls \`toHaveScreenshot("<name>.png")\` pixel-compares a page, element, or region against a committed baseline PNG. For such a test only — this does not widen the detection list for any other test — also treat as a reaching change any appearance edit inside what the call captures: a CSS/Tailwind/styled-components change, a changed literal string that renders, a swapped image or icon, a reordered or added visible element. When such a change reaches what one of those calls captures, the baseline is stale — the assertion's expected value is the image, and it must be refreshed exactly as a text assertion would be corrected. Assign **UPDATE** and list the affected baseline filenames in \`rebaselineSnapshots\` (only those whose captured page/element/region the diff reaches — an unaffected baseline in the same spec stays as it is). The refresh happens at execution via \`skyramp_execute_test\`'s \`rebaselineSnapshots\`; never edit the \`toHaveScreenshot()\` call, raise \`maxDiffPixelRatio\`, delete the assertion, or hand-edit a PNG to make it pass — those hide real regressions. When a pre-edit run reports \`Screenshot comparison failed\` and NO diff line explains the mismatch for that capture, the baseline is not stale: leave the test as it is, keep it \`Fail\`, and record the finding in \`issuesFound\`.
43
+ **Visual-snapshot baselines.** A test that calls \`toHaveScreenshot("<name>.png")\` pixel-compares a page, element, or region against a committed baseline PNG. For such a test only — this does not widen the detection list for any other test — also treat as a reaching change any appearance edit inside what the call captures: a CSS/Tailwind/styled-components change, a changed literal string that renders, a swapped image or icon, a reordered or added visible element. When such a change reaches what one of those calls captures, the baseline is stale — the assertion's expected value is the image, and it must be refreshed exactly as a text assertion would be corrected. Assign **UPDATE** and list the affected baseline filenames in \`rebaselineSnapshots\` (only those whose captured page/element/region the diff reaches — an unaffected baseline in the same spec stays as it is). The refresh happens at execution via \`skyramp_execute_test\`'s \`rebaselineSnapshots\`; never edit the \`toHaveScreenshot()\` call, raise \`maxDiffPixelRatio\`, delete the assertion, or hand-edit a PNG to make it pass — those hide real regressions. When a pre-edit run reports \`Screenshot comparison failed\` and NO diff line explains the mismatch for that capture, the baseline is not stale: assign **VERIFY** (the test must still appear as a maintenance row with its before/after status — never drop it from the verdicts), leave its source as it is, keep it \`Fail\`, and also record the finding in \`issuesFound\`. When the pre-edit run **passed**, nothing is stale (a missing baseline is created by that run): do not list \`rebaselineSnapshots\`. A newly generated test writes its own baseline on its first run and can never be stale, so this applies to existing specs only. The refresh rides the final execution and never costs an extra one: pass the \`rebaselineSnapshots\` list on that single call, and if a screenshot mismatch first appears on that final run, report \`Fail\` rather than adding another run. The execution result names which baselines were refreshed and which were not, and the report row's description carries the refreshed names automatically, so \`afterDetails\` states only the outcome (e.g. \`1 passed in 6.1s\`).
44
44
 
45
45
  **Rules (assign exactly one action per test; severity order above):**
46
46
  - **DELETE** when the test's target is gone: all routes/pages it covers no longer exist; all critical selectors were removed with no migration path; or, for a component/widget test, the component it renders was deleted (source removed with no re-export; report-only for external tests).
@@ -458,7 +458,8 @@ ${CONTRACT_MODE_GUIDANCE}
458
458
  - \`skyramp_export_zip\` outputPath: \`${repositoryPath}/.skyramp/<test_name>_trace.zip\`
459
459
  - \`skyramp_ui_test_generation\`: set \`modularizeCode: false\` and \`codeReuse: true\` (TypeScript/JavaScript Playwright only — the default; leave \`codeReuse\` unset for other languages)
460
460
  - **\`browser_assert\`**: the \`browser_navigate\` result states the rule this tool has to satisfy on every page you reach. Call multiple assertions in the same tool call batch when checking independent elements.
461
- - **Removal guard — when \`uiContext.removedElements\` is non-empty**: the server lists which elements this PR removed — each identified by a \`data-*\` attribute and value, with the file it came from (renames, moves and deleted files are excluded). Read that file and the route or page files that render it to decide which page each appeared on. Per PAGE: \`browser_navigate\` to it, wait for the app to render, then call \`browser_assert_hidden\` once per listed element using the target the list shows — \`testId: "<value>"\` when the attribute is \`data-testid\`, otherwise \`attribute: "<name>", value: "<value>"\` (e.g. \`attribute: "data-cy"\`); no \`ref\` — the element no longer exists, so no snapshot has one; the tool checks the live page and refuses to record on a blank or un-rendered page. Then at least one \`browser_assert\` on a RETAINED element of the same page verifying its text, value or state. If a UI trace for that page is already planned, record the guard assertions inside that trace; otherwise this is its own trace and spec, \`verify-removed-ui-<page-slug>\`. The generated test asserts \`toBeHidden()\` on \`getByTestId(...)\` or \`page.locator('[data-cy="..."]')\` accordingly. A removed element the server did not list still gets its test (the removal check requires it) — ground that one in the diff and say so in \`reasoning\`.
461
+ - **Removal guard — when \`uiContext.removedElements\` is non-empty**: the server lists which elements this PR removed — each identified by a \`data-*\` attribute and value, with the file it came from (renames, moves and deleted files are excluded). Read that file and the route or page files that render it to decide which page each appeared on. Per PAGE: \`browser_navigate\` to it, wait for the app to render, then call \`browser_assert_hidden\` once per listed element using the target the list shows — \`testId: "<value>"\` when the attribute is \`data-testid\`, otherwise \`attribute: "<name>", value: "<value>"\` (e.g. \`attribute: "data-cy"\`); no \`ref\` — the element no longer exists, so no snapshot has one; the tool checks the live page and refuses to record on a blank or un-rendered page. Then at least one \`browser_assert\` on a RETAINED element of the same page verifying its text, value or state. If a UI trace for that page is already planned, record the guard assertions inside that trace; otherwise this is its own trace and spec, \`verify-removed-ui-<page-slug>\`. The generated test asserts \`toBeHidden()\` on \`getByTestId(...)\` or \`page.locator('[data-cy="..."]')\` accordingly. A removed element the server did not list still gets its test (the removal check requires it) — ground that one in the diff and prefix its \`reasoning\` with \`[removed-element diff-grounded]\`; prefix \`reasoning\` with \`[removed-element server-listed]\` when the element came from \`uiContext.removedElements\`. **Deleted routes or pages are deleted features, not removed elements**: test the removal through the link or entry point that survives. A trace may still open the retired URL — to assert the app's fallback there, or that the old page's elements are gone — after it has recorded a positive \`browser_assert\` on a surviving page; a trace whose only visited page is the dead URL has no positive companion and is not a guard spec.
462
+ - **Renames are not removals — on every PR, whatever \`uiContext.removedElements\` holds**: when a test id, \`aria-label\` or other accessible name changed, assert the element by its new identifier and leave the retired identifier alone (no \`browser_assert_hidden\` on it). A rename PR removes nothing, so this rule applies when the removed list is empty.
462
463
  - **\`browser_visual_snapshot\` — for visual/appearance checks**: when the instruction asks to take a screenshot, capture a baseline, or verify how a page/element/region *looks* (not its text or value), call \`browser_visual_snapshot\` — it records a \`toHaveScreenshot()\` assertion so the generated test pixel-compares against a baseline on every run. Do NOT use \`browser_take_screenshot\` for this: it captures a throwaway image that is dropped at export and never appears in the generated test (use it only to view the page yourself).
463
464
  - **Wait for stable state before the second capture**: After performing an action that affects computed fields (filling a discount, submitting a form, adding an item), check the current page state before calling the second \`browser_blueprint\` (the capture after the action). If a computed field — total, price, count, derived text — still shows its initial empty or zero value (e.g. \`$0.00\`, \`0\`, \`Loading...\`, empty string), that means async data hasn't finished loading yet. Use \`browser_wait_for\` to wait up to 10 seconds for the field to update to a real value (for example, wait for the total to show a non-zero amount like \`$799.99\` instead of \`$0.00\`). Once the field shows a real value, THEN call the second \`browser_blueprint\` to capture stable state. If after 10 seconds the field still hasn't updated, skip the assertion on that field — don't capture and assert a value that hasn't loaded.
464
465
  If \`browser_navigate\` fails (app not running / connection refused), apply skip condition (a) above: leave the planned test in your plan, say in \`businessCaseAnalysis\` why it was not written, AND record the outage in \`issuesFound\`.
@@ -533,7 +534,6 @@ ${EXEC_FAIL.EXPECTED_FAILURE}. **Expected failure check (no retry):** If the fai
533
534
 
534
535
  **If you did confirm the missing or wrong operation in the source, do NOT make the test pass.** Never add a reset, cleanup, or setup call for isolation. Never weaken the assertion — no \`==\` to \`>=\`, no exact value to a range. A failing test whose diagnosis names a pre-existing bug is the most valuable output of this run; a passing version of it reports nothing.
535
536
 
536
- **Visual snapshots — maintained tests only, and never a third run.** A newly generated test writes its baseline on its first run and cannot be stale, so this applies to an existing spec whose committed baseline the PR made out of date. That mismatch is known from the \`phase: "before"\` run, and the refresh rides the final run: pass the \`rebaseline_snapshots\` list \`skyramp_actions\` returned as \`rebaselineSnapshots\` on that one call. It never costs an extra execution — if a screenshot mismatch on a maintained test first appears on the final run, report \`Fail\`; do not add a run. The execution result names which baselines were refreshed and which were not; the report row's description carries the refreshed names automatically, so \`afterDetails\` states only the outcome (e.g. \`1 passed in 6.1s\`). If no diff line explains a mismatch, keep the test \`Fail\` and add an \`issuesFound\` entry. Never raise \`maxDiffPixelRatio\`, never remove the \`toHaveScreenshot()\` call, never edit or delete a PNG by hand, and never pass \`rebaselineSnapshots\` for a mismatch the diff does not explain.
537
537
  ${EXEC_FAIL.INFRA_RETRY}. Apply a targeted fix and retry **once** only for **infrastructure failures** — that means exactly **2 total \`skyramp_execute_test\` calls per test file** for these cases. Examples of infrastructure failures worth fixing:
538
538
  - Assertion mismatch from floating-point precision, or an expected value mis-transcribed from the observed response or computed with an arithmetic slip. If application behavior outside the diff explains the mismatch, it is not an infrastructure failure — use step ${EXEC_FAIL.EXPECTED_FAILURE} instead.
539
539
  - Import error, syntax error, or missing dependency in the generated test file${FIX_ERRORS_RETRY_HINT}
@@ -138,6 +138,9 @@ export interface VerifyContext {
138
138
  * survives. A removal guard declares the element that is GONE, so no capture
139
139
  * can hold it and this list is its only grounding. */
140
140
  removedUiElements: RemovedUiElement[];
141
+ /** Identifiers a rename replaced, empty when the diff renames nothing. Only the
142
+ * server pairs the two sides of a diff. */
143
+ retiredUiElements: RemovedUiElement[];
141
144
  /** Whether a cited path names a file in any of the run's repositories. Injected
142
145
  * to keep the verifiers filesystem-free. */
143
146
  citedFileExists(relativePath: string): boolean;
@@ -201,180 +201,13 @@ export declare const REMOVED_ELEMENT_GUARDED_CONTRACT: {
201
201
  readonly message: "The diff removes `{element}` and no planned test says it is gone.";
202
202
  readonly suggestion: "Plan a UI test on the page that still renders, asserting this element is absent, and name it in that test's elements. Removing it is what this pull request ships, so the removal is what gets tested. If the diff shows it still rendering under a new identifier it is a rename, not a removal — say so as the answer.";
203
203
  };
204
- };
205
- readonly suggestion: "Does every element this diff removes have a planned test that asserts it is gone?";
206
- };
207
- /** In the order of `PLAN_TIME_VERIFIERS`, which decides the order objections are
208
- * reported in and carries no priority. References, not copies: iterating this
209
- * array, or indexing it with a variable, widens the element to the union of them
210
- * all and then no `objections` key resolves. */
211
- export declare const VERIFIER_CONTRACTS: readonly [{
212
- readonly id: "changedFile";
213
- readonly declarationFields: readonly ["declarations.changedFile", "declarations.screenEvidence.file"];
214
- readonly objections: {
215
- readonly notStated: {
216
- readonly message: "This test does not state which changed file it targets.";
217
- readonly suggestion: "Name the repository-relative file this test is about in `changedFile`, spelled as the diff spells it.";
218
- };
219
- readonly unrelated: {
220
- readonly message: "The file this test names as its changed file is not one this pull request changed.";
221
- readonly suggestion: "Name a file from this PR's own diff in `changedFile`, repository-relative and spelled as the diff spells it — or say why this test belongs in the plan without one.";
222
- };
223
- readonly unknownRepository: {
224
- readonly message: "This planned test names a repository this run did not analyze.";
225
- readonly suggestion: "Name one of the repositories listed in the evidence, or leave `repository` out on a single-repository run. A name nothing analyzed leaves the planned test with no diff to be checked against.";
226
- };
227
- readonly screenEvidenceUnrelated: {
228
- readonly message: "The file this UI test cites for its screen is not one this pull request changed.";
229
- readonly suggestion: "Which changed file renders the screen this test opens — or which changed screen should it open instead? Name that file in `screenEvidence.file`, repository-relative, spelled as the diff spells it.";
204
+ readonly retired: {
205
+ readonly message: "This test names `{element}`. The diff pairs it with a new identifier on the same element, which reads as a rename and not a removal.";
206
+ readonly suggestion: "If this test asserts that identifier absent, assert the element by the one the diff adds instead — a guard on a retired identifier asserts a live element hidden. If the diff replaces the element rather than renaming it, or removes it as well, or the test names it for another reason, say so as the answer.";
230
207
  };
231
208
  };
232
- readonly suggestion: "Which file that this pull request changed does this test target?";
233
- }, {
234
- readonly id: "endpointGrounded";
235
- readonly declarationFields: readonly ["steps[]", "testType", "declarations.routes"];
236
- readonly objections: {
237
- readonly uncited: {
238
- readonly message: "A call this test makes cites no file that declares it.";
239
- readonly suggestion: "Add a `routes` entry naming the repository file that declares it — the route or operation need not appear verbatim in that file. A route the endpoint catalog missed is evidence of nothing: cite the file and keep the planned test.";
240
- };
241
- readonly disqualifiedCitation: {
242
- readonly message: "The file this test cites for a call cannot be the file that declares it.";
243
- readonly suggestion: "Cite the source file that declares it — not a test, not built output, not documentation. Any language or format is accepted, and the route or operation need not appear verbatim in the file.";
244
- };
245
- readonly uncitedAndDisqualified: {
246
- readonly message: "This test has a call with no citation, and a call whose cited file cannot be the file that declares it.";
247
- readonly suggestion: "Add `routes` for the call that has none, and point the other citation at the source file that declares its route or operation. Neither need appear verbatim in the file.";
248
- };
249
- };
250
- readonly suggestion: "Which repository file declares this route or operation, and does an entry name that step's `order`?";
251
- }, {
252
- readonly id: "statedDifference";
253
- readonly declarationFields: readonly ["steps[]", "testType", "scenarioName", "declarations.differsFrom"];
254
- readonly objections: {
255
- readonly unexplainedPair: {
256
- readonly message: "Another planned test in this plan tests the same endpoint and neither says how they differ.";
257
- readonly suggestion: "Two tests on one endpoint is a legitimate plan. This asks HOW they differ, not whether one should go. Add a `differsFrom` entry naming the other planned test and what this one asserts that it does not — or, if one really is redundant, drop it.";
258
- };
259
- readonly unknownPartner: {
260
- readonly message: "A `differsFrom` entry names a partner that is not a planned test in this plan.";
261
- readonly suggestion: "Spell the partner's `scenarioName` exactly as it appears in your own planned test list — the match is exact apart from surrounding space, so a difference in case names nothing.";
262
- };
263
- readonly subjectNotStated: {
264
- readonly message: "This test makes more than one call and does not say which one it is about.";
265
- readonly suggestion: "Set `declarations.stepUnderTest` to the `order` of the step under test. The other calls are setup, and nothing on the server ranks them for you.";
266
- };
267
- };
268
- readonly suggestion: "Which call is this test about, and what does it assert that the other planned test on the same endpoint does not?";
269
- }, {
270
- readonly id: "existingCoverage";
271
- readonly declarationFields: readonly ["declarations.existingTests"];
272
- readonly objections: {
273
- readonly citedTestMissing: {
274
- readonly message: "This planned test names an existing test file that is not in the checkout.";
275
- readonly suggestion: "Name the file repository-relative, spelled as the analysis lists it. Drop the entry if it names a test you did not read — naming none is an answer too.";
276
- };
277
- };
278
- readonly suggestion: "Which existing tests did you read on this change, and how does this planned test differ from them?";
279
- }, {
280
- readonly id: "expectedOutcome";
281
- readonly declarationFields: readonly ["category", "declarations.expected.outcome", "declarations.expected.why"];
282
- readonly objections: {
283
- readonly passInShouldFailCategory: {
284
- readonly message: "This planned test is categorised \"{category}\" but expects to pass on the app as it stands.";
285
- readonly suggestion: "If it catches a defect, it should be red until the defect is fixed — say so. If it pins behaviour that is already correct, change the category.";
286
- };
287
- readonly executed: {
288
- readonly message: "This test failed, but it did not reach the behavior it was written for — it fell over earlier. A failure like this is not a caught bug.";
289
- readonly suggestion: "If the blocker is the test's own setup — sign-in, token, a wrong path — fix it and run the test again. If the blocker is the defect this run reports (a route that is not mounted, a server error before the behaviour), answer this objection naming that issue: the failure then stands as evidence of that issue, not as a caught bug.";
290
- };
291
- };
292
- readonly suggestion: "Should this test fail until the defect is fixed, or is the category wrong for a test that pins behaviour the app already has?";
293
- }, {
294
- readonly id: "uiElementGrounded";
295
- readonly declarationFields: readonly ["testType", "steps[]", "declarations.elements.items", "declarations.elements.pageUrl", "declarations.asserts", "scenarioName", "description", "declarations.expected.why"];
296
- readonly objections: {
297
- readonly nogrounding: {
298
- readonly message: "This UI test names no `elements.items`, so nothing says the elements it targets are on the page.";
299
- readonly suggestion: "Copy the elements this test targets out of the capture of the page it runs on into `elements.items`, and set `elements.pageUrl` to that page. If no captured page holds this test's elements — the changed component mounts nowhere the run reached, or the screen is behind a gate this run could not pass — answer this objection saying which, and leave `elements.items` null.";
300
- };
301
- readonly duplicate: {
302
- readonly message: "`elements.items` names the same element more than once.";
303
- readonly suggestion: "List each element once. Two entries that agree on role, name and identifiers describe one element, and the repeat states nothing the first entry did not.";
304
- };
305
- readonly pagecontext: {
306
- readonly message: "This UI test names `elements.items` but no page they came from.";
307
- readonly suggestion: "Set `elements.pageUrl` to the `url` of the capture the elements were lifted from. Without it there is no page to look them up on.";
308
- };
309
- readonly ungrounded: {
310
- readonly message: "An element this UI test targets is in no blueprint captured for that page.";
311
- readonly suggestion: "Cite what a capture shows you, never what the screen ought to have. A capture shows the page as it stood when it was taken, so an element behind a modal, a dropdown or an accordion is legitimately absent — answer this objection naming the action that reveals it. Otherwise copy the element from the capture as it is spelled there, or point `elements.pageUrl` at the page you actually captured.";
312
- };
313
- };
314
- readonly suggestion: "Which capture of the page does each declared element come from, and for one that is in no capture, which action on that page reveals it?";
315
- }, {
316
- readonly id: "screenRoute";
317
- readonly declarationFields: readonly ["declarations.elements.pageUrl", "declarations.screenEvidence.file", "declarations.changedFile"];
318
- readonly objections: {
319
- readonly mismatch: {
320
- readonly message: "The page this UI test opens is not a page that renders the changed file it is about.";
321
- readonly suggestion: "Open one of the routes in the evidence, or name the changed file that does render this page in `screenEvidence.file`. `skyramp_resolve_screen` gives the url to open, with the route parameters it can fill. A trace recorded on a screen the diff did not change proves nothing about the change.";
322
- };
323
- readonly unmounted: {
324
- readonly message: "No route this run found renders the file this UI test is about.";
325
- readonly suggestion: "If a route in this PR mounts it, name that route in `elements.pageUrl`. If nothing mounts it, the component is unintegrated: say so in one line and keep the planned test out of the browser.";
326
- };
327
- };
328
- readonly suggestion: "Does the page this test opens render the changed file the test is about?";
329
- }, {
330
- readonly id: "coverage";
331
- readonly declarationFields: readonly ["changes", "declarations.changes", "declarations.changedFile", "declarations.routes", "declarations.screenEvidence.file", "declarations.startState", "changes[].cases", "changes[].surfaces"];
332
- readonly objections: {
333
- readonly change: {
334
- readonly message: "This declared change has no test in the plan.";
335
- readonly suggestion: "Plan a test that exercises it. If this run cannot write one, answer it and set `blocker` to what stopped the run — a service that is not running, a paired branch that no longer exists, the one credential the run holds. An answer with no blocker leaves this open: a change nothing prevented a test from reaching takes the test. Coverage per file is the basic minimum; coverage per change is what the plan is judged by, so a file covered by a shape test still leaves the change it makes untested.";
336
- };
337
- readonly noChanges: {
338
- readonly message: "The plan declares no changes, so nothing says what the pull request must make different.";
339
- readonly suggestion: "List the behaviours the pull request description promises, one sentence each, and cite them from the planned tests that test them — or answer in one line that it promises none, which a docs-only or cosmetic change legitimately does.";
340
- };
341
- readonly unknownChange: {
342
- readonly message: "A planned test cites a change id that this plan does not declare.";
343
- readonly suggestion: "Spell the change's `id` exactly as your own `changes` list gives it — the match is exact apart from surrounding space. Declare the change if it is missing.";
344
- };
345
- readonly file: {
346
- readonly message: "This changed file has no test in the plan.";
347
- readonly suggestion: "Add a planned test that cites it — as `changedFile`, as a `routes` file, or as `screenEvidence.file` — or answer in ONE line why it needs none: config only, covered by the test on the route it serves, or style-only and covered by the UI test on the page it belongs to. This check is a floor, never a cap: it never asks you to plan fewer tests.";
348
- };
349
- readonly stateTest: {
350
- readonly message: "No test in this plan names the state the record is in when {route} mutates it.";
351
- readonly suggestion: "Plan one test that puts a record in a stated state — through the API, the UI, or data the app already holds; a record with no rows yet counts — then runs the mutation on it, and name that state as `startState`. If the route can meet a record that is not freshly created, use that state. Only a planned test closes this: there is no answer for it.";
352
- };
353
- readonly cases: {
354
- readonly message: "No test for this change sends the declared case {missing}.";
355
- readonly suggestion: "Add the missing values to a test that cites this change, or answer in one line why they need no test.";
356
- };
357
- readonly reject: {
358
- readonly message: "`{param}` accepts `{value}`, a record the plan creates, and no case rejects a record that is not there or belongs to another parent.";
359
- readonly suggestion: "Add a reject case on `{param}` with a value that is not that record and a step that sends it, or answer why the route cannot refuse it.";
360
- };
361
- readonly surface: {
362
- readonly message: "No {kind} test cites this change, which is on the {surface}.";
363
- readonly suggestion: "Add a test on that surface that cites this change, or answer in one line why that surface needs no test.";
364
- };
365
- };
366
- readonly suggestion: "Which planned test tests each behaviour this change promises and each file it touches, and for one nothing covers, why does it need none?";
367
- }, {
368
- readonly id: "removedElementGuarded";
369
- readonly declarationFields: readonly ["declarations.elements.items", "declarations.asserts"];
370
- readonly objections: {
371
- readonly missing: {
372
- readonly message: "The diff removes `{element}` and no planned test says it is gone.";
373
- readonly suggestion: "Plan a UI test on the page that still renders, asserting this element is absent, and name it in that test's elements. Removing it is what this pull request ships, so the removal is what gets tested. If the diff shows it still rendering under a new identifier it is a rename, not a removal — say so as the answer.";
374
- };
375
- };
376
- readonly suggestion: "Does every element this diff removes have a planned test that asserts it is gone?";
377
- }];
209
+ readonly suggestion: "Does every element this diff removes have a planned test that asserts it is gone, and does no test claim a renamed identifier is gone?";
210
+ };
378
211
  /** Fills a contract sentence's `{placeholder}` spans from the run's or the
379
212
  * planned test's own values. A placeholder with no value is left as written rather
380
213
  * than blanked, so a missed key shows up as `{knob}` instead of reading as a gap
@@ -236,24 +236,16 @@ export const REMOVED_ELEMENT_GUARDED_CONTRACT = {
236
236
  message: "The diff removes `{element}` and no planned test says it is gone.",
237
237
  suggestion: "Plan a UI test on the page that still renders, asserting this element is absent, and name it in that test's elements. Removing it is what this pull request ships, so the removal is what gets tested. If the diff shows it still rendering under a new identifier it is a rename, not a removal — say so as the answer.",
238
238
  },
239
+ // A question, not an accusation, on two counts: the check sees the identifier
240
+ // NAMED and a plan has no field that says "absent", and the pairing that produced
241
+ // the list cannot tell a rename from a same-shape replacement.
242
+ retired: {
243
+ message: "This test names `{element}`. The diff pairs it with a new identifier on the same element, which reads as a rename and not a removal.",
244
+ suggestion: "If this test asserts that identifier absent, assert the element by the one the diff adds instead — a guard on a retired identifier asserts a live element hidden. If the diff replaces the element rather than renaming it, or removes it as well, or the test names it for another reason, say so as the answer.",
245
+ },
239
246
  },
240
- suggestion: "Does every element this diff removes have a planned test that asserts it is gone?",
247
+ suggestion: "Does every element this diff removes have a planned test that asserts it is gone, and does no test claim a renamed identifier is gone?",
241
248
  };
242
- /** In the order of `PLAN_TIME_VERIFIERS`, which decides the order objections are
243
- * reported in and carries no priority. References, not copies: iterating this
244
- * array, or indexing it with a variable, widens the element to the union of them
245
- * all and then no `objections` key resolves. */
246
- export const VERIFIER_CONTRACTS = [
247
- CHANGED_FILE_CONTRACT,
248
- ENDPOINT_GROUNDED_CONTRACT,
249
- STATED_DIFFERENCE_CONTRACT,
250
- EXISTING_COVERAGE_CONTRACT,
251
- EXPECTED_OUTCOME_CONTRACT,
252
- UI_ELEMENT_GROUNDED_CONTRACT,
253
- SCREEN_ROUTE_CONTRACT,
254
- COVERAGE_CONTRACT,
255
- REMOVED_ELEMENT_GUARDED_CONTRACT,
256
- ];
257
249
  /** Fills a contract sentence's `{placeholder}` spans from the run's or the
258
250
  * planned test's own values. A placeholder with no value is left as written rather
259
251
  * than blanked, so a missed key shows up as `{knob}` instead of reading as a gap
@@ -1,3 +1,5 @@
1
+ import { REMOVED_ELEMENT_GUARDED_CONTRACT, fillPlaceholders } from "../verifierContracts.js";
2
+ import { escapeRegExp } from "../../utils/regex.js";
1
3
  /** Verifier 9. Every element the diff removed needs a planned test that says it is
2
4
  * gone.
3
5
  *
@@ -9,28 +11,53 @@
9
11
  * decide which page the element belongs on, and is not free to leave it out.
10
12
  *
11
13
  * This is the direction `uiElementGrounded` does not check. That one asks whether
12
- * a declared element is real; this one asks whether a real removal was declared. */
14
+ * a declared element is real; this one asks whether a real removal was declared.
15
+ *
16
+ * The retired case is the reverse claim, which nothing else checks: a plan naming an
17
+ * identifier the diff renamed, where a guard would assert a live element hidden. It
18
+ * sees a MENTION, not an absence claim — the plan has no field for one — so it asks
19
+ * rather than accuses. `data-*` only, all the extractor reads; a renamed `aria-label`
20
+ * is the prompt's to catch. */
21
+ const { missing: MISSING, retired: RETIRED } = REMOVED_ELEMENT_GUARDED_CONTRACT.objections;
13
22
  const text = (value) => (typeof value === "string" ? value.trim() : "");
23
+ function declaresElement(plannedTest, value) {
24
+ const declared = plannedTest?.declarations;
25
+ const items = Array.isArray(declared?.elements?.items) ? declared.elements.items : [];
26
+ return items.some((item) => text(item?.testId) === value || text(item?.stableId) === value);
27
+ }
14
28
  /** How a plan names an element. `testId` is the one field that carries the value
15
29
  * for a `data-testid`; a guard on `data-cy` or `data-qa` reaches the plan with
16
30
  * `testId` null, so the value is looked for in the prose the test states too. */
17
31
  function namesElement(plannedTest, value) {
18
- const declared = plannedTest?.declarations;
19
- const items = Array.isArray(declared?.elements?.items) ? declared.elements.items : [];
20
- if (items.some((item) => text(item?.testId) === value || text(item?.stableId) === value))
32
+ if (declaresElement(plannedTest, value))
21
33
  return true;
22
- return [text(declared?.asserts), text(plannedTest?.plannedTestId)].some((prose) => prose.includes(value));
34
+ return [text(plannedTest?.declarations?.asserts), text(plannedTest?.plannedTestId)].some((prose) => prose.includes(value));
35
+ }
36
+ /** Whole-identifier match. This case objects on a hit, so `includes` would raise
37
+ * `nav-clear` against a test about `nav-clear-state-banner`. */
38
+ function mentionsIdentifier(prose, value) {
39
+ return new RegExp(`(^|[^\\w:-])${escapeRegExp(value)}(?![\\w:-])`).test(prose);
40
+ }
41
+ /** No `plannedTestId`: the prompt names guard specs `verify-removed-ui-<id>`, so every
42
+ * correct one carries the identifier in its name. */
43
+ function retiredIdentifierNamedBy(plannedTest, value) {
44
+ if (declaresElement(plannedTest, value))
45
+ return true;
46
+ return mentionsIdentifier(text(plannedTest?.declarations?.asserts), value);
23
47
  }
24
48
  /** `attribute=value` identifies an element across the run, so the objection id is
25
49
  * the same string on every registration whatever order the list arrives in. */
26
50
  const objectionIdFor = (element) => `removedElementGuarded:${text(element?.attribute)}=${text(element?.value)}`;
51
+ /** Keyed by the test too: one answer must not close another test's objection. */
52
+ const retiredObjectionIdFor = (element, plannedTestId) => `removedElementGuarded:retired:${text(element?.attribute)}=${text(element?.value)}:${plannedTestId || "plan"}`;
27
53
  export const removedElementGuarded = {
28
54
  name: "removedElementGuarded",
29
55
  run(registration, ctx) {
30
- const removed = Array.isArray(ctx?.removedUiElements) ? ctx.removedUiElements : [];
31
- if (removed.length === 0)
56
+ const { removedUiElements: removed, retiredUiElements: retired } = ctx;
57
+ // A rename PR removes nothing, so an empty `removed` is when the retired case runs.
58
+ if (removed.length === 0 && retired.length === 0)
32
59
  return [];
33
- const plannedTests = Array.isArray(registration?.plannedTests) ? registration.plannedTests : [];
60
+ const plannedTests = registration.plannedTests;
34
61
  const objections = [];
35
62
  const seen = new Set();
36
63
  for (const element of removed) {
@@ -47,11 +74,38 @@ export const removedElementGuarded = {
47
74
  objections.push({
48
75
  objectionId,
49
76
  verifier: "removedElementGuarded",
50
- message: `The diff removes \`${attribute}="${value}"\` and no planned test says it is gone.`,
77
+ message: fillPlaceholders(MISSING.message, { element: `${attribute}="${value}"` }),
51
78
  evidence: `removed from ${text(element?.file) || "a changed frontend file"}; no planned test names ${value}`,
52
- suggestion: "Plan a UI test on the page that still renders, asserting this element is absent, and name it in that test's elements. Removing it is what this pull request ships, so the removal is what gets tested. If the diff shows it still rendering under a new identifier it is a rename, not a removal — say so as the answer.",
79
+ suggestion: MISSING.suggestion,
53
80
  });
54
81
  }
82
+ // Also removed somewhere means removed, whatever one hunk's pairing made of it.
83
+ // Keyed the way the rest of the contract keys an element: `data-cy="legacy"` going
84
+ // away says nothing about `data-testid="legacy"` being renamed.
85
+ const removedKeys = new Set(removed.map((element) => `${text(element?.attribute)}=${text(element?.value)}`).filter((key) => key !== "="));
86
+ for (const element of retired) {
87
+ const value = text(element?.value);
88
+ const attribute = text(element?.attribute);
89
+ if (!value || !attribute || removedKeys.has(`${attribute}=${value}`))
90
+ continue;
91
+ for (const plannedTest of plannedTests) {
92
+ if (!retiredIdentifierNamedBy(plannedTest, value))
93
+ continue;
94
+ const plannedTestId = text(plannedTest?.plannedTestId);
95
+ const objectionId = retiredObjectionIdFor(element, plannedTestId);
96
+ if (seen.has(objectionId))
97
+ continue;
98
+ seen.add(objectionId);
99
+ objections.push({
100
+ objectionId,
101
+ verifier: "removedElementGuarded",
102
+ ...(plannedTestId ? { plannedTestId } : {}),
103
+ message: fillPlaceholders(RETIRED.message, { element: `${attribute}="${value}"` }),
104
+ evidence: `${text(element?.file) || "a changed frontend file"} pairs ${value} with a new identifier on the same element, which reads as a rename; planned test ${plannedTestId || "(unnamed)"} names ${value}`,
105
+ suggestion: RETIRED.suggestion,
106
+ });
107
+ }
108
+ }
55
109
  return objections;
56
110
  },
57
111
  };
@@ -212,6 +212,14 @@ export function authorizeRebaseline(stateData, testFile, requested) {
212
212
  error: `rebaselineSnapshots refused: no phase: "before" execution is recorded for ${path.basename(testFile)}. Run the pre-edit baseline first (it is the evidence the baseline is stale), then pass rebaselineSnapshots on the final run.`,
213
213
  };
214
214
  }
215
+ // SKYR-4334: the before-run is the evidence. If it passed, no baseline is stale — either the
216
+ // committed picture already matches, or there was no picture and that run just created one.
217
+ // A refresh here would rewrite a current picture and let the report claim a fix that fixed nothing.
218
+ if (entry.executionBefore.status === TestExecutionStatus.Pass) {
219
+ return {
220
+ error: `rebaselineSnapshots refused: the phase: "before" run of ${path.basename(testFile)} passed, so no visual baseline is stale (a missing baseline is created by that run). There is nothing to refresh — leave the test as it is and record it as VERIFY, not UPDATE.`,
221
+ };
222
+ }
215
223
  return {};
216
224
  }
217
225
  /**
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  import { StateManager, } from "../../utils/AnalysisStateManager.js";
4
4
  import { TestSource, DriftAction, RecommendationPriority, EstimatedWork } from "../../types/TestAnalysis.js";
5
+ import { TestExecutionStatus } from "../../types/TestExecution.js";
5
6
  import { TestType } from "../../types/TestTypes.js";
6
7
  import * as fs from "fs";
7
8
  import * as path from "path";
@@ -325,14 +326,34 @@ export function registerActionsTool(server) {
325
326
  if (!validBaselines.includes(name))
326
327
  validBaselines.push(name);
327
328
  }
329
+ // SKYR-4334: a refresh is only meaningful when the recorded phase: "before" run showed
330
+ // a mismatch. If that run passed, nothing is stale (a missing picture is created by
331
+ // that run), so the list is dropped here rather than refused later by
332
+ // skyramp_execute_test — and a rebaseline-only UPDATE has nothing left to do, so it
333
+ // is recorded as VERIFY. When no before-run is recorded yet, the execute-time gate
334
+ // still applies.
335
+ let actionForVerdict = rec.action;
336
+ let rationaleForVerdict = rec.rationale;
337
+ if (rec.action === DriftAction.Update && validBaselines.length) {
338
+ const beforeRun = catalogByFile.get(resolvedFile)?.executionBefore;
339
+ if (beforeRun?.status === TestExecutionStatus.Pass) {
340
+ const dropped = validBaselines.splice(0, validBaselines.length);
341
+ const rebaselineOnly = !rec.updateInstructions?.trim();
342
+ droppedRebaselineNotes.push(`${rec.testFilePath}: rebaselineSnapshots [${dropped.join(", ")}] dropped — the phase: "before" run passed, so no baseline is stale (a missing baseline is created by that run). ${rebaselineOnly ? "Recorded as VERIFY: there is nothing to maintain." : "The UPDATE is held to its spec/POM edit only."}`);
343
+ if (rebaselineOnly) {
344
+ actionForVerdict = DriftAction.Verify;
345
+ rationaleForVerdict = `${rec.rationale} [rebaseline dropped: the pre-edit run passed, so the baseline is not stale]`;
346
+ }
347
+ }
348
+ }
328
349
  recommendations.push({
329
350
  testFilePath: resolvedFile,
330
351
  pomFile: resolvedPomFile,
331
- action: rec.action,
352
+ action: actionForVerdict,
332
353
  testType: rec.testType,
333
354
  endpoint: rec.endpoint,
334
355
  priority: rec.priority ?? RecommendationPriority.Medium,
335
- rationale: rec.rationale,
356
+ rationale: rationaleForVerdict,
336
357
  estimatedWork: rec.estimatedWork ?? EstimatedWork.Small,
337
358
  updateInstructions: rec.updateInstructions ?? "",
338
359
  renamedEndpoints: rec.renamedEndpoints ?? [],
@@ -341,13 +362,13 @@ export function registerActionsTool(server) {
341
362
  // baseline name still compares against the committed PNG, and DELETE
342
363
  // removes the spec — neither is a refresh, so the list is dropped (and
343
364
  // surfaced in the response below rather than silently discarded).
344
- ...(rec.action === DriftAction.Update && validBaselines.length
365
+ ...(actionForVerdict === DriftAction.Update && validBaselines.length
345
366
  ? {
346
367
  rebaselineSnapshots: validBaselines,
347
368
  // No updateInstructions → the refresh is the whole maintenance and
348
369
  // the report gate must not expect a spec/POM edit. With
349
370
  // updateInstructions the gate holds the UPDATE to both.
350
- rebaselineOnly: !rec.updateInstructions,
371
+ rebaselineOnly: !rec.updateInstructions?.trim(),
351
372
  }
352
373
  : {}),
353
374
  });
@@ -66,6 +66,8 @@ export interface AnalyzeChangesData {
66
66
  */
67
67
  uiContext?: {
68
68
  removedElements?: RemovedUiElement[];
69
+ /** Identifiers a rename replaced. Never guard these; the removal check reads them. */
70
+ retiredElements?: RemovedUiElement[];
69
71
  };
70
72
  /** Set when the call returned before analysis, and why. */
71
73
  /** The one instruction the result keeps: which tool comes next. */
@@ -141,18 +143,41 @@ export declare const analyzeChangesOutputSchema: {
141
143
  file: string;
142
144
  attribute: string;
143
145
  }>, "many">>;
146
+ retiredElements: z.ZodOptional<z.ZodArray<z.ZodObject<{
147
+ attribute: z.ZodString;
148
+ value: z.ZodString;
149
+ file: z.ZodString;
150
+ }, "strip", z.ZodTypeAny, {
151
+ value: string;
152
+ file: string;
153
+ attribute: string;
154
+ }, {
155
+ value: string;
156
+ file: string;
157
+ attribute: string;
158
+ }>, "many">>;
144
159
  }, "strip", z.ZodTypeAny, {
145
160
  removedElements?: {
146
161
  value: string;
147
162
  file: string;
148
163
  attribute: string;
149
164
  }[] | undefined;
165
+ retiredElements?: {
166
+ value: string;
167
+ file: string;
168
+ attribute: string;
169
+ }[] | undefined;
150
170
  }, {
151
171
  removedElements?: {
152
172
  value: string;
153
173
  file: string;
154
174
  attribute: string;
155
175
  }[] | undefined;
176
+ retiredElements?: {
177
+ value: string;
178
+ file: string;
179
+ attribute: string;
180
+ }[] | undefined;
156
181
  }>>;
157
182
  nextStep: z.ZodOptional<z.ZodString>;
158
183
  };
@@ -15,7 +15,7 @@ import { AnalysisScope } from "../../types/RepositoryAnalysis.js";
15
15
  import { computeBranchDiff, changedFileNames, } from "../../utils/branchDiff.js";
16
16
  import { detectProjectMetadata } from "../../utils/projectMetadata.js";
17
17
  import { diffBlocksByPath } from "../../utils/branchDiff.js";
18
- import { collectRemovedUiElements } from "../../utils/removedUiElements.js";
18
+ import { collectUiIdentifierChanges } from "../../utils/removedUiElements.js";
19
19
  import { parseTraceFile, discoverTraceFiles, discoverPlaywrightZips, } from "../../utils/trace-parser.js";
20
20
  import { TestSource } from "../../types/TestAnalysis.js";
21
21
  /** Exported for testing: maps a parsed trace result to a TraceFile. */
@@ -101,6 +101,11 @@ export const analyzeChangesInputSchema = {
101
101
  .default(false)
102
102
  .describe("When true, diffs the base ref against the working tree (captures uncommitted and unstaged changes). Use for local-dev workflows. Defaults to false (CI mode — committed changes only)."),
103
103
  };
104
+ /** Both halves of `uiContext` carry the same record, so the shape is declared once.
105
+ * It mirrors `RemovedUiElement`; the assignment below fails to compile if either drifts. */
106
+ const uiIdentifierShape = z.object({ attribute: z.string(), value: z.string(), file: z.string() });
107
+ const _uiIdentifierShapeMatchesType = {};
108
+ void _uiIdentifierShapeMatchesType;
104
109
  /** SKYR-4182: structuredContent needs a declared outputSchema, and the schema
105
110
  * has to accept the early exit as well as a full analysis — hence the optional
106
111
  * fields. */
@@ -134,9 +139,8 @@ export const analyzeChangesOutputSchema = {
134
139
  .optional(),
135
140
  uiContext: z
136
141
  .object({
137
- removedElements: z
138
- .array(z.object({ attribute: z.string(), value: z.string(), file: z.string() }))
139
- .optional(),
142
+ removedElements: z.array(uiIdentifierShape).optional(),
143
+ retiredElements: z.array(uiIdentifierShape).optional(),
140
144
  })
141
145
  .optional(),
142
146
  nextStep: z.string().optional(),
@@ -529,9 +533,10 @@ export function registerAnalyzeChangesTool(server) {
529
533
  // check requires, since a removed element appears in no blueprint.
530
534
  // Purely additive: a failure here must never cost the analyze call.
531
535
  let removedElements = [];
536
+ let retiredElements = [];
532
537
  if (diffData?.diffContent) {
533
538
  try {
534
- removedElements = collectRemovedUiElements({
539
+ ({ removed: removedElements, retired: retiredElements } = collectUiIdentifierChanges({
535
540
  // Every changed file that is not a test. A removed `data-testid`
536
541
  // is found by reading the diff, not by the file's extension.
537
542
  changedFrontendFiles: (diffData.changedFiles ?? []).filter((f) => !isTestFile(f)),
@@ -542,13 +547,14 @@ export function registerAnalyzeChangesTool(server) {
542
547
  ...discoveredRelevantExternalPaths,
543
548
  ],
544
549
  discoveryFailed: testDiscoveryFailed,
545
- });
550
+ }));
546
551
  }
547
552
  catch (err) {
548
553
  logger.warning("removedUiElements: collection failed, continuing without removal-guard grounding", {
549
554
  error: err instanceof Error ? err.message : String(err),
550
555
  });
551
556
  removedElements = [];
557
+ retiredElements = [];
552
558
  }
553
559
  }
554
560
  if (removedElements.length > 0) {
@@ -557,7 +563,7 @@ export function registerAnalyzeChangesTool(server) {
557
563
  removed: removedElements.map((e) => `${e.attribute}="${e.value}"`),
558
564
  });
559
565
  }
560
- return removedElements.length > 0 ? { removedElements } : {};
566
+ return { removedElements, retiredElements };
561
567
  })();
562
568
  // The server proposes no tests. It returns the analysis; the agent
563
569
  // drafts its own plan from it and registers that through
@@ -667,7 +673,9 @@ export function registerAnalyzeChangesTool(server) {
667
673
  ...(wsSchemaPath ? { openApiSpecPath: wsSchemaPath } : {}),
668
674
  openApiSpecLoaded: specFetchSucceeded,
669
675
  },
670
- ...(uiContext?.removedElements?.length ? { uiContext } : {}),
676
+ // Removed half only. No prompt describes `retiredElements`, and beside the guard
677
+ // list it reads as more guards to record; the plan checks read the state file.
678
+ ...(uiContext?.removedElements.length ? { uiContext: { removedElements: uiContext.removedElements } } : {}),
671
679
  nextStep: "skyramp_analyze_test_health, with this stateFile",
672
680
  });
673
681
  }
@@ -547,6 +547,7 @@ export async function buildVerifyContext(state) {
547
547
  // removal guard names an element that renders nowhere, so this list is the
548
548
  // only thing that can tell an honest one from an invented element.
549
549
  removedUiElements: sections.flatMap((section) => section.data?.uiContext?.removedElements ?? []),
550
+ retiredUiElements: sections.flatMap((section) => section.data?.uiContext?.retiredElements ?? []),
550
551
  citedFileExists: makeCitedFileCheck(state),
551
552
  routesRenderingFile: makeScreenRouteResolver(state),
552
553
  };
@@ -125,6 +125,8 @@ export interface UiAnalysisContext {
125
125
  * (`toBeHidden()` on the id + a positive sibling assertion) for each entry.
126
126
  */
127
127
  removedElements?: RemovedUiElement[];
128
+ /** Identifiers a rename replaced. Read by the removal check. */
129
+ retiredElements?: RemovedUiElement[];
128
130
  }
129
131
  /** A pre-existing Skyramp-generated test the reuse pass edited to import from a
130
132
  * shared utils file (STEP 5c / the sibling-inline advisory). Keyed by ABSOLUTE
@@ -41,8 +41,10 @@ export declare function extractAddedAttributeValues(diff: string): string[];
41
41
  * element's handlers does not pair. An element whose changed lines carry nothing
42
42
  * but the identifier pairs by tag alone: a relabelled rename (`clear-state` →
43
43
  * `reset-session`, "Clear State" → "Reset Session") and a same-shape replacement
44
- * are the same diff, and the tie goes to rename a guard on a retired identifier
45
- * passes vacuously, and the agent still reads the diff for what the list lacks. A paired added element is consumed, so one addition
44
+ * are the same diff, and the tie goes to rename. So `retired` is what the pairing
45
+ * made of the hunk, not a fact about the diff: a same-shape replacement lands there
46
+ * too, and the removal check then asks the run about a guard that is right. The
47
+ * objection is answerable and the run keeps the test. A paired added element is consumed, so one addition
46
48
  * suppresses one removal, not every removal in the hunk. SKYR-4339: pairing by tag
47
49
  * alone consumed a genuinely removed `<a href="#">` with a genuinely added
48
50
  * `<a href="https://…">` in the same hunk (judgement eval RG03), so the guard was
@@ -52,4 +54,11 @@ export declare function extractAddedAttributeValues(diff: string): string[];
52
54
  * (`toBeHidden()` on `getByTestId(value)` for `data-testid`, on
53
55
  * `locator('[<attribute>="<value>"]')` for any other `data-*`).
54
56
  */
57
+ /** `retired` is the identifier a rename replaced. Only the pairing below knows it,
58
+ * and it used to be discarded there. */
59
+ export interface AttributeChanges {
60
+ removed: RemovedAttribute[];
61
+ retired: RemovedAttribute[];
62
+ }
55
63
  export declare function extractRemovedAttributes(fileDiff: string, addedValuesElsewhere?: Iterable<string>): RemovedAttribute[];
64
+ export declare function extractAttributeChanges(fileDiff: string, addedValuesElsewhere?: Iterable<string>): AttributeChanges;
@@ -119,42 +119,10 @@ export function extractAddedAttributeValues(diff) {
119
119
  }
120
120
  return [...out];
121
121
  }
122
- /**
123
- * Identifying `data-*` attributes one file's diff REMOVED: literals on `-` lines, minus the
124
- * cases where the element still renders somewhere:
125
- *
126
- * - **moved / extracted** — the same value appears on a `+` line of this file or, via
127
- * `addedValuesElsewhere`, anywhere else in the diff (a component extracted into a new
128
- * file is the common shape);
129
- * - **not an element** — a `-` line counts only when it opens a JSX/HTML tag or is a
130
- * wrapped attribute line of a tag opened above it, on a changed or an unchanged
131
- * line (the tag is carried until the `>` that closes it; a `>` inside a quoted
132
- * value, a `{…}` expression or an `=>` arrow is content and does not close it). A deleted comment, docs snippet, story arg or page-object
133
- * `getByTestId(...)` call that merely mentions the attribute is not an element
134
- * removal — the element itself may well survive on an unchanged context line;
135
- * - **renamed** — a rename cannot be told apart by literal identity (old and new
136
- * value differ by definition), so it is paired structurally: within the same hunk,
137
- * each removed ELEMENT is paired with AT MOST ONE added element that opens the same
138
- * tag, carries a NEW identifying attribute, and keeps every other attribute NAME
139
- * the removed element set on its changed lines (a rename may add attributes or
140
- * edit their values, never drop one). With the tag kept on a context line, the
141
- * changed lines are exactly the attributes that differ, so an in-place rename
142
- * compares only what it touched while an in-place replacement that drops the old
143
- * element's handlers does not pair. An element whose changed lines carry nothing
144
- * but the identifier pairs by tag alone: a relabelled rename (`clear-state` →
145
- * `reset-session`, "Clear State" → "Reset Session") and a same-shape replacement
146
- * are the same diff, and the tie goes to rename — a guard on a retired identifier
147
- * passes vacuously, and the agent still reads the diff for what the list lacks. A paired added element is consumed, so one addition
148
- * suppresses one removal, not every removal in the hunk. SKYR-4339: pairing by tag
149
- * alone consumed a genuinely removed `<a href="#">` with a genuinely added
150
- * `<a href="https://…">` in the same hunk (judgement eval RG03), so the guard was
151
- * never listed.
152
- *
153
- * Feeds `uiContext.removedElements` (SKYR-4305) — the grounding for a removal guard
154
- * (`toBeHidden()` on `getByTestId(value)` for `data-testid`, on
155
- * `locator('[<attribute>="<value>"]')` for any other `data-*`).
156
- */
157
122
  export function extractRemovedAttributes(fileDiff, addedValuesElsewhere = []) {
123
+ return extractAttributeChanges(fileDiff, addedValuesElsewhere).removed;
124
+ }
125
+ export function extractAttributeChanges(fileDiff, addedValuesElsewhere = []) {
158
126
  const addedAnywhere = new Set(addedValuesElsewhere);
159
127
  for (const v of extractAddedAttributeValues(fileDiff))
160
128
  addedAnywhere.add(v);
@@ -196,7 +164,9 @@ export function extractRemovedAttributes(fileDiff, addedValuesElsewhere = []) {
196
164
  return t.includes(">");
197
165
  };
198
166
  const out = [];
167
+ const retired = [];
199
168
  const seen = new Set();
169
+ const seenRetired = new Set();
200
170
  for (const hunk of hunks) {
201
171
  // Group changed lines into elements per side: an element starts on the line that
202
172
  // opens its tag and carries on until the `>` that closes the opening tag, so a
@@ -269,6 +239,16 @@ export function extractRemovedAttributes(fileDiff, addedValuesElsewhere = []) {
269
239
  });
270
240
  if (partner) {
271
241
  partner.consumed = true;
242
+ for (const a of r.ids) {
243
+ // Pairing does not require the id to change: an edited line pairs with itself.
244
+ if (addedAnywhere.has(a.value))
245
+ continue;
246
+ const key = `${a.attribute}=${a.value}`;
247
+ if (!seenRetired.has(key)) {
248
+ seenRetired.add(key);
249
+ retired.push(a);
250
+ }
251
+ }
272
252
  continue;
273
253
  }
274
254
  for (const a of r.ids) {
@@ -280,5 +260,5 @@ export function extractRemovedAttributes(fileDiff, addedValuesElsewhere = []) {
280
260
  }
281
261
  }
282
262
  }
283
- return out;
263
+ return { removed: out, retired };
284
264
  }
@@ -19,4 +19,11 @@ export interface CollectRemovedUiElementsInput {
19
19
  /** Injectable for tests; defaults to a tolerant fs read that logs failures. */
20
20
  readFile?: (absPath: string) => string | undefined;
21
21
  }
22
+ /** `retired` holds the identifiers a rename replaced. No existing-test filter: that
23
+ * filter answers "is this maintenance", which a false guard is not. */
24
+ export interface UiIdentifierChanges {
25
+ removed: RemovedUiElement[];
26
+ retired: RemovedUiElement[];
27
+ }
22
28
  export declare function collectRemovedUiElements(input: CollectRemovedUiElementsInput): RemovedUiElement[];
29
+ export declare function collectUiIdentifierChanges(input: CollectRemovedUiElementsInput): UiIdentifierChanges;
@@ -26,7 +26,7 @@
26
26
  * Every skip is logged at debug with its reason so a missing guard is diagnosable.
27
27
  */
28
28
  import * as fs from "fs";
29
- import { extractAddedAttributeValues, extractRemovedAttributes } from "./frontendSelectors.js";
29
+ import { extractAddedAttributeValues, extractAttributeChanges } from "./frontendSelectors.js";
30
30
  import { escapeRegExp } from "./regex.js";
31
31
  import { logger } from "./logger.js";
32
32
  function defaultReadFile(absPath) {
@@ -51,9 +51,12 @@ function referencesLiteral(content, value) {
51
51
  return new RegExp(`(^|[^\\w:-])${escapeRegExp(value)}(?![\\w:-])`).test(content);
52
52
  }
53
53
  export function collectRemovedUiElements(input) {
54
+ return collectUiIdentifierChanges(input).removed;
55
+ }
56
+ export function collectUiIdentifierChanges(input) {
57
+ // Only the REMOVED half reads the existing tests, so only that half fails closed.
54
58
  if (input.discoveryFailed) {
55
- logger.warning("removedUiElements: test discovery failed — cannot tell whether an existing test covers a removed id; reporting none");
56
- return [];
59
+ logger.warning("removedUiElements: test discovery failed — cannot tell whether an existing test covers a removed id; reporting removals as none, retired identifiers as normal");
57
60
  }
58
61
  const readFile = input.readFile ?? defaultReadFile;
59
62
  const deleted = new Set(input.deletedFiles);
@@ -76,7 +79,9 @@ export function collectRemovedUiElements(input) {
76
79
  return testContents.some((c) => referencesLiteral(c, value));
77
80
  };
78
81
  const out = [];
82
+ const retired = [];
79
83
  const seen = new Set();
84
+ const seenRetired = new Set();
80
85
  for (const file of input.changedFrontendFiles) {
81
86
  if (deleted.has(file)) {
82
87
  skip(file, "file deleted by the diff (whole surface removed)");
@@ -87,7 +92,15 @@ export function collectRemovedUiElements(input) {
87
92
  skip(file, "no diff slice");
88
93
  continue;
89
94
  }
90
- const attrs = extractRemovedAttributes(slice, addedAcrossDiff);
95
+ const changes = extractAttributeChanges(slice, addedAcrossDiff);
96
+ for (const { attribute, value } of changes.retired) {
97
+ const key = `${attribute}=${value}`;
98
+ if (seenRetired.has(key))
99
+ continue;
100
+ seenRetired.add(key);
101
+ retired.push({ attribute, value, file });
102
+ }
103
+ const attrs = input.discoveryFailed ? [] : changes.removed;
91
104
  if (attrs.length === 0)
92
105
  continue;
93
106
  for (const { attribute, value } of attrs) {
@@ -102,5 +115,5 @@ export function collectRemovedUiElements(input) {
102
115
  out.push({ attribute, value, file });
103
116
  }
104
117
  }
105
- return out;
118
+ return { removed: out, retired };
106
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.4.0-rc.1",
3
+ "version": "0.4.0",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",