@skyramp/mcp 0.4.3 → 0.4.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/build/playwright/traceRecordingPrompt.js +2 -1
  2. package/build/prompts/orderAssertionRule.d.ts +9 -0
  3. package/build/prompts/orderAssertionRule.js +9 -0
  4. package/build/prompts/test-maintenance/driftAnalysisShared.js +2 -0
  5. package/build/prompts/testbot/testbot-prompts.js +10 -7
  6. package/build/recommendation/answers.d.ts +54 -19
  7. package/build/recommendation/answers.js +102 -31
  8. package/build/recommendation/blockerProbes.d.ts +13 -0
  9. package/build/recommendation/blockerProbes.js +91 -0
  10. package/build/recommendation/blockers.d.ts +65 -0
  11. package/build/recommendation/blockers.js +135 -0
  12. package/build/recommendation/pullRequestText.d.ts +2 -0
  13. package/build/recommendation/pullRequestText.js +16 -0
  14. package/build/recommendation/registerPlan.d.ts +7 -4
  15. package/build/recommendation/registerPlan.js +37 -11
  16. package/build/recommendation/types.d.ts +21 -4
  17. package/build/recommendation/verifierContracts.d.ts +6 -6
  18. package/build/recommendation/verifierContracts.js +8 -7
  19. package/build/recommendation/verifiers/citedPath.d.ts +2 -2
  20. package/build/recommendation/verifiers/citedPath.js +2 -2
  21. package/build/recommendation/verifiers/coverage.js +25 -25
  22. package/build/recommendation/verifiers/deliveredMatchesPlan.js +3 -3
  23. package/build/recommendation/verifiers/issueTraceability.d.ts +1 -5
  24. package/build/recommendation/verifiers/issueTraceability.js +4 -35
  25. package/build/tools/executeSkyrampTestTool.js +8 -0
  26. package/build/tools/generate-tests/generateE2ERestTool.d.ts +1 -0
  27. package/build/tools/generate-tests/generateE2ERestTool.js +9 -1
  28. package/build/tools/generate-tests/generateUIRestTool.d.ts +2 -0
  29. package/build/tools/generate-tests/generateUIRestTool.js +9 -1
  30. package/build/tools/submitReportTool.d.ts +9 -9
  31. package/build/tools/submitReportTool.js +178 -26
  32. package/build/tools/test-management/registerTestPlanTool.d.ts +20 -14
  33. package/build/tools/test-management/registerTestPlanTool.js +30 -14
  34. package/build/types/TestTypes.d.ts +7 -0
  35. package/build/types/TestTypes.js +22 -0
  36. package/build/types/TestbotReport.d.ts +4 -1
  37. package/build/utils/assertion-verify/metrics.js +12 -0
  38. package/build/utils/findUp.d.ts +20 -0
  39. package/build/utils/findUp.js +59 -0
  40. package/build/utils/language-helper.js +1 -1
  41. package/build/utils/repoSdkVersion.d.ts +16 -0
  42. package/build/utils/repoSdkVersion.js +88 -0
  43. package/build/utils/testDependencyPolicy.d.ts +20 -0
  44. package/build/utils/testDependencyPolicy.js +64 -4
  45. package/build/utils/utils-verify/typecheck.js +3 -18
  46. package/node_modules/playwright/lib/mcp/skyramp/assertOrder.js +66 -0
  47. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +12 -3
  48. package/node_modules/playwright/lib/mcp/skyramp/common/liveReadError.js +40 -0
  49. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +21 -0
  50. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -1
  51. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +96 -16
  52. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +10 -0
  53. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  54. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderRunner.js +22 -0
  55. package/node_modules/playwright/node_modules/playwright-core/lib/utils/isomorphic/elementOrder.js +57 -0
  56. package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
  57. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.uiPTFncY.js → index.0MJW0uG7.js} +1 -1
  58. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  59. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  60. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  61. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderRunner.ts +26 -1
  62. package/node_modules/playwright/node_modules/playwright-core/src/utils/isomorphic/elementOrder.ts +83 -0
  63. package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
  64. package/node_modules/playwright/package.json +1 -1
  65. package/package.json +4 -2
  66. package/plugin/prompts/plan-tests.md +9 -17
  67. package/plugin/skills/enhance-assertions/reference/shared-rules.md +1 -1
  68. package/plugin/skills/enhance-assertions/reference/ui.md +2 -2
  69. package/plugin/skills/fix-test-import-errors/SKILL.md +1 -1
@@ -5,15 +5,17 @@ import { constants as fsConstants } from "fs";
5
5
  import * as path from "path";
6
6
  import { AnalyticsService } from "../services/AnalyticsService.js";
7
7
  import { SCENARIO_CATEGORIES, categoryExpectsRed, } from "../types/TestRecommendation.js";
8
- import { TESTBOT_SUPPORTED_TEST_TYPES, TestType } from "../types/TestTypes.js";
8
+ import { ProgrammingLanguage, TESTBOT_SUPPORTED_TEST_TYPES, TestType, } from "../types/TestTypes.js";
9
9
  import { STEP_METHOD_DESCRIPTION, stepMethodSchema, } from "../types/StepMethod.js";
10
10
  import { DriftAction } from "../types/TestAnalysis.js";
11
- import { TestExecutionStatus } from "../types/TestExecution.js";
11
+ import { isResult, TestExecutionStatus } from "../types/TestExecution.js";
12
12
  import { IssueFoundCategory } from "../types/TestbotReport.js";
13
13
  import { StateManager, runArtifactDir, getTestsRepoDir, resolveOwnRunStatePath, } from "../utils/AnalysisStateManager.js";
14
14
  import { toolError, testFileMatches } from "../utils/utils.js";
15
15
  import { isTestbotEnabled, isUtilsReuseEnabled, } from "../utils/featureFlags.js";
16
- import { answerFor, unknownAnswerObjections, } from "../recommendation/answers.js";
16
+ import { BLOCKER_DESCRIPTION, closingAnswerFor, needsBlocker, REFUSED_ANSWER_PREFIX, refusedAnswerObjections, unknownAnswerObjections, } from "../recommendation/answers.js";
17
+ import { blockersIn, probeBlockers } from "../recommendation/blockerProbes.js";
18
+ import { blockerSchema, blockerText, hasBlocker, refutedEvidence, verifyBlocker, } from "../recommendation/blockers.js";
17
19
  import { checkDeliveredMatchesPlan, checkMaintenanceDelivered, } from "../recommendation/verifiers/deliveredMatchesPlan.js";
18
20
  import { checkReportedCategoryMatchesPlan, checkRequirementConflictReported, } from "../recommendation/verifiers/reportedCategory.js";
19
21
  import { checkDefectsReported, checkIssueTraceability, } from "../recommendation/verifiers/issueTraceability.js";
@@ -79,6 +81,28 @@ function rowsLeavingCoverage(rows) {
79
81
  MAINTENANCE_COVERAGE_ACTIONS.has(row.action) &&
80
82
  row?.reportOnly !== true);
81
83
  }
84
+ /** The VERIFY rows this run owes one run: the test is read, not edited, so its
85
+ * only run is the one after the edits. */
86
+ function verifyRows(rows) {
87
+ return (rows ?? []).filter((row) => row?.action === DriftAction.Verify && row?.reportOnly !== true);
88
+ }
89
+ /** The rows that back a `maintains` claim: an edit this run made, or a VERIFY
90
+ * whose test this server ran after the edits to a result. A VERIFY that never
91
+ * ran, or whose run left only the Unknown reservation, backs nothing. In the
92
+ * plan-only lane nothing runs, so a VERIFY backs the claim without a run. */
93
+ function rowsBackingMaintains(rows, executions, planOnly) {
94
+ return [
95
+ ...rowsLeavingCoverage(rows),
96
+ ...verifyRows(rows).filter((row) => {
97
+ if (planOnly)
98
+ return true;
99
+ if (typeof row.testFilePath !== "string")
100
+ return false;
101
+ const run = executions?.[canonicalTestPath(row.testFilePath)];
102
+ return (run?.phase === "after" && isResult(run.status));
103
+ }),
104
+ ];
105
+ }
82
106
  /** Objection ids for an untested declared change, `coverage:change:<id>`. The
83
107
  * change table reads the agent's answer back off them. */
84
108
  const CHANGE_OBJECTION_PREFIX = "coverage:change:";
@@ -350,7 +374,7 @@ const issueFoundSchema = z
350
374
  .optional()
351
375
  .describe("The `id` of the plan defect this issue reports, from the `defects` list you registered with skyramp_register_test_plan. Spell it as that list spells it: an id the plan does not declare traces nothing. " +
352
376
  "Set it on the `category: bug` entry that reports the defect. A `defectId` on a `lint`, `type` or `config` entry does not report the defect — the report renders those in its own Configuration Errors section. " +
353
- "CHECKED: every plan defect has a `bug` issue that names it, and a `bug` entry with no `plannedTestId` traces through this defect when a delivered test that expects to fail cites it, when a `maintains` entry you registered cites it, or when you answered `defects:untested:<id>` at plan time. Leave it out for an issue the plan did not declare."),
377
+ "CHECKED: every plan defect has a `bug` issue that names it, and a `bug` entry with no `plannedTestId` traces through this defect when a delivered test that expects to fail cites it, or when a `maintains` entry you registered cites it. Leave it out for an issue the plan did not declare."),
354
378
  sourceFile: citationString.describe("Path of the application file whose code is missing or wrong, relative to the repository root (e.g. 'src/crud/products.py'). " +
355
379
  "REQUIRED when category is 'bug'. " +
356
380
  "For code that is MISSING — an unmounted router, an unregistered route, an import never added — name the file where the line should be, not the file that defines what is unmounted. " +
@@ -1217,6 +1241,9 @@ async function pathExists(file) {
1217
1241
  * missing timestamp on either side falls back to "a run is a run", which is the
1218
1242
  * behaviour before this and never invents an objection. */
1219
1243
  function runOfWhatWasWritten(file) {
1244
+ if (file.how === "verified")
1245
+ return (file.recordedPhase === "after" &&
1246
+ file.recordedStatus !== TestExecutionStatus.Unknown);
1220
1247
  if (file.how === "updated")
1221
1248
  return file.recordedPhase === "after";
1222
1249
  if (file.recordedPhase === undefined)
@@ -1229,7 +1256,8 @@ function runOfWhatWasWritten(file) {
1229
1256
  return true;
1230
1257
  return ran >= wrote;
1231
1258
  }
1232
- /** Every test file this run wrote has an execution this server performed.
1259
+ /** Every test file this run wrote, and every VERIFY file it read, has an
1260
+ * execution this server performed.
1233
1261
  *
1234
1262
  * Informational, like every check here: it surfaces the gap for a person to read
1235
1263
  * and the agent can answer it. The agent is not blocked — a hard ban on running a
@@ -1257,9 +1285,11 @@ function checkEveryWrittenTestRan(written) {
1257
1285
  // drove itself is not visible here.
1258
1286
  message: file.how === "generated"
1259
1287
  ? "This run generated this test file, and this server recorded no run of it after it was written."
1260
- : file.recordedPhase === "before"
1261
- ? "This run edited this test file, and the only run of it this server recorded is from before the edit."
1262
- : "This run edited this test file, and this server recorded no run of it.",
1288
+ : file.how === "verified"
1289
+ ? "This run marked this test file VERIFY, and this server recorded no run of it after the edits."
1290
+ : file.recordedPhase === "before"
1291
+ ? "This run edited this test file, and the only run of it this server recorded is from before the edit."
1292
+ : "This run edited this test file, and this server recorded no run of it.",
1263
1293
  evidence: file.recordedPhase === "before"
1264
1294
  ? `${file.file} — the only recorded skyramp_execute_test run of this file is the pre-edit baseline`
1265
1295
  : file.how === "generated" && file.recordedPhase !== undefined
@@ -1382,11 +1412,12 @@ export function registerSubmitReportTool(server) {
1382
1412
  answer: z
1383
1413
  .string()
1384
1414
  .describe("Why the objection does not apply, or what you decided instead. A blank or whitespace-only answer closes nothing and the objection stays open."),
1415
+ blocker: blockerSchema.optional().describe(BLOCKER_DESCRIPTION),
1385
1416
  })
1386
1417
  .strict())
1387
1418
  .optional()
1388
1419
  .default([])
1389
- .describe("One answer per check that objected to your previous call and that you do not intend to fix. An answered objection is published with your answer beside it and no longer counted open; an unanswered one is published on its own. Answers persist across calls, so an id answered once stays answered. There is no call limit."),
1420
+ .describe("One answer per check that objected to your previous call and that you do not intend to fix. An answered objection is published with your answer beside it and no longer counted open; an unanswered one is published on its own. An objection about a bug with no test is the exception: it closes only when the answer also sets `blocker`, and a sentence on its own leaves it open and published. Answers persist across calls, so an id answered once stays answered — with the blocker it carried. There is no call limit."),
1390
1421
  stateFile: z
1391
1422
  .string()
1392
1423
  .optional()
@@ -2195,9 +2226,10 @@ export function registerSubmitReportTool(server) {
2195
2226
  // Every file this run wrote, from the server's own records — one canonical
2196
2227
  // absolute path each, which is also the key an execution is recorded under.
2197
2228
  // Generated files come from the generators; edited files from the maintenance
2198
- // triage, narrowed by `rowsLeavingCoverage` to UPDATE and REGENERATE. The rest
2199
- // cannot be run: DELETE removed the file, VERIFY and IGNORE never edited one,
2200
- // and a `reportOnly` row only advised a change this run did not make.
2229
+ // triage, narrowed by `rowsLeavingCoverage` to UPDATE and REGENERATE. A VERIFY
2230
+ // file was not written but owes the same after-edit run. The rest owe none:
2231
+ // DELETE removed the file, IGNORE never touched one, and a `reportOnly` row
2232
+ // only advised a change this run did not make.
2201
2233
  //
2202
2234
  // A verdict names the SPEC, never `pomFile`: a POM-backed row edits the page
2203
2235
  // object, but the execution is recorded against the spec.
@@ -2232,6 +2264,23 @@ export function registerSubmitReportTool(server) {
2232
2264
  filesWritten.set(key, "updated");
2233
2265
  }
2234
2266
  }
2267
+ // A VERIFY file is not written, but owes the same after-edit run
2268
+ // (SKYR-4523). Primary only: the server turns every edit to a related
2269
+ // repository's test into a VERIFY the agent never chose. A language
2270
+ // skyramp_execute_test does not accept can never record a run.
2271
+ const runnableLanguages = Object.values(ProgrammingLanguage);
2272
+ for (const verdict of verifyRows(stateData.maintenanceVerdicts)) {
2273
+ const raw = verdict.testFilePath?.trim();
2274
+ if (!raw)
2275
+ continue;
2276
+ const key = canonicalTestPath(raw);
2277
+ const row = stateData.existingTests?.find((t) => canonicalTestPath(t.testFile) === key);
2278
+ // An uncatalogued file has no row for its result to land on.
2279
+ if (!row || !runnableLanguages.includes(row.language))
2280
+ continue;
2281
+ if (!filesWritten.has(key) && !deletedPaths.has(key))
2282
+ filesWritten.set(key, "verified");
2283
+ }
2235
2284
  // The LATEST run of each file, because a re-run replaces the record. An edited
2236
2285
  // file needs a run of the edit, so its pre-edit baseline does not answer for
2237
2286
  // it — `phase` is what tells the two apart. A generated file has no baseline,
@@ -2240,6 +2289,7 @@ export function registerSubmitReportTool(server) {
2240
2289
  file,
2241
2290
  {
2242
2291
  phase: String(record?.phase ?? ""),
2292
+ status: String(record?.status ?? ""),
2243
2293
  executedAt: String(record?.executedAt ?? ""),
2244
2294
  },
2245
2295
  ])));
@@ -2256,6 +2306,7 @@ export function registerSubmitReportTool(server) {
2256
2306
  file,
2257
2307
  how,
2258
2308
  recordedPhase: run?.phase,
2309
+ recordedStatus: run?.status,
2259
2310
  executedAt: run?.executedAt,
2260
2311
  generatedAt: generatedAt.get(file),
2261
2312
  });
@@ -2265,6 +2316,7 @@ export function registerSubmitReportTool(server) {
2265
2316
  const executionCoverage = {
2266
2317
  generated: written.filter((f) => f.how === "generated").length,
2267
2318
  updated: written.filter((f) => f.how === "updated").length,
2319
+ verified: written.filter((f) => f.how === "verified").length,
2268
2320
  executed: written.filter(runOfWhatWasWritten).length,
2269
2321
  };
2270
2322
  // A skipped test is not an execution. Refuse before writing a report so the
@@ -2423,10 +2475,11 @@ export function registerSubmitReportTool(server) {
2423
2475
  }, dedupedNewTests, params.testResults, params.issuesFound ?? [], isPlanOnlyMode(),
2424
2476
  // The triage's own verdicts, not this tool's input, so the join is against
2425
2477
  // what the run is recorded as having edited — and only the rows that left a
2426
- // test standing and were actually applied, so a no-op assessment, a deletion
2427
- // or a report-only recommendation cannot corroborate. The verdicts rather
2428
- // than the published rows because `reportOnly` lives here.
2429
- rowsLeavingCoverage(stateData.maintenanceVerdicts));
2478
+ // test standing and were actually applied, or a VERIFY whose test ran after
2479
+ // the edits — so an unrun assessment, a deletion or a report-only
2480
+ // recommendation cannot corroborate. The verdicts rather than the
2481
+ // published rows because `reportOnly` lives here.
2482
+ rowsBackingMaintains(stateData.maintenanceVerdicts, stateData.testExecutions, isPlanOnlyMode()));
2430
2483
  // Read defensively, like the crash-contained checks above: the plan comes
2431
2484
  // off disk, so its declared array type holds only on the validated path.
2432
2485
  const stillOpen = Array.isArray(stateData.plan?.openObjections)
@@ -2460,6 +2513,14 @@ export function registerSubmitReportTool(server) {
2460
2513
  typeof entry?.answer === "string" &&
2461
2514
  entry.answer.trim().length > 0);
2462
2515
  const carriedByKey = new Map(carried.map((entry) => [answerKey(entry.objection), entry]));
2516
+ // Probed here for the same reason the plan tool probes before building its
2517
+ // context: the checks below are synchronous. Both this call's blockers and
2518
+ // the carried ones, so a carried close is re-judged rather than trusted.
2519
+ const blockerChecks = await probeBlockers([
2520
+ ...blockersIn(acknowledged),
2521
+ ...blockersIn(carried),
2522
+ ]);
2523
+ const judge = (blocker) => verifyBlocker(blocker, blockerChecks);
2463
2524
  // What the LAST call raised. Without it there is nothing to notice a
2464
2525
  // fixed objection's absence against.
2465
2526
  const previouslyRaised = (Array.isArray(stateData.reportObjections?.raisedObjections)
@@ -2486,12 +2547,35 @@ export function registerSubmitReportTool(server) {
2486
2547
  ...raised,
2487
2548
  ...previouslyRaised.map((entry) => entry.objection),
2488
2549
  ];
2550
+ const raisedKeys = new Set(raised.map(answerKey));
2489
2551
  const reportStageObjections = [
2490
2552
  ...raised,
2491
2553
  ...unknownAnswerObjections(answerable, acknowledged, {
2492
2554
  scope: "this run raised",
2493
2555
  suggestion: "Answer only the ids this tool has listed on this run. A plan-time objection is answered through skyramp_register_test_plan's own answers, not here.",
2494
2556
  }),
2557
+ // The same refusal plan time raises: a sentence sent for an objection that
2558
+ // takes a blocker leaves that objection open, and this says so rather than
2559
+ // letting the answer pass in silence. Against this call's answers only,
2560
+ // like `registerPlan` — a carried one that closed nothing simply stays
2561
+ // carried, and its objection stays published open.
2562
+ ...refusedAnswerObjections(raised, acknowledged, judge, (id) =>
2563
+ // The same rule the loop below applies to a carried answer. Without
2564
+ // it a second, weaker answer for an objection an earlier call already
2565
+ // closed raised `answers:refused:` beside the close this call still
2566
+ // publishes as answered. Matched by id AND message, as `carriedByKey`
2567
+ // is: one id can name a different bug on a later call, and a close
2568
+ // carried for the first must not silence a refusal for the second.
2569
+ carried.some((entry) => {
2570
+ if (entry.objection?.objectionId !== id)
2571
+ return false;
2572
+ if (!raisedKeys.has(answerKey(entry.objection)))
2573
+ return false;
2574
+ const blocker = hasBlocker(entry.blocker)
2575
+ ? entry.blocker
2576
+ : undefined;
2577
+ return (!needsBlocker(id) || (!!blocker && judge(blocker) !== "refuted"));
2578
+ })),
2495
2579
  ];
2496
2580
  const raisedNow = new Set(reportStageObjections.map(answerKey));
2497
2581
  // A call with no execution result gives these checks nothing to judge, so
@@ -2521,12 +2605,57 @@ export function registerSubmitReportTool(server) {
2521
2605
  const answeredReportObjections = [];
2522
2606
  const openReportObjections = [];
2523
2607
  for (const objection of reportStageObjections) {
2524
- const answer = answerFor(objection, acknowledged) ??
2525
- carriedByKey.get(answerKey(objection))?.answer;
2526
- if (answer)
2527
- answeredReportObjections.push({ objection, answer });
2528
- else
2608
+ // `closingAnswerFor` applies the blocker rule to THIS call's answers. A
2609
+ // carried answer was accepted on an earlier call and is re-checked against
2610
+ // the same rule here, because the objection it closed is recomputed every
2611
+ // call. The answer and the blocker come from the ONE entry that closed it:
2612
+ // searching for the blocker apart from the answer published the first
2613
+ // blocker sent, refuted or belonging to no answer at all.
2614
+ const wantsBlocker = needsBlocker(objection.objectionId);
2615
+ const closingNow = closingAnswerFor(objection, acknowledged, judge);
2616
+ const blockerNow = hasBlocker(closingNow?.blocker)
2617
+ ? closingNow.blocker
2618
+ : undefined;
2619
+ const carriedEntry = carriedByKey.get(answerKey(objection));
2620
+ const carriedBlocker = hasBlocker(carriedEntry?.blocker)
2621
+ ? carriedEntry?.blocker
2622
+ : undefined;
2623
+ const answerNow = closingNow?.answer;
2624
+ // A carried answer meets the same rule as the call that sent it: a
2625
+ // blocker, and one this call's probe does not refute.
2626
+ const carriedCloses = carriedEntry &&
2627
+ (!wantsBlocker ||
2628
+ (!!carriedBlocker && judge(carriedBlocker) !== "refuted"));
2629
+ const answer = answerNow ?? (carriedCloses ? carriedEntry?.answer : undefined);
2630
+ if (!answer) {
2529
2631
  openReportObjections.push(objection);
2632
+ // A carried answer that closed this objection on an earlier call and
2633
+ // does not now is the probe changing its mind — the service came back
2634
+ // up. Without this the objection simply reappears with no reason.
2635
+ if (carriedEntry &&
2636
+ carriedBlocker &&
2637
+ judge(carriedBlocker) === "refuted") {
2638
+ openReportObjections.push({
2639
+ objectionId: `${REFUSED_ANSWER_PREFIX}${objection.objectionId}`,
2640
+ verifier: "answers",
2641
+ message: "The server checked the blocker this answer names and found the opposite, so the objection stays open.",
2642
+ evidence: `answered "${objection.objectionId}" on an earlier call with \`${carriedBlocker.kind}\`; ${refutedEvidence(carriedBlocker)}`,
2643
+ suggestion: "Write the test: what you named as the blocker is not in the way. If something else is, send the blocker that names it — the server checks each kind against the run it can see.",
2644
+ });
2645
+ }
2646
+ continue;
2647
+ }
2648
+ // A refuted blocker is not published, as at plan time: on an objection any
2649
+ // answer closes, the sentence closes it and the untrue claim is dropped.
2650
+ const sent = answerNow ? blockerNow : carriedBlocker;
2651
+ const blocker = sent && judge(sent) !== "refuted" ? sent : undefined;
2652
+ answeredReportObjections.push({
2653
+ objection,
2654
+ answer,
2655
+ ...(blocker
2656
+ ? { blocker, blockerVerified: judge(blocker) === "verified" }
2657
+ : {}),
2658
+ });
2530
2659
  }
2531
2660
  objections = [
2532
2661
  ...stillOpen.map((objection) => ({
@@ -2539,6 +2668,16 @@ export function registerSubmitReportTool(server) {
2539
2668
  ...entry.objection,
2540
2669
  stage: "plan",
2541
2670
  answer: typeof entry.answer === "string" ? entry.answer : "",
2671
+ // A plan-time close that a blocker carried says so here, exactly as
2672
+ // the post-execution closes below do. Without this a probed close
2673
+ // was published as an ordinary sentence and the report lost the one
2674
+ // fact that distinguishes them.
2675
+ ...(hasBlocker(entry.blocker)
2676
+ ? {
2677
+ unverifiedBlocker: blockerText(entry.blocker),
2678
+ blockerVerified: !!entry.blockerVerified,
2679
+ }
2680
+ : {}),
2542
2681
  })),
2543
2682
  ...unverified
2544
2683
  .filter((entry) => entry?.objection)
@@ -2546,16 +2685,29 @@ export function registerSubmitReportTool(server) {
2546
2685
  ...entry.objection,
2547
2686
  stage: "plan",
2548
2687
  answer: typeof entry.answer === "string" ? entry.answer : "",
2549
- unverifiedBlocker: typeof entry.blocker === "string" ? entry.blocker : "",
2688
+ // `unverifiedCloses[].blocker` is a `PlanBlocker`; reading it as a
2689
+ // string published an empty one on every plan-time close.
2690
+ ...(hasBlocker(entry.blocker)
2691
+ ? { unverifiedBlocker: blockerText(entry.blocker) }
2692
+ : {}),
2550
2693
  })),
2551
2694
  ...openReportObjections.map((objection) => ({
2552
2695
  ...objection,
2553
2696
  stage: "post-execution",
2554
2697
  })),
2555
- ...answeredReportObjections.map(({ objection, answer }) => ({
2698
+ // The blocker is published beside the answer exactly as the plan-time
2699
+ // closes above are, and `blockerVerified` says whether a probe backed it
2700
+ // or the server could only take it as given.
2701
+ ...answeredReportObjections.map(({ objection, answer, blocker, blockerVerified }) => ({
2556
2702
  ...objection,
2557
2703
  stage: "post-execution",
2558
2704
  answer,
2705
+ ...(blocker
2706
+ ? {
2707
+ unverifiedBlocker: blockerText(blocker),
2708
+ blockerVerified: !!blockerVerified,
2709
+ }
2710
+ : {}),
2559
2711
  })),
2560
2712
  ...resolvedObjections.map(({ objection, resolvedBy }) => ({
2561
2713
  ...objection,
@@ -2593,10 +2745,10 @@ export function registerSubmitReportTool(server) {
2593
2745
  //
2594
2746
  // `testFileMatches` rather than a basename, so a spec under one directory
2595
2747
  // cannot lend its coverage to a same-named spec under another; and only the
2596
- // rows that left a test standing, so DELETE and the no-op actions cannot
2597
- // corroborate a claim that an existing test covers the change.
2748
+ // rows `rowsBackingMaintains` keeps, so DELETE, IGNORE and an unrun VERIFY
2749
+ // cannot corroborate a claim that an existing test covers the change.
2598
2750
  const editedPaths = [];
2599
- for (const row of rowsLeavingCoverage(stateData.maintenanceVerdicts)) {
2751
+ for (const row of rowsBackingMaintains(stateData.maintenanceVerdicts, stateData.testExecutions, isPlanOnlyMode())) {
2600
2752
  for (const full of [
2601
2753
  row?.testFilePath,
2602
2754
  row?.pomFile,
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StateManager, UnifiedAnalysisState } from "../../utils/AnalysisStateManager.js";
4
- import { PlanResult } from "../../recommendation/registerPlan.js";
5
- import { ObjectionAnswer, PlanChange, PlanDefect, PlanInput, PlanMaintenance, VerifyContext } from "../../recommendation/types.js";
4
+ import { Plan, PlanResult } from "../../recommendation/registerPlan.js";
5
+ import { ObjectionAnswer, PlanBlocker, PlanChange, PlanDefect, PlanInput, PlanMaintenance, VerifyContext } from "../../recommendation/types.js";
6
6
  /** The whole run's state file: the primary repository at the root, every related
7
7
  * repository under `relatedRepos`. */
8
8
  type RunState = Awaited<ReturnType<StateManager<UnifiedAnalysisState>["readFullState"]>>;
@@ -72,9 +72,9 @@ export declare const declarationFieldsSchema: z.ZodObject<{
72
72
  widgetType: z.ZodOptional<z.ZodEnum<["native", "custom", "unknown"]>>;
73
73
  }, "strip", z.ZodTypeAny, {
74
74
  role: string;
75
- accessibleName: string;
76
75
  testId: string | null;
77
76
  stableId: string | null;
77
+ accessibleName: string;
78
78
  contextText: string[] | null;
79
79
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
80
80
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -90,9 +90,9 @@ export declare const declarationFieldsSchema: z.ZodObject<{
90
90
  }, "strict", z.ZodTypeAny, {
91
91
  items?: {
92
92
  role: string;
93
- accessibleName: string;
94
93
  testId: string | null;
95
94
  stableId: string | null;
95
+ accessibleName: string;
96
96
  contextText: string[] | null;
97
97
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
98
98
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -130,9 +130,9 @@ export declare const declarationFieldsSchema: z.ZodObject<{
130
130
  elements?: {
131
131
  items?: {
132
132
  role: string;
133
- accessibleName: string;
134
133
  testId: string | null;
135
134
  stableId: string | null;
135
+ accessibleName: string;
136
136
  contextText: string[] | null;
137
137
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
138
138
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -328,9 +328,9 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
328
328
  widgetType: z.ZodOptional<z.ZodEnum<["native", "custom", "unknown"]>>;
329
329
  }, "strip", z.ZodTypeAny, {
330
330
  role: string;
331
- accessibleName: string;
332
331
  testId: string | null;
333
332
  stableId: string | null;
333
+ accessibleName: string;
334
334
  contextText: string[] | null;
335
335
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
336
336
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -346,9 +346,9 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
346
346
  }, "strict", z.ZodTypeAny, {
347
347
  items?: {
348
348
  role: string;
349
- accessibleName: string;
350
349
  testId: string | null;
351
350
  stableId: string | null;
351
+ accessibleName: string;
352
352
  contextText: string[] | null;
353
353
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
354
354
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -386,9 +386,9 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
386
386
  elements?: {
387
387
  items?: {
388
388
  role: string;
389
- accessibleName: string;
390
389
  testId: string | null;
391
390
  stableId: string | null;
391
+ accessibleName: string;
392
392
  contextText: string[] | null;
393
393
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
394
394
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -462,9 +462,9 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
462
462
  elements?: {
463
463
  items?: {
464
464
  role: string;
465
- accessibleName: string;
466
465
  testId: string | null;
467
466
  stableId: string | null;
467
+ accessibleName: string;
468
468
  contextText: string[] | null;
469
469
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
470
470
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -555,9 +555,9 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
555
555
  elements?: {
556
556
  items?: {
557
557
  role: string;
558
- accessibleName: string;
559
558
  testId: string | null;
560
559
  stableId: string | null;
560
+ accessibleName: string;
561
561
  contextText: string[] | null;
562
562
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
563
563
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -665,9 +665,9 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
665
665
  elements?: {
666
666
  items?: {
667
667
  role: string;
668
- accessibleName: string;
669
668
  testId: string | null;
670
669
  stableId: string | null;
670
+ accessibleName: string;
671
671
  contextText: string[] | null;
672
672
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
673
673
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -775,9 +775,9 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
775
775
  elements?: {
776
776
  items?: {
777
777
  role: string;
778
- accessibleName: string;
779
778
  testId: string | null;
780
779
  stableId: string | null;
780
+ accessibleName: string;
781
781
  contextText: string[] | null;
782
782
  mutability?: "unknown" | "mutable" | "immutable" | undefined;
783
783
  widgetType?: "unknown" | "custom" | "native" | undefined;
@@ -842,8 +842,14 @@ export declare function makeSpecFileReader(state: RunState): (relativePath: stri
842
842
  * `owner/repo` read off the origin remote, so the name is always matchable.
843
843
  */
844
844
  export declare function makeScreenRouteResolver(state: RunState): (relativePath: string, repository: string) => string[] | undefined;
845
- /** Every fact the verifiers check against, gathered once per registration. */
846
- export declare function buildVerifyContext(state: RunState): Promise<VerifyContext>;
845
+ /** The blockers this registration is judged on: the ones it sends AND the ones the
846
+ * stored plan carries. A carried close is re-probed on every registration — the
847
+ * service that was down when it was accepted may be up now. */
848
+ export declare function blockersToProbe(registration: PlanInput, previous?: Plan): PlanBlocker[];
849
+ /** Every fact the verifiers check against, gathered once per registration. The
850
+ * blockers are probed HERE rather than inside a verifier: an HTTP request and a
851
+ * `git` call are both asynchronous and every verifier is not. */
852
+ export declare function buildVerifyContext(state: RunState, blockers?: PlanBlocker[]): Promise<VerifyContext>;
847
853
  /**
848
854
  * What the agent reads back. Every objection carries its id so it can be answered,
849
855
  * and the text says the plan is stored either way — a objections list without that
@@ -12,7 +12,10 @@ import { collectChangedRuns } from "../../utils/changedRuns.js";
12
12
  import { LOCKFILE, NOT_SOURCE_DIR, } from "../../recommendation/verifiers/endpointGrounded.js";
13
13
  import { hasUITestTypeLabel, isHTTPStep, } from "../../recommendation/subjectStep.js";
14
14
  import { STEP_METHOD_DESCRIPTION, isUiStepVerb, normalizeStepMethod, stepMethodSchema, } from "../../types/StepMethod.js";
15
- import { registerPlan } from "../../recommendation/registerPlan.js";
15
+ import { registerPlan, } from "../../recommendation/registerPlan.js";
16
+ import { BLOCKER_DESCRIPTION } from "../../recommendation/answers.js";
17
+ import { probeBlockers, blockersIn, } from "../../recommendation/blockerProbes.js";
18
+ import { blockerSchema, verifyBlocker } from "../../recommendation/blockers.js";
16
19
  import { targetElementSchema } from "../submitReportTool.js";
17
20
  import { capturedBlueprintDigests } from "../../playwright/blueprintDigestStore.js";
18
21
  import { pullRequestText } from "../../recommendation/pullRequestText.js";
@@ -129,7 +132,7 @@ const plannedTestCitations = citationFields({
129
132
  changesSubject: "test proves",
130
133
  defectsSubject: "test proves",
131
134
  changesExtra: " This test proves one behaviour: list the id of every change that the behaviour covers. The server checks that each declared change has a planned test that cites it, or an answer. The server does not check that the test exercises the change. CHECKED on a `requirement_conflict` test: at least one cited change must have been read from the pull request or a requirements file, never only from the diff.",
132
- defectsExtra: " The server checks that each declared defect has a planned test that cites it and expects to fail, or a blocker naming what stopped the test. The server does not check that the test reaches the defect. CHECKED on a `requirement_conflict` test: it must cite at least one defect.",
135
+ defectsExtra: " The server checks that each declared defect has a planned test that cites it and expects to fail. The server does not check that the test reaches the defect. CHECKED on a `requirement_conflict` test: it must cite at least one defect.",
133
136
  });
134
137
  // The fields `declarations` accepts. Named on its own so one list of them can be
135
138
  // derived from it — a hand-written second copy had already fallen behind.
@@ -527,7 +530,7 @@ const registerTestPlanSchema = {
527
530
  });
528
531
  })
529
532
  .default([])
530
- .describe("The defects your code review found in the code that serves each change, whether or not the diff contains the line. Every defect closes one of three ways: a planned test that cites it in `defects` and expects to fail, a `maintains` entry that cites it because your edit to an existing test covers it, or a blocker naming what stopped the test. A pre-existing defect in a file this pull request touches belongs on this list. An empty list says the review found none; the check asks you to confirm that in one line. A requirement conflict is a defect: list it here, and the `requirement_conflict` test cites it."),
533
+ .describe("The defects your code review found in the code that serves each change, whether or not the diff contains the line. Every defect closes one of two ways: a planned test that cites it in `defects` and expects to fail, or a `maintains` entry that cites it because your edit to an existing test covers it. A service that is down does not stop the test: write it, and it runs once the service is back. A pre-existing defect in a file this pull request touches belongs on this list. An empty list says the review found none; the check asks you to confirm that in one line. A requirement conflict is a defect: list it here, and the `requirement_conflict` test cites it."),
531
534
  // REQUIRED, not `.default([])`. The MCP SDK wraps this shape in a plain
532
535
  // `z.object`, which STRIPS an undeclared top-level key, so with a default
533
536
  // `planned test:` for `planned tests:` stored an EMPTY plan and counted the
@@ -565,16 +568,16 @@ const registerTestPlanSchema = {
565
568
  repository: repositorySlugField("a maintenance entry", "which holds the test this run edits"),
566
569
  file: z
567
570
  .string()
568
- .describe("Repository-relative path of the existing test this run edits. It has to resolve in the repository named above: a change is credited as covered only when it does, and `existingCoverage` objects when it does not."),
571
+ .describe("Repository-relative path of the existing test this run edits, or runs unchanged as a VERIFY. It has to resolve in the repository named above: a change is credited as covered only when it does, and `existingCoverage` objects when it does not."),
569
572
  differsBy: z
570
573
  .string()
571
- .describe("What your edit changes in that test, in one sentence."),
574
+ .describe("What your edit changes in that test, in one sentence. For a test you run unchanged, what it asserts that the cited changes or defects reach."),
572
575
  changes: maintainedCitations.changes,
573
576
  defects: maintainedCitations.defects,
574
577
  })
575
578
  .strict())
576
579
  .optional()
577
- .describe("Existing tests this run edits instead of planning new ones. Coverage counts the changes these cite, and the defect check counts the defects they cite, so a change or a defect already covered by a spec you are updating needs no planned test — and a duplicate spec beside the one you edited is the mistake. Declare maintenance here even when the plan holds no planned tests at all: a maintenance-only plan is a complete plan."),
580
+ .describe("Existing tests this run edits, or runs unchanged as a VERIFY, instead of planning new ones. A test you run unchanged is checked at report time: the report objects when this server recorded no run of it after the edits. Coverage counts the changes these cite, and the defect check counts the defects they cite, so a change or a defect already covered by a spec you are updating needs no planned test — and a duplicate spec beside the one you edited is the mistake. Declare maintenance here even when the plan holds no planned tests at all: a maintenance-only plan is a complete plan."),
578
581
  answers: z
579
582
  .array(z
580
583
  .object({
@@ -584,10 +587,7 @@ const registerTestPlanSchema = {
584
587
  answer: z
585
588
  .string()
586
589
  .describe("Why the objection does not apply, or what you decided instead."),
587
- blocker: z
588
- .string()
589
- .optional()
590
- .describe("What stopped this run from writing the test: a service that is not running, a paired branch that no longer exists, the one credential the run holds. An objection about a change or a defect with no test, or about a value a page and API change refuses that no UI test names, closes only with this. A reason the change is not worth testing is not a blocker."),
590
+ blocker: blockerSchema.optional().describe(BLOCKER_DESCRIPTION),
591
591
  })
592
592
  .strict())
593
593
  .default([])
@@ -795,10 +795,25 @@ function recordedPullRequest() {
795
795
  logger.warning("No pull request text was recorded before the plan was registered; the checks that read it will see an empty pull request.");
796
796
  return { title: "", description: "" };
797
797
  }
798
- /** Every fact the verifiers check against, gathered once per registration. */
799
- export async function buildVerifyContext(state) {
798
+ /** The blockers this registration is judged on: the ones it sends AND the ones the
799
+ * stored plan carries. A carried close is re-probed on every registration — the
800
+ * service that was down when it was accepted may be up now. */
801
+ export function blockersToProbe(registration, previous) {
802
+ return [
803
+ ...blockersIn(registration.answers),
804
+ ...blockersIn(previous?.answeredObjections ?? []),
805
+ ...blockersIn(previous?.unverifiedCloses ?? []),
806
+ ];
807
+ }
808
+ /** Every fact the verifiers check against, gathered once per registration. The
809
+ * blockers are probed HERE rather than inside a verifier: an HTTP request and a
810
+ * `git` call are both asynchronous and every verifier is not. */
811
+ export async function buildVerifyContext(state, blockers = []) {
800
812
  const sections = repoSections(state);
801
- const listings = await Promise.all(sections.map((section) => changedFilesForVerification(section)));
813
+ const [listings, checks] = await Promise.all([
814
+ Promise.all(sections.map((section) => changedFilesForVerification(section))),
815
+ probeBlockers(blockers),
816
+ ]);
802
817
  // Per repository, so a planned test is checked against the one it names. Every
803
818
  // planned test names one; a single-repository run names that one repository.
804
819
  return {
@@ -821,6 +836,7 @@ export async function buildVerifyContext(state) {
821
836
  citedFileExists: makeCitedFileCheck(state),
822
837
  readSpecFile: makeSpecFileReader(state),
823
838
  routesRenderingFile: makeScreenRouteResolver(state),
839
+ blockerVerdict: (blocker) => verifyBlocker(blocker, checks),
824
840
  };
825
841
  }
826
842
  // ── Output ──────────────────────────────────────────────────────────────────
@@ -930,7 +946,7 @@ export function registerRegisterTestPlanTool(server) {
930
946
  const registration = buildRegistration(params, registrationNumber);
931
947
  // The previous registration's answers are already on the stored plan, which
932
948
  // the write below replaces.
933
- const result = registerPlan(registration, await buildVerifyContext(fullState), fullState.plan);
949
+ const result = registerPlan(registration, await buildVerifyContext(fullState, blockersToProbe(registration, fullState.plan)), fullState.plan);
934
950
  try {
935
951
  const { metadata: _metadata, relatedRepos: _sections, ...rootData } = fullState;
936
952
  await stateManager.appendData({
@@ -255,6 +255,13 @@ export declare const codeRefactoringSchema: z.ZodObject<{
255
255
  codeReuse?: boolean | undefined;
256
256
  modularizeCode?: boolean | undefined;
257
257
  }>;
258
+ /** The `testName` field for a recording-driven generation tool.
259
+ *
260
+ * Single-sourced because the description is prompt text: it is the only thing
261
+ * steering the agent's choice of title, it gets re-tuned against eval results,
262
+ * and two copies would let the UI and E2E tools drift apart silently. Each
263
+ * tool passes the title its own generator falls back to (SKYR-4468). */
264
+ export declare const testNameField: (fallbackTitle: string) => z.ZodOptional<z.ZodString>;
258
265
  /** Test types skyramp_enhance_assertions supports — single-sourced so the
259
266
  * tool's schema enum and generation-time baseline seeding cannot drift
260
267
  * (contract additionally requires provider mode at the seeding site). */