@skyramp/mcp 0.4.0 → 0.4.1-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/build/prompts/enhance-assertions/contractProviderAssertionsPrompt.js +2 -1
  2. package/build/prompts/enhance-assertions/integrationAssertionsPrompt.js +2 -1
  3. package/build/prompts/enhance-assertions/sharedAssertionRules.d.ts +1 -1
  4. package/build/prompts/enhance-assertions/sharedAssertionRules.js +57 -22
  5. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +17 -9
  6. package/build/prompts/test-recommendation/diffExecutionPlan.js +0 -2
  7. package/build/prompts/test-recommendation/test-recommendation-prompt.js +11 -6
  8. package/build/prompts/testbot/testbot-prompts.js +19 -12
  9. package/build/recommendation/answers.d.ts +11 -7
  10. package/build/recommendation/answers.js +14 -10
  11. package/build/recommendation/pullRequestText.d.ts +18 -0
  12. package/build/recommendation/pullRequestText.js +31 -0
  13. package/build/recommendation/registerPlan.d.ts +9 -1
  14. package/build/recommendation/registerPlan.js +8 -1
  15. package/build/recommendation/runVerifiers.js +6 -0
  16. package/build/recommendation/types.d.ts +58 -0
  17. package/build/recommendation/verifierContracts.d.ts +108 -13
  18. package/build/recommendation/verifierContracts.js +147 -29
  19. package/build/recommendation/verifiers/coverage.d.ts +10 -0
  20. package/build/recommendation/verifiers/coverage.js +144 -22
  21. package/build/recommendation/verifiers/defects.d.ts +9 -0
  22. package/build/recommendation/verifiers/defects.js +117 -0
  23. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +22 -0
  24. package/build/recommendation/verifiers/deliveredMatchesPlan.js +43 -0
  25. package/build/recommendation/verifiers/existingCoverage.js +53 -0
  26. package/build/recommendation/verifiers/expectedValueSourced.d.ts +14 -0
  27. package/build/recommendation/verifiers/expectedValueSourced.js +246 -0
  28. package/build/recommendation/verifiers/issueTraceability.d.ts +52 -0
  29. package/build/recommendation/verifiers/issueTraceability.js +197 -0
  30. package/build/recommendation/verifiers/requirementSourced.d.ts +2 -0
  31. package/build/recommendation/verifiers/requirementSourced.js +168 -0
  32. package/build/services/TestGenerationService.js +3 -1
  33. package/build/tools/code-refactor/codeReuseTool.js +1 -1
  34. package/build/tools/code-refactor/reuse-outcome.d.ts +1 -1
  35. package/build/tools/code-refactor/reuse-state.d.ts +85 -7
  36. package/build/tools/code-refactor/reuse-state.js +239 -34
  37. package/build/tools/code-refactor/utils-verify-gates.d.ts +5 -0
  38. package/build/tools/code-refactor/utils-verify-gates.js +103 -11
  39. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -1
  40. package/build/tools/submitReportTool.js +276 -39
  41. package/build/tools/test-management/actionsTool.js +5 -0
  42. package/build/tools/test-management/analyzeChangesTool.d.ts +53 -0
  43. package/build/tools/test-management/analyzeChangesTool.js +55 -2
  44. package/build/tools/test-management/registerTestPlanTool.d.ts +32 -17
  45. package/build/tools/test-management/registerTestPlanTool.js +105 -16
  46. package/build/types/ReuseOutcome.d.ts +73 -7
  47. package/build/types/TestAnalysis.d.ts +6 -0
  48. package/build/types/TestbotReport.d.ts +22 -4
  49. package/build/utils/AnalysisStateManager.d.ts +7 -1
  50. package/build/utils/AnalysisStateManager.js +5 -1
  51. package/build/utils/assertion-verify/api-shared-lints.js +118 -11
  52. package/build/utils/assertion-verify/format.js +2 -2
  53. package/build/utils/assertion-verify/helper-imports.d.ts +7 -0
  54. package/build/utils/assertion-verify/helper-imports.js +119 -27
  55. package/build/utils/assertion-verify/lint-types.d.ts +31 -2
  56. package/build/utils/assertion-verify/lint-types.js +66 -0
  57. package/build/utils/assertion-verify/metrics.d.ts +13 -0
  58. package/build/utils/assertion-verify/metrics.js +16 -0
  59. package/build/utils/assertion-verify/verify.d.ts +11 -6
  60. package/build/utils/assertion-verify/verify.js +56 -15
  61. package/build/utils/canonicalJson.d.ts +11 -0
  62. package/build/utils/canonicalJson.js +17 -0
  63. package/build/utils/utils-verify/action-key.d.ts +27 -0
  64. package/build/utils/utils-verify/action-key.js +292 -0
  65. package/build/utils/utils-verify/allow.d.ts +8 -1
  66. package/build/utils/utils-verify/allow.js +14 -1
  67. package/build/utils/utils-verify/call-sites.d.ts +76 -8
  68. package/build/utils/utils-verify/call-sites.js +256 -70
  69. package/build/utils/utils-verify/language-spec.d.ts +3 -2
  70. package/build/utils/utils-verify/parse.d.ts +22 -3
  71. package/build/utils/utils-verify/parse.js +123 -52
  72. package/build/utils/utils-verify/verify.d.ts +33 -3
  73. package/build/utils/utils-verify/verify.js +126 -12
  74. package/build/utils/workspaceAuth.d.ts +59 -19
  75. package/build/utils/workspaceAuth.js +228 -31
  76. package/package.json +1 -1
  77. package/plugin/prompts/generate-tests/execution-plan.md +3 -3
  78. package/plugin/prompts/generate-tests/generation.md +1 -0
  79. package/plugin/prompts/plan-tests.md +41 -15
  80. package/plugin/prompts/testbot-task1.md +3 -9
  81. package/build/prompts/testbot/planDeclarations.d.ts +0 -6
  82. package/build/prompts/testbot/planDeclarations.js +0 -9
  83. package/plugin/prompts/declaring-a-plan.md +0 -20
@@ -13,14 +13,15 @@ import { StateManager, runArtifactDir, getTestsRepoDir, resolveRunStatePath, } f
13
13
  import { toolError, testFileMatches } from "../utils/utils.js";
14
14
  import { isTestbotEnabled, } from "../utils/featureFlags.js";
15
15
  import { answerFor, unknownAnswerObjections } from "../recommendation/answers.js";
16
- import { checkDeliveredMatchesPlan } from "../recommendation/verifiers/deliveredMatchesPlan.js";
16
+ import { checkDeliveredMatchesPlan, checkMaintenanceDelivered } from "../recommendation/verifiers/deliveredMatchesPlan.js";
17
17
  import { checkReportedCategoryMatchesPlan, checkRequirementConflictReported, } from "../recommendation/verifiers/reportedCategory.js";
18
+ import { checkDefectsReported, checkIssueTraceability } from "../recommendation/verifiers/issueTraceability.js";
18
19
  import { checkExpectedOutcomeAfterExecution, } from "../recommendation/verifiers/expectedOutcome.js";
19
20
  import { findInvalidSourceCitations, findUnchangedFileClaims, listChangedFiles, listChangedFilesAcross, listChangedFilesAbs, } from "../utils/reportVerification.js";
20
21
  import { isPlanOnlyMode } from "../utils/planOnlyMode.js";
21
22
  import { getReportLanguage, isEnforcedReportLanguage, findLanguageViolations, findLanguageNearMisses, reportLanguageDisplayName, } from "../utils/reportLanguage.js";
22
23
  import { canonicalTestPath, findAssertionRecordByFileName, rederiveAssertionOutcome, } from "./code-refactor/assertion-state.js";
23
- import { rederiveReuseOutcome, reuseChainSkipped, samePath, } from "./code-refactor/reuse-state.js";
24
+ import { rederiveReuse, REUSE_SUBMIT_MAX_REFUSALS, reuseChainSkipped, samePath, } from "./code-refactor/reuse-state.js";
24
25
  import { retrofitGate, } from "./code-refactor/retrofit-state.js";
25
26
  // Mirrors the tools wired to planGuard: UI and E2E are not gated at generation
26
27
  // time, so gating them here would be a report-time-only surprise.
@@ -50,6 +51,22 @@ const MAINTENANCE_CHANGE_ACTIONS = new Set([
50
51
  DriftAction.Regenerate,
51
52
  DriftAction.Delete,
52
53
  ]);
54
+ // Narrower than the set above, and for a different question. That one asks whether a
55
+ // row touched a file; this asks whether the row left a test standing that a `maintains`
56
+ // claim can rest on. DELETE touched the file and removed the coverage with it, VERIFY
57
+ // and IGNORE never edited anything — none of the three corroborate a claim that an
58
+ // existing test covers a change.
59
+ const MAINTENANCE_COVERAGE_ACTIONS = new Set([
60
+ DriftAction.Update,
61
+ DriftAction.Regenerate,
62
+ ]);
63
+ /** The maintenance rows a `maintains` claim may be checked against: an action that leaves
64
+ * a test standing, and an edit this run actually made. `reportOnly` is the second half —
65
+ * an external test's REGENERATE keeps its real action and touches no file, so counting it
66
+ * would credit a claim nothing backs. */
67
+ function rowsLeavingCoverage(rows) {
68
+ return (rows ?? []).filter((row) => row?.action !== undefined && MAINTENANCE_COVERAGE_ACTIONS.has(row.action) && row?.reportOnly !== true);
69
+ }
53
70
  /** Objection ids for an untested declared change, `coverage:change:<id>`. The
54
71
  * change table reads the agent's answer back off them. */
55
72
  const CHANGE_OBJECTION_PREFIX = "coverage:change:";
@@ -322,7 +339,15 @@ const issueFoundSchema = z
322
339
  plannedTestId: z
323
340
  .string()
324
341
  .optional()
325
- .describe("The `plannedTestId` of the planned test this issue is about, when it is about one. Set it on the issue you record for a `requirement_conflict` test so the conflict is tied to the test that asserts it; otherwise leave it out."),
342
+ .describe("The `plannedTestId` of the DELIVERED test that proves this issue. Your plan declares that test with `expected.outcome: fail`, so it stays red until the issue is fixed; a `requirement_conflict` test that asserts the requirement is declared the same way. A planned test you did not write proves nothing, and a test the plan declares green proves nothing either. " +
343
+ "This field is CONDITIONAL, not required on every bug: set it when such a test exists; set `defectId` instead when a plan defect traces the issue; leave both out when no test proves the issue. " +
344
+ "CHECKED on a `bug` entry: one that neither names a delivered red test here nor traces through `defectId` draws an objection you answer in one line."),
345
+ defectId: z
346
+ .string()
347
+ .optional()
348
+ .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. " +
349
+ "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. " +
350
+ "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 you answered `defects:untested:<id>` at plan time. Leave it out for an issue the plan did not declare."),
326
351
  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'). " +
327
352
  "REQUIRED when category is 'bug'. " +
328
353
  "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. " +
@@ -484,7 +509,6 @@ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFie
484
509
  // `targetElements: null` is its normal state rather than a lost capture.
485
510
  }
486
511
  }));
487
- // TODO(multi-repo maintenance): no `repository` field yet — see readData() TODO below.
488
512
  const testMaintenanceSchema = z.object({
489
513
  testType: z.nativeEnum(TestType).describe("Type of test."),
490
514
  endpoint: z
@@ -514,6 +538,10 @@ const testMaintenanceSchema = z.object({
514
538
  "For failing runs: failure name and one-line root cause, e.g. " +
515
539
  "'FAILED test_foo — check_schema fails, order_id=1 has discount from prior PATCH test'. " +
516
540
  "Empty string for VERIFY/IGNORE/DELETE entries where no after-execution was run."),
541
+ // Server-populated from the verdict's own state section, falling back to the
542
+ // run's primary repo. Always set: a consumer that has to treat absence as
543
+ // "probably the primary" cannot tell a single-repo row from a mis-stamped one.
544
+ repository: z.string(),
517
545
  // Server-populated from stateFile execution records — never supplied by the LLM.
518
546
  beforeStatus: z.nativeEnum(TestExecutionStatus),
519
547
  afterStatus: z.nativeEnum(TestExecutionStatus),
@@ -592,7 +620,7 @@ function computeReportMetrics(params) {
592
620
  *
593
621
  * The counts are RE-DERIVED from the delivered spec rather than read back from
594
622
  * state: the execution fix-up can restore `<testFile>.raw.bak` over it afterwards
595
- * by plain `cp`, which this tool never sees. See rederiveReuseOutcome.
623
+ * by plain `cp`, which this tool never sees. See rederiveReuse.
596
624
  *
597
625
  * Tests the reuse tool never ran for and specs whose outcome cannot be re-derived
598
626
  * get nothing — which consumers already treat as "no reuse summary" — with one
@@ -619,7 +647,12 @@ async function attachAssertionOutcome(test, outcomes, checkouts) {
619
647
  return test;
620
648
  return { ...test, assertions: await rederiveAssertionOutcome(record) };
621
649
  }
622
- async function attachReuseOutcome(test, outcomes, handOffs, retrofits = []) {
650
+ async function attachReuseOutcome(test, outcomes, handOffs, retrofits,
651
+ // A blocking verdict measured on the delivered files is collected here rather
652
+ // than written into the row: the caller refuses the report on it. Required, not
653
+ // defaulted — a caller that forgot it would ship the report with no refusal and
654
+ // no compiler error. See the refusal below the row map.
655
+ blocking) {
623
656
  // POM records describe browser specs; a basename collision with an API test's
624
657
  // fileName must not attach them there. A utils-path record is attachable anywhere.
625
658
  const record = outcomes?.[path.basename(test.fileName)];
@@ -629,7 +662,10 @@ async function attachReuseOutcome(test, outcomes, handOffs, retrofits = []) {
629
662
  const found = record && typeMatches && (test.testType === TestType.UI || record.utils)
630
663
  ? record
631
664
  : undefined;
632
- const derived = found ? await rederiveReuseOutcome(found) : undefined;
665
+ const rederived = found ? await rederiveReuse(found) : undefined;
666
+ const derived = rederived?.outcome;
667
+ if (rederived?.blocking)
668
+ blocking.push(rederived.blocking);
633
669
  // Pre-existing generated tests this spec's reuse pass rewired (SKYR-4276 A4): the
634
670
  // report names each with its recorded execution, so a reviewer sees that the
635
671
  // module became a dependency of code they already owned — and that it still runs.
@@ -672,6 +708,13 @@ function attachVideoPath(row, videos) {
672
708
  videoPath: videos?.[path.basename(row.testFilePath)]?.videoPath,
673
709
  };
674
710
  }
711
+ /** Two-space indent on every line, so a multi-line verdict reads as one bullet's body. */
712
+ function indent(text) {
713
+ return text
714
+ .split("\n")
715
+ .map((line) => ` ${line}`)
716
+ .join("\n");
717
+ }
675
718
  function deduplicateById(items) {
676
719
  const seen = new Set();
677
720
  const result = [];
@@ -812,7 +855,10 @@ const PLANNABLE_TEST_TYPES = new Set([
812
855
  TestType.E2E,
813
856
  TestType.UI,
814
857
  ]);
815
- function runPostExecutionChecks(plan, delivered, results, issues) {
858
+ function runPostExecutionChecks(plan, delivered, results, issues,
859
+ /** The plan-only lane delivers nothing, so there a planned test is proof
860
+ * enough for a bug. Passed in, not read here, so the checks stay pure. */
861
+ planOnly, maintained = []) {
816
862
  const shipped = delivered
817
863
  // A smoke, fuzz or load entry is filtered out rather than reported as
818
864
  // unplanned: a plan cannot hold one, so the objection would name a mistake the
@@ -823,9 +869,16 @@ function runPostExecutionChecks(plan, delivered, results, issues) {
823
869
  .map((plannedTestId) => ({ plannedTestId }));
824
870
  return [
825
871
  ...runPostExecutionCheck("deliveredMatchesPlan", "deliveredMatchesPlan", () => checkDeliveredMatchesPlan(plan, shipped)),
872
+ // The maintenance mirror: a `maintains` entry credited coverage at plan time,
873
+ // so something has to check the file was edited.
874
+ ...runPostExecutionCheck("deliveredMatchesPlan", "deliveredMatchesPlan:maintains", () => checkMaintenanceDelivered(plan, maintained)),
826
875
  ...runPostExecutionCheck("expectedOutcome", "expectedOutcome:executed", () => checkExpectedOutcomeAfterExecution(plan, collectExecutionOutcomes(delivered, results), issues)),
827
876
  ...runPostExecutionCheck("reportedCategory", "reportedCategory", () => checkReportedCategoryMatchesPlan(plan, delivered)),
828
877
  ...runPostExecutionCheck("requirementConflictReported", "requirementConflictReported", () => checkRequirementConflictReported(plan, delivered, issues)),
878
+ // Both halves of the review's accountability, and neither refuses the report:
879
+ // a bug with no test behind it, and a plan defect the report never mentions.
880
+ ...runPostExecutionCheck("issueTraceability", "issueTraceability", () => checkIssueTraceability(plan, shipped, issues, { planOnly })),
881
+ ...runPostExecutionCheck("defectsReported", "defects:unreported", () => checkDefectsReported(plan, issues)),
829
882
  ];
830
883
  }
831
884
  export function registerSubmitReportTool(server) {
@@ -1044,8 +1097,8 @@ export function registerSubmitReportTool(server) {
1044
1097
  const stateManager = StateManager.fromStatePath(stateFile);
1045
1098
  let stateData;
1046
1099
  try {
1047
- // Only reads the primary repo — see the related-repo check below for why related
1048
- // repos' maintenanceVerdicts are never folded in here.
1100
+ // Primary section only; related sections are read where testMaintenance
1101
+ // is built, so each row keeps its own repo's executions.
1049
1102
  stateData = await stateManager.readData();
1050
1103
  }
1051
1104
  catch (err) {
@@ -1111,29 +1164,74 @@ export function registerSubmitReportTool(server) {
1111
1164
  return errorResult;
1112
1165
  }
1113
1166
  }
1114
- // TestbotReport.testMaintenance has no repository field, so a related repo's
1115
- // reconciled verdicts have nowhere to go in this report. Fail loud instead of
1116
- // silently omitting real maintenance work skyramp_actions already did and persisted.
1117
- // Multi-repo maintenance reporting is a separate, unscoped feature (would also need
1118
- // testbot prompt / drift-analysis / test-bot.git renderer changes) not done here.
1119
- const relatedReposWithVerdicts = [];
1120
- for (const repo of await stateManager.listRelatedRepos()) {
1121
- const relatedData = await stateManager.readRepoData(repo);
1122
- if ((relatedData?.maintenanceVerdicts?.length ?? 0) > 0)
1123
- relatedReposWithVerdicts.push(repo);
1167
+ // Every section that can hold verdicts: primary, then each related repo.
1168
+ // Read once the file can carry a large diffText and readRepoData would
1169
+ // re-parse it twice per repo.
1170
+ const fullMaintenanceState = await stateManager.readFullState();
1171
+ // The state file's root names the primary only when a caller declared one
1172
+ // (or wrote first). GITHUB_REPOSITORY is the trigger event's own repo, so
1173
+ // it names the primary even for a run that passed neither repo argument.
1174
+ const primaryRepository = fullMaintenanceState?.metadata?.repository?.trim() ||
1175
+ process.env.GITHUB_REPOSITORY?.trim() ||
1176
+ undefined;
1177
+ const maintenanceSections = [{ data: stateData }];
1178
+ for (const [repo, section] of Object.entries(fullMaintenanceState?.relatedRepos ?? {})) {
1179
+ // Only these two keys resolve back to the primary, already collected
1180
+ // above. Any other key — a case variant, or another name over the same
1181
+ // checkout — is a SEPARATE store, so skipping it would discard its
1182
+ // verdicts; duplicates collapse per row below instead.
1183
+ if (!repo.trim())
1184
+ continue;
1185
+ if (repo === primaryRepository)
1186
+ continue;
1187
+ const data = section?.data;
1188
+ if (data)
1189
+ maintenanceSections.push({ repository: repo, data });
1190
+ }
1191
+ // Stamp every row, primary included and single-repo runs included — as the
1192
+ // prompt already requires of the other report sections. A section without
1193
+ // its own name is the primary's, so it takes the run's primary repo.
1194
+ const repositoryOf = (section) => section.repository ?? primaryRepository ?? "";
1195
+ // Required field, so refuse rather than stamp a blank one: nothing named
1196
+ // the primary and the run is not on a GitHub runner either. Only sections
1197
+ // that actually produce rows need a name — a run with no verdicts at all
1198
+ // has nothing to attribute and must still be able to submit.
1199
+ if (!primaryRepository &&
1200
+ maintenanceSections.some((section) => !section.repository &&
1201
+ (section.data.maintenanceVerdicts?.length ?? 0) > 0)) {
1202
+ errorResult = toolError("Cannot name the repository for the maintenance rows: the stateFile records no primary repository " +
1203
+ "and GITHUB_REPOSITORY is unset. Pass `repository` (and `primaryRepository` in a multi-repo run) " +
1204
+ "to skyramp_analyze_changes, then re-run it.");
1205
+ return errorResult;
1124
1206
  }
1125
- if (relatedReposWithVerdicts.length > 0) {
1126
- errorResult = toolError(`Related repo(s) have reconciled maintenance verdicts that cannot be included in this report yet ` +
1127
- `(multi-repo maintenance reporting is not supported — testMaintenance has no repository field): ${relatedReposWithVerdicts.join(", ")}.`);
1207
+ // Reject rather than throw: a non-array here, or a missing testFilePath
1208
+ // below, escapes the handler and loses the analytics event in its `finally`.
1209
+ const malformedSections = maintenanceSections
1210
+ .filter(({ data }) => data.maintenanceVerdicts !== undefined &&
1211
+ !Array.isArray(data.maintenanceVerdicts))
1212
+ .map(({ repository }) => repository ?? "the primary repo");
1213
+ if (malformedSections.length > 0) {
1214
+ errorResult = toolError(`stateFile has a non-array maintenanceVerdicts for: ${malformedSections.join(", ")}. ` +
1215
+ `Re-run skyramp_actions for that repository to rewrite the section.`);
1128
1216
  return errorResult;
1129
1217
  }
1130
- // action/testType/endpoint/description come entirely from stateData.maintenanceVerdicts
1131
- // (written by skyramp_actions right after drift analysis) — the LLM never supplies
1132
- // testMaintenance directly, so there's nothing to re-transcribe here.
1218
+ const badVerdicts = [];
1219
+ // action/testType/endpoint/description come entirely from each section's
1220
+ // maintenanceVerdicts (written by skyramp_actions right after drift analysis) —
1221
+ // the LLM never supplies testMaintenance directly, so there's nothing to
1222
+ // re-transcribe here.
1133
1223
  // `pomFile` rides along per row and is stripped from the wire format below,
1134
1224
  // like testFilePath. It cannot be keyed by spec path: a spec emits one UPDATE
1135
1225
  // verdict PER page object, so a spec-keyed map keeps only the last.
1136
- const rawMaintenance = (stateData.maintenanceVerdicts ?? []).map((v) => ({
1226
+ const rawMaintenance = maintenanceSections.flatMap((section) => (section.data.maintenanceVerdicts ?? [])
1227
+ .filter((v) => {
1228
+ if (v && typeof v.testFilePath === "string" && v.testFilePath.trim())
1229
+ return true;
1230
+ badVerdicts.push(section.repository ?? "the primary repo");
1231
+ return false;
1232
+ })
1233
+ .map((v) => ({
1234
+ repository: repositoryOf(section),
1137
1235
  testFilePath: v.testFilePath,
1138
1236
  pomFile: v.pomFile,
1139
1237
  testType: v.testType,
@@ -1146,20 +1244,47 @@ export function registerSubmitReportTool(server) {
1146
1244
  : v.rationale,
1147
1245
  beforeDetails: "",
1148
1246
  afterDetails: "",
1149
- }));
1247
+ })));
1248
+ // GitHub slugs are case-insensitive, so two sections can name one repo and
1249
+ // repeat a file. Keep the first — the primary's, given the order above.
1250
+ // pomFile is part of the key: one spec emits one UPDATE verdict per page
1251
+ // object, and those rows are distinct, not duplicates.
1252
+ const seenRows = new Set();
1253
+ const dedupedMaintenance = rawMaintenance.filter((row) => {
1254
+ const key = `${(row.repository ?? "").trim().toLowerCase()}\u0000${row.testFilePath}\u0000${row.pomFile ?? ""}`;
1255
+ if (seenRows.has(key))
1256
+ return false;
1257
+ seenRows.add(key);
1258
+ return true;
1259
+ });
1260
+ if (badVerdicts.length > 0) {
1261
+ errorResult = toolError(`${badVerdicts.length} maintenance verdict(s) in the stateFile have no testFilePath ` +
1262
+ `(sections: ${[...new Set(badVerdicts)].join(", ")}). ` +
1263
+ `Re-run skyramp_actions for those repositories to rewrite the section.`);
1264
+ return errorResult;
1265
+ }
1266
+ // Keyed by the row's own repo, so executions come from its own section.
1267
+ const sectionByRepository = new Map(maintenanceSections.map((section) => [
1268
+ repositoryOf(section),
1269
+ section.data,
1270
+ ]));
1150
1271
  let testMaintenance;
1151
1272
  // Rows missing a required detail (a real execution happened but the LLM didn't
1152
1273
  // draft a summary for it) — collected here and rejected below, rather than
1153
1274
  // silently shipping a blank field.
1154
1275
  const missingDetails = [];
1155
- if (rawMaintenance.length > 0) {
1276
+ if (dedupedMaintenance.length > 0) {
1156
1277
  // beforeStatus/afterStatus are always stateFile-authoritative. beforeDetails/
1157
1278
  // afterDetails come from the LLM's drafted summary (testMaintenanceDetails) —
1158
1279
  // required whenever the stateFile shows a matching execution actually ran.
1159
- testMaintenance = rawMaintenance.map((m) => {
1160
- const recorded = stateData.existingTests?.find((t) => testFileMatches(t.testFile, m.testFilePath));
1280
+ testMaintenance = dedupedMaintenance.map((m) => {
1281
+ const recorded = sectionByRepository
1282
+ .get(m.repository)
1283
+ ?.existingTests?.find((t) => testFileMatches(t.testFile, m.testFilePath));
1161
1284
  const detail = params.testMaintenanceDetails?.find((d) => d.testFilePath === m.testFilePath);
1162
- const displayName = path.basename(m.testFilePath);
1285
+ // Two repos can hold the same basename; name the repo so the agent
1286
+ // knows which row to draft for.
1287
+ const displayName = `${path.basename(m.testFilePath)} (${m.repository})`;
1163
1288
  const defaultBeforeStatus = MAINTENANCE_CHANGE_ACTIONS.has(m.action)
1164
1289
  ? TestExecutionStatus.Unknown
1165
1290
  : TestExecutionStatus.Skipped;
@@ -1189,7 +1314,8 @@ export function registerSubmitReportTool(server) {
1189
1314
  });
1190
1315
  }
1191
1316
  if (missingDetails.length > 0) {
1192
- errorResult = toolError(`${missingDetails.length} maintenance row(s) have a recorded execution but no drafted summary: ${missingDetails.join(", ")}. ` +
1317
+ const uniqueMissing = [...new Set(missingDetails)];
1318
+ errorResult = toolError(`${uniqueMissing.length} maintenance row(s) have a recorded execution but no drafted summary: ${uniqueMissing.join(", ")}. ` +
1193
1319
  "Add a testMaintenanceDetails entry with the missing field(s), drafted from the execution output you already saw.");
1194
1320
  return errorResult;
1195
1321
  }
@@ -1227,6 +1353,11 @@ export function registerSubmitReportTool(server) {
1227
1353
  ...Object.values(fullState?.relatedRepos ?? {}).map((section) => section.repositoryPath),
1228
1354
  ])),
1229
1355
  ];
1356
+ // Primary verdicts only, by design: this checks UPDATEs, and
1357
+ // skyramp_actions downgrades every actionable related-repo verdict to
1358
+ // VERIFY (actionsTool.ts). If that changes, pass each section's
1359
+ // verdicts with its OWN checkout root — isBacked relativizes against
1360
+ // `repoRoot` and would otherwise exempt them all.
1230
1361
  const unbacked = findUnchangedFileClaims({
1231
1362
  repoRoot,
1232
1363
  changedFiles,
@@ -1315,10 +1446,70 @@ export function registerSubmitReportTool(server) {
1315
1446
  const assertionCheckouts = await stateManager
1316
1447
  .listRepoCheckouts()
1317
1448
  .catch(() => []);
1449
+ const reuseBlocking = [];
1318
1450
  const sanitizedNewTests = await Promise.all(dedupedNewTests.map(async ({ scenarioFile: _sf, traceFile: _tf, frontendTrace: _ft, ...rest }) => {
1319
- const row = await attachReuseOutcome(normalizeRepository(rest), stateData.reuseOutcomes, stateData.reuseHandOffs, retrofitViews);
1451
+ const row = await attachReuseOutcome(normalizeRepository(rest), stateData.reuseOutcomes, stateData.reuseHandOffs, retrofitViews, reuseBlocking);
1320
1452
  return attachAssertionOutcome(row, stateData.assertionOutcomes ?? {}, assertionCheckouts);
1321
1453
  }));
1454
+ // A blocking reuse verdict on the delivered files refuses the report. The
1455
+ // live verify pass is a one-time checkpoint on a file that keeps changing —
1456
+ // the execution fix loop and a post-verification rewrite both edit after it —
1457
+ // and the verdict re-derived here is the only one measured on what ships. It
1458
+ // used to become a row field, so the run committed the fault, detected it,
1459
+ // disclosed it, and delivered it. Reject rather than publish it, exactly like
1460
+ // the maintenanceVerdicts check above: this is a repair loop, not an abort —
1461
+ // the agent fixes the file (or documents the decline the check names) and
1462
+ // calls again. Advisories never reach this list, so a report-time advisory
1463
+ // stays reportable.
1464
+ //
1465
+ // BOUNDED, per spec: after REUSE_SUBMIT_MAX_REFUSALS refusals of one spec the
1466
+ // report is accepted and its row ships `verification: failed` with the
1467
+ // reasons. A loop with no exit at the last step of a run delivers nothing when
1468
+ // the agent cannot reach a pass, and a customer with no pull request is worse
1469
+ // off than one with a disclosed fault (see REUSE_SUBMIT_MAX_REFUSALS). The
1470
+ // count is persisted per spec path; a count that cannot be persisted cannot
1471
+ // bound anything, so a failed write accepts rather than refuses forever.
1472
+ // `reuseOutcomes` is keyed by basename, so two rows sharing a file name find
1473
+ // one record and push one verdict twice — deduplicated by path here, as
1474
+ // pendingReuseVerification skips that case on its side. Sorted by file so the
1475
+ // text is stable across calls.
1476
+ if (reuseBlocking.length > 0) {
1477
+ const verdicts = [
1478
+ ...new Map(reuseBlocking.map((v) => [v.file, v])).values(),
1479
+ ].sort((a, b) => a.file.localeCompare(b.file));
1480
+ const refusals = { ...(stateData.reuseRefusals ?? {}) };
1481
+ const owed = verdicts.filter((v) => (refusals[v.file] ?? 0) < REUSE_SUBMIT_MAX_REFUSALS);
1482
+ let persisted = owed.length > 0;
1483
+ if (owed.length > 0) {
1484
+ for (const v of owed)
1485
+ refusals[v.file] = (refusals[v.file] ?? 0) + 1;
1486
+ try {
1487
+ await stateManager.writeData({ ...stateData, reuseRefusals: refusals });
1488
+ }
1489
+ catch (err) {
1490
+ persisted = false;
1491
+ logger.warning("Could not persist the reuse refusal count — accepting the report rather than refusing without a bound", { error: String(err) });
1492
+ }
1493
+ }
1494
+ if (owed.length > 0 && persisted) {
1495
+ errorResult = toolError(`${owed.length} delivered test${owed.length === 1 ? "" : "s"} fail${owed.length === 1 ? "s" : ""} a blocking code-reuse check on the file as it stands now — the same check skyramp_reuse_code runs with verify: true, and a report cannot ship work that does not pass it:\n` +
1496
+ owed
1497
+ .map((v) => `\n- ${v.file} · ${v.failures.map((f) => f.kind).join(", ")} · refusal ${refusals[v.file]} of ${REUSE_SUBMIT_MAX_REFUSALS}\n${indent(v.detail)}\n Then call ${v.verifyCall} so the repaired files are re-checked.`)
1498
+ .join("\n") +
1499
+ // The bound is stated as a counter only. What happens when it is
1500
+ // reached is not — an agent told that two more calls ship the report
1501
+ // may wait instead of repair, and the sanctioned exits (repair, or the
1502
+ // documented decline) are already named.
1503
+ `\n\nRepair each file above (or document a deliberate decline with the exact marker the check names), re-verify it, and call skyramp_submit_report again.`);
1504
+ return errorResult;
1505
+ }
1506
+ logger.warning("Accepting a report whose delivered files fail a blocking code-reuse check — the refusal bound is reached, the rows record the failure", {
1507
+ files: verdicts.map((v) => ({
1508
+ file: v.file,
1509
+ kinds: v.failures.map((f) => f.kind),
1510
+ })),
1511
+ });
1512
+ }
1322
1513
  // The report enum has no `bug_caught` value, so which failure was on purpose
1323
1514
  // is derived from the plan rather than from agent prose. Read defensively:
1324
1515
  // the plan comes off disk.
@@ -1357,7 +1548,13 @@ export function registerSubmitReportTool(server) {
1357
1548
  let objections;
1358
1549
  let changeTable;
1359
1550
  {
1360
- const postExecution = runPostExecutionChecks(stateData.plan ?? { plannedTests: [], registrationNumber: 0, answers: [], openObjections: [], answeredObjections: [] }, dedupedNewTests, params.testResults, params.issuesFound ?? []);
1551
+ const postExecution = runPostExecutionChecks(stateData.plan ?? { plannedTests: [], registrationNumber: 0, answers: [], openObjections: [], answeredObjections: [] }, dedupedNewTests, params.testResults, params.issuesFound ?? [], isPlanOnlyMode(),
1552
+ // The triage's own verdicts, not this tool's input, so the join is against
1553
+ // what the run is recorded as having edited — and only the rows that left a
1554
+ // test standing and were actually applied, so a no-op assessment, a deletion
1555
+ // or a report-only recommendation cannot corroborate. The verdicts rather
1556
+ // than the published rows because `reportOnly` lives here.
1557
+ rowsLeavingCoverage(stateData.maintenanceVerdicts));
1361
1558
  // Read defensively, like the crash-contained checks above: the plan comes
1362
1559
  // off disk, so its declared array type holds only on the validated path.
1363
1560
  const stillOpen = Array.isArray(stateData.plan?.openObjections)
@@ -1501,6 +1698,46 @@ export function registerSubmitReportTool(server) {
1501
1698
  changesByCandidate.set(id, cited.map((entry) => String(entry ?? "").trim()));
1502
1699
  }
1503
1700
  }
1701
+ // A change an existing test covers raises no `coverage:change:` objection
1702
+ // and ships no planned test, so neither `testedBy` nor `answer` would say
1703
+ // anything about it. Without this the table reads as untested for a change
1704
+ // `coverage` counts as covered.
1705
+ //
1706
+ // Only what a maintenance row corroborates: publishing a claim on its own
1707
+ // would let the table assert coverage the verifier refused — `coverage`
1708
+ // declines to credit an entry whose file does not resolve, and this row
1709
+ // would still have named it. An uncorroborated claim draws
1710
+ // `deliveredMatchesPlan:maintains:` instead of a table entry.
1711
+ //
1712
+ // `testFileMatches` rather than a basename, so a spec under one directory
1713
+ // cannot lend its coverage to a same-named spec under another; and only the
1714
+ // rows that left a test standing, so DELETE and the no-op actions cannot
1715
+ // corroborate a claim that an existing test covers the change.
1716
+ const editedPaths = [];
1717
+ for (const row of rowsLeavingCoverage(stateData.maintenanceVerdicts)) {
1718
+ for (const full of [row?.testFilePath, row?.pomFile]) {
1719
+ const path = typeof full === "string" ? full.trim() : "";
1720
+ if (path)
1721
+ editedPaths.push(path);
1722
+ }
1723
+ }
1724
+ const maintainedByChange = new Map();
1725
+ for (const entry of Array.isArray(stateData.plan?.maintains) ? stateData.plan.maintains : []) {
1726
+ const file = String(entry?.file ?? "").trim();
1727
+ if (!file || !Array.isArray(entry?.changes))
1728
+ continue;
1729
+ if (!editedPaths.some((full) => testFileMatches(full, file)))
1730
+ continue;
1731
+ for (const cited of entry.changes) {
1732
+ const changeId = String(cited ?? "").trim();
1733
+ if (!changeId)
1734
+ continue;
1735
+ const files = maintainedByChange.get(changeId) ?? [];
1736
+ if (!files.includes(file))
1737
+ files.push(file);
1738
+ maintainedByChange.set(changeId, files);
1739
+ }
1740
+ }
1504
1741
  const answerByChange = new Map();
1505
1742
  for (const objection of objections) {
1506
1743
  if (!objection.objectionId.startsWith(CHANGE_OBJECTION_PREFIX))
@@ -1515,11 +1752,13 @@ export function registerSubmitReportTool(server) {
1515
1752
  .map((test) => (test.plannedTestId ?? "").trim())
1516
1753
  .filter((plannedTestId) => plannedTestId && (changesByCandidate.get(plannedTestId) ?? []).includes(id));
1517
1754
  const answer = answerByChange.get(id);
1755
+ const maintainedBy = maintainedByChange.get(id) ?? [];
1518
1756
  return {
1519
1757
  id,
1520
1758
  text: String(change?.text ?? ""),
1521
1759
  source: String(change?.source ?? ""),
1522
1760
  testedBy,
1761
+ ...(maintainedBy.length > 0 ? { maintainedBy } : {}),
1523
1762
  ...(answer ? { answer } : {}),
1524
1763
  };
1525
1764
  });
@@ -1565,8 +1804,6 @@ export function registerSubmitReportTool(server) {
1565
1804
  additionalRecommendations: dedupedRecommendations.map(normalizeRepository),
1566
1805
  // Report wire format keeps main's original `fileName` (basename) — testFilePath is
1567
1806
  // an internal-only field, needed for matching but never meant to reach the report.
1568
- // TODO(multi-repo maintenance): map(normalizeRepository) once testMaintenanceSchema
1569
- // has a repository field (see TODO above).
1570
1807
  testMaintenance: testMaintenance
1571
1808
  ? await Promise.all(testMaintenance.map(async ({ testFilePath, pomFile, ...row }) => {
1572
1809
  // `pomFile` is the file skyramp_actions told the agent to edit;
@@ -1582,7 +1819,7 @@ export function registerSubmitReportTool(server) {
1582
1819
  const assertions = record
1583
1820
  ? await rederiveAssertionOutcome(record)
1584
1821
  : undefined;
1585
- return {
1822
+ return normalizeRepository({
1586
1823
  ...row,
1587
1824
  fileName: path.basename(testFilePath),
1588
1825
  ...(pomFile &&
@@ -1590,7 +1827,7 @@ export function registerSubmitReportTool(server) {
1590
1827
  ? { editedFileName: path.basename(pomFile) }
1591
1828
  : {}),
1592
1829
  ...(assertions ? { assertions } : {}),
1593
- };
1830
+ });
1594
1831
  }))
1595
1832
  : undefined,
1596
1833
  // videoPath is filled from the run's execution records; testFilePath is the
@@ -410,6 +410,11 @@ export function registerActionsTool(server) {
410
410
  ...(r.rebaselineSnapshots?.length
411
411
  ? { rebaselineSnapshots: r.rebaselineSnapshots, rebaselineOnly: r.rebaselineOnly === true }
412
412
  : {}),
413
+ // Persisted so maintenance coverage can tell a REGENERATE that rewrote a
414
+ // file from one that only advised the developer to: an external test's
415
+ // REGENERATE/DELETE keeps its real action and touches nothing, so without
416
+ // this the report credits a `maintains` claim no edit backs.
417
+ ...(r.reportOnly ? { reportOnly: true } : {}),
413
418
  })),
414
419
  }, { repo: args.repository, repositoryPath, step: "actions" });
415
420
  }
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
+ import { AuthFinding } from "../../utils/workspaceAuth.js";
3
4
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4
5
  import { TraceFile } from "../../types/RepositoryAnalysis.js";
5
6
  import { ChangedFileName } from "../../utils/branchDiff.js";
@@ -58,6 +59,10 @@ export interface AnalyzeChangesData {
58
59
  openApiSpecPath?: string;
59
60
  /** Whether the spec loaded and carried usable path entries. */
60
61
  openApiSpecLoaded: boolean;
62
+ /** What the agent has to decide about this repository's auth, when the
63
+ * workspace and the code disagree. Each carries the sentence the agent acts
64
+ * on; the server changes no value on the strength of one. */
65
+ authFindings?: AuthFinding[];
61
66
  };
62
67
  /**
63
68
  * Identifying `data-*` attributes the diff removed from files that survive it
@@ -116,18 +121,66 @@ export declare const analyzeChangesOutputSchema: {
116
121
  authHeader: z.ZodOptional<z.ZodString>;
117
122
  openApiSpecPath: z.ZodOptional<z.ZodString>;
118
123
  openApiSpecLoaded: z.ZodBoolean;
124
+ authFindings: z.ZodOptional<z.ZodArray<z.ZodObject<{
125
+ kind: z.ZodLiteral<"workspace-disagreement">;
126
+ message: z.ZodString;
127
+ serviceName: z.ZodString;
128
+ field: z.ZodEnum<["authType", "authHeader", "authScheme"]>;
129
+ primary: z.ZodString;
130
+ analysed: z.ZodString;
131
+ primaryWorkspaceFile: z.ZodString;
132
+ analysedWorkspaceFile: z.ZodString;
133
+ }, "strip", z.ZodTypeAny, {
134
+ message: string;
135
+ kind: "workspace-disagreement";
136
+ serviceName: string;
137
+ field: "authHeader" | "authScheme" | "authType";
138
+ primaryWorkspaceFile: string;
139
+ analysedWorkspaceFile: string;
140
+ primary: string;
141
+ analysed: string;
142
+ }, {
143
+ message: string;
144
+ kind: "workspace-disagreement";
145
+ serviceName: string;
146
+ field: "authHeader" | "authScheme" | "authType";
147
+ primaryWorkspaceFile: string;
148
+ analysedWorkspaceFile: string;
149
+ primary: string;
150
+ analysed: string;
151
+ }>, "many">>;
119
152
  }, "strip", z.ZodTypeAny, {
120
153
  baseUrl: string;
121
154
  authMethod: string;
122
155
  openApiSpecLoaded: boolean;
123
156
  authHeader?: string | undefined;
124
157
  openApiSpecPath?: string | undefined;
158
+ authFindings?: {
159
+ message: string;
160
+ kind: "workspace-disagreement";
161
+ serviceName: string;
162
+ field: "authHeader" | "authScheme" | "authType";
163
+ primaryWorkspaceFile: string;
164
+ analysedWorkspaceFile: string;
165
+ primary: string;
166
+ analysed: string;
167
+ }[] | undefined;
125
168
  }, {
126
169
  baseUrl: string;
127
170
  authMethod: string;
128
171
  openApiSpecLoaded: boolean;
129
172
  authHeader?: string | undefined;
130
173
  openApiSpecPath?: string | undefined;
174
+ authFindings?: {
175
+ message: string;
176
+ kind: "workspace-disagreement";
177
+ serviceName: string;
178
+ field: "authHeader" | "authScheme" | "authType";
179
+ primaryWorkspaceFile: string;
180
+ analysedWorkspaceFile: string;
181
+ primary: string;
182
+ analysed: string;
183
+ }[] | undefined;
131
184
  }>>;
132
185
  uiContext: z.ZodOptional<z.ZodObject<{
133
186
  removedElements: z.ZodOptional<z.ZodArray<z.ZodObject<{