@skyramp/mcp 0.4.3-rc.2 → 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
@@ -0,0 +1,135 @@
1
+ import { z } from "zod";
2
+ /** What `blocker` is, in the words both tools' schemas give the agent. One string:
3
+ * the plan states the rule before the tests run and the report states it after, and
4
+ * two copies drifted the moment either was edited. */
5
+ export const BLOCKER_DESCRIPTION = "What stopped this run from writing the test, as one of two claims the server checks: `service-unreachable` with the URL the run needed, or `credential-missing` with the URL of the page the missing login guards. An objection about a change or a reported bug with no test, or about a value a page and API change accepts or refuses that no UI test names, closes only with one of these, and the server probes it — a claim it finds untrue leaves the objection open. A reason the test is not worth writing is not a blocker. A step that the browser tools cannot record is not a blocker either: record the steps they can, then edit the test file the generator writes and add the missing step yourself.";
6
+ /** The `kind` values, in the schema's own words. */
7
+ export const BLOCKER_KIND_DESCRIPTION = "Which of the two checkable things stopped the test. `service-unreachable`: the run could not reach a service it needed. `credential-missing`: the run holds no login for a page the test has to get past.";
8
+ export const BLOCKER_DETAIL_DESCRIPTION = "The fact behind the claim: for `service-unreachable` the URL, spelled as the run tried it — the server requests it; for `credential-missing` the URL of the page that asked for the login — the server reads it against the pages this run already loaded, and a page the run loaded with no login needs none.";
9
+ /** The blocker both tools take, as the schema that rejects anything else. An
10
+ * OBJECT, not a sentence: free text let an argument close an untested defect, and
11
+ * each kind here names one fact the server can probe. `.strict()` so an invented
12
+ * kind or an extra field fails the schema rather than reading as a claim nothing
13
+ * could check. */
14
+ export const blockerSchema = z
15
+ .object({
16
+ kind: z
17
+ .enum(["service-unreachable", "credential-missing"])
18
+ .describe(BLOCKER_KIND_DESCRIPTION),
19
+ detail: z
20
+ .string()
21
+ .refine((value) => value.trim().length > 0, {
22
+ message: "a blocker's detail must not be blank",
23
+ })
24
+ .describe(BLOCKER_DETAIL_DESCRIPTION),
25
+ })
26
+ .strict()
27
+ // A detail the server cannot make anything of used to be accepted and then called
28
+ // `unverifiable`, which closes — so `service-unreachable: outside this PR` was the
29
+ // free-text escape this schema exists to remove. Each kind states its detail in the
30
+ // shape its check takes, or the plan is refused here. `credential-missing` was a
31
+ // sentence, and it closed an XR02 page objection on a page that needs no login.
32
+ .superRefine((blocker, ctx) => {
33
+ const detail = String(blocker?.detail ?? "").trim();
34
+ if (!detail || probeUrl(detail))
35
+ return;
36
+ ctx.addIssue({
37
+ code: z.ZodIssueCode.custom,
38
+ path: ["detail"],
39
+ message: blocker.kind === "credential-missing"
40
+ ? "a `credential-missing` detail is the URL of the page that asked for the login, with an `http://` or `https://` scheme — the server reads it against the pages this run loaded."
41
+ : "a `service-unreachable` detail is the URL the run tried, with an `http://` or `https://` scheme — the server requests it. A cloud metadata address is not requested.",
42
+ });
43
+ });
44
+ /** Whether an answer carries a blocker at all: a kind the schema knows and a detail
45
+ * that says something. Read defensively — the plan comes off disk, so the declared
46
+ * type holds only on the validated path. */
47
+ export function hasBlocker(blocker) {
48
+ const kind = blocker?.kind;
49
+ const detail = blocker?.detail;
50
+ return ((kind === "service-unreachable" || kind === "credential-missing") &&
51
+ typeof detail === "string" &&
52
+ detail.trim().length > 0);
53
+ }
54
+ /** The blocker as one line, for a report field and an objection's evidence. */
55
+ export function blockerText(blocker) {
56
+ return `${blocker.kind}: ${String(blocker.detail ?? "").trim()}`;
57
+ }
58
+ /** The addresses a probe must never be pointed at: the cloud metadata endpoint, in
59
+ * the two spellings it wears. The service under test runs on the runner, so
60
+ * loopback, `host.docker.internal` and the private ranges stay reachable — they
61
+ * are the probe's whole purpose. Only the link-local range, which serves nothing
62
+ * a test needs and serves instance credentials on every major cloud, is closed. */
63
+ function isMetadataAddress(hostname) {
64
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
65
+ // 169.254.0.0/16, the same range mapped into IPv6 (the URL parser writes it as
66
+ // `::ffff:a9fe:…`), and fe80::/10 written as any of its four leading nibbles.
67
+ if (/^169\.254\./.test(host) ||
68
+ /^::ffff:a9fe:/.test(host) ||
69
+ /^fe[89ab][0-9a-f]:/.test(host))
70
+ return true;
71
+ return host === "metadata" || host === "metadata.google.internal";
72
+ }
73
+ /** An http(s) URL the probe can be pointed at, or UNDEFINED. A detail that is not
74
+ * one is not a wrong claim — it is a claim nothing can be made of. */
75
+ export function probeUrl(detail) {
76
+ try {
77
+ const url = new URL(detail.trim());
78
+ if (url.protocol !== "http:" && url.protocol !== "https:")
79
+ return undefined;
80
+ // `fetch` throws on a URL with a login in it, which would read as unreachable.
81
+ if (url.username || url.password)
82
+ return undefined;
83
+ return isMetadataAddress(url.hostname) ? undefined : url.toString();
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ /** What the server made of this blocker. Pure: every fact comes from `checks`. */
90
+ export function verifyBlocker(blocker, checks) {
91
+ const detail = String(blocker?.detail ?? "").trim();
92
+ switch (blocker?.kind) {
93
+ case "service-unreachable": {
94
+ const url = probeUrl(detail);
95
+ if (!url)
96
+ return "unverifiable";
97
+ const answered = checks?.serviceAnswers(url);
98
+ return answered === undefined
99
+ ? "unverifiable"
100
+ : answered
101
+ ? "refuted"
102
+ : "verified";
103
+ }
104
+ case "credential-missing": {
105
+ // The run records ONE opaque credentials string, so `true` says a login was
106
+ // supplied and nothing about which one — and that login may be what let a
107
+ // capture reach the page. Only `false` states a fact about the claim, and then
108
+ // a capture of the page says the page needed no login.
109
+ const url = probeUrl(detail);
110
+ if (!url || checks?.credentialsSupplied !== false)
111
+ return "unverifiable";
112
+ const loaded = checks?.pageLoaded(url);
113
+ return loaded === undefined
114
+ ? "unverifiable"
115
+ : loaded
116
+ ? "refuted"
117
+ : "verified";
118
+ }
119
+ default:
120
+ return "unverifiable";
121
+ }
122
+ }
123
+ /** What the server saw, in the words the refusal quotes back. Only ever called on a
124
+ * `refuted` verdict, so each branch states the opposite of the claim. */
125
+ export function refutedEvidence(blocker) {
126
+ const detail = String(blocker?.detail ?? "").trim();
127
+ switch (blocker?.kind) {
128
+ case "service-unreachable":
129
+ return `${detail} answered an HTTP request, so the service is running`;
130
+ case "credential-missing":
131
+ return `this run loaded ${detail} with no login and read its elements, so the page needs none`;
132
+ default:
133
+ return blockerText(blocker);
134
+ }
135
+ }
@@ -9,6 +9,8 @@ export declare function pullRequestText(): {
9
9
  description: string;
10
10
  } | undefined;
11
11
  export declare function clearPullRequestText(): void;
12
+ export declare function recordUiCredentials(value: unknown): void;
13
+ export declare function uiCredentialsSupplied(): boolean | undefined;
12
14
  /** The pull request as one searchable string. Whitespace runs fold to one space, so
13
15
  * a quote wrapped across lines still matches the sentence it was taken from. */
14
16
  export declare const searchable: (pr: {
@@ -21,6 +21,22 @@ export function pullRequestText() {
21
21
  }
22
22
  export function clearPullRequestText() {
23
23
  recorded = undefined;
24
+ credentialsSupplied = undefined;
25
+ }
26
+ /** Whether the run was given browser credentials, recorded from the same place and
27
+ * for the same reason as the text above: the prompt and the plan tool share a
28
+ * process, so the flag is already here. A blocker of kind `credential-missing`
29
+ * is checked against it.
30
+ *
31
+ * UNDEFINED when this process never rendered the prompt, which is not `false` —
32
+ * the server cannot tell a run with no credentials from a run it never saw, and
33
+ * the blocker is then unverifiable rather than true. */
34
+ let credentialsSupplied;
35
+ export function recordUiCredentials(value) {
36
+ credentialsSupplied = typeof value === "string" && value.trim().length > 0;
37
+ }
38
+ export function uiCredentialsSupplied() {
39
+ return credentialsSupplied;
24
40
  }
25
41
  /** The pull request as one searchable string. Whitespace runs fold to one space, so
26
42
  * a quote wrapped across lines still matches the sentence it was taken from. */
@@ -1,4 +1,4 @@
1
- import { Objection, ObjectionAnswer, PlanChange, PlanDefect, PlanInput, PlanMaintenance, PlannedTest, VerifyContext } from "./types.js";
1
+ import { Objection, ObjectionAnswer, PlanBlocker, PlanChange, PlanDefect, PlanInput, PlanMaintenance, PlannedTest, VerifyContext } from "./types.js";
2
2
  /** The plan as stored for the run. Every registration produces one; the newest
3
3
  * replaces the last. */
4
4
  export interface Plan {
@@ -14,11 +14,14 @@ export interface Plan {
14
14
  * `coverage` counts the changes they cite, so a reader of the plan has to be
15
15
  * able to see which coverage came from maintenance rather than a new test. */
16
16
  maintains?: PlanMaintenance[];
17
- /** Objections the agent answered, with the answers. */
17
+ /** Objections the agent answered, with the answers. A `blocker` here is one the
18
+ * server PROBED and could not refute — an unreachable service that really did not
19
+ * answer, a branch that really is gone — so the close is a checked one. */
18
20
  answeredObjections: Array<{
19
21
  objection: Objection;
20
22
  answer: string;
21
- blocker?: string;
23
+ blocker?: PlanBlocker;
24
+ blockerVerified?: boolean;
22
25
  }>;
23
26
  /** Closed by a blocker nothing checked. Its own bucket rather than a flag on
24
27
  * `answeredObjections`, so a run's count of them is a number the report can
@@ -29,7 +32,7 @@ export interface Plan {
29
32
  unverifiedCloses: Array<{
30
33
  objection: Objection;
31
34
  answer: string;
32
- blocker: string;
35
+ blocker: PlanBlocker;
33
36
  }>;
34
37
  /** Objections with no answer. Rendered in the report — this is the accountability. */
35
38
  openObjections: Objection[];
@@ -1,4 +1,5 @@
1
- import { answerFor, isNonAnswerable, refusedAnswerObjections, unknownAnswerObjections, } from "./answers.js";
1
+ import { hasBlocker } from "./blockers.js";
2
+ import { answerFor, closingAnswerFor, isNonAnswerable, refusedAnswerObjections, unknownAnswerObjections, } from "./answers.js";
2
3
  import { runPlanTimeVerifiers } from "./runVerifiers.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  function storedRegistrationNumber(registration) {
@@ -37,7 +38,7 @@ function carriedAnswerFor(objectionId, previous) {
37
38
  return {
38
39
  objectionId,
39
40
  answer,
40
- ...(entry.blocker ? { blocker: entry.blocker } : {}),
41
+ ...(hasBlocker(entry.blocker) ? { blocker: entry.blocker } : {}),
41
42
  };
42
43
  }
43
44
  return undefined;
@@ -65,6 +66,9 @@ export function registerPlan(registration, ctx, previous) {
65
66
  const changes = registration.changes;
66
67
  const defects = registration.defects;
67
68
  const registrationNumber = storedRegistrationNumber(registration);
69
+ // Awaited by the caller before this context was built: every probe is an HTTP
70
+ // request or a `git` call, and nothing from here down is asynchronous.
71
+ const judge = ctx?.blockerVerdict?.bind(ctx);
68
72
  const verified = runPlanTimeVerifiers(registration, ctx);
69
73
  const objections = [
70
74
  ...verified,
@@ -72,7 +76,15 @@ export function registerPlan(registration, ctx, previous) {
72
76
  scope: "on this plan",
73
77
  suggestion: "Answer only the ids in the Objections list this registration returns. An objection you have since fixed is gone and needs no answer, and an objection raised after the tests run cannot be answered before them.",
74
78
  }),
75
- ...refusedAnswerObjections(verified, acknowledged),
79
+ ...refusedAnswerObjections(verified, acknowledged, judge, (id) => {
80
+ // The previous registration's close is carried below when this answer does
81
+ // not close. If that carried close still stands, it is the close.
82
+ const objection = verified.find((o) => o.objectionId === id);
83
+ const carried = carriedAnswerFor(id, previous);
84
+ return (!!objection &&
85
+ !!carried &&
86
+ !!closingAnswerFor(objection, [carried], judge));
87
+ }),
76
88
  ];
77
89
  const carried = carriedAnswers(objections, acknowledged, previous);
78
90
  // The carried answers go last: `answerFor` takes the first non-blank entry, so
@@ -87,15 +99,29 @@ export function registerPlan(registration, ctx, previous) {
87
99
  // a planned test too, unless the answer names what stopped this run from
88
100
  // writing one. Either way an answer that does not close it leaves it open and
89
101
  // draws the refusal objection raised above.
90
- const answer = isNonAnswerable(objection.objectionId)
102
+ const closing = isNonAnswerable(objection.objectionId)
91
103
  ? undefined
92
- : answerFor(objection, answers);
93
- if (answer) {
94
- const blocker = answers.find((entry) => entry.objectionId === objection.objectionId &&
95
- String(entry.blocker ?? "").trim().length > 0)?.blocker;
96
- // A blocker closes the objection and nothing here can check it, so it is
97
- // counted apart from an answer the plan itself makes checkable.
98
- if (blocker)
104
+ : closingAnswerFor(objection, answers, judge);
105
+ if (closing) {
106
+ const answer = closing.answer;
107
+ // The blocker that arrived with THIS answer. Searching for it apart from the
108
+ // answer took the two from different entries, and filed a refuted blocker,
109
+ // or one no answer closed, as the close.
110
+ const blocker = hasBlocker(closing.blocker) && judge?.(closing.blocker) !== "refuted"
111
+ ? closing.blocker
112
+ : undefined;
113
+ // A blocker the server PROBED and could not refute is a checked close and sits
114
+ // with the answers. One nothing could look at is counted apart: a run whose
115
+ // objections all close that way must not read like one that answered.
116
+ if (blocker && judge?.(blocker) === "verified") {
117
+ answeredObjections.push({
118
+ objection,
119
+ answer,
120
+ blocker,
121
+ blockerVerified: true,
122
+ });
123
+ }
124
+ else if (blocker)
99
125
  unverifiedCloses.push({ objection, answer, blocker });
100
126
  else
101
127
  answeredObjections.push({ objection, answer });
@@ -164,13 +164,24 @@ export interface PlannedTest {
164
164
  * included — the name is what joins the test to the diff it is checked against. */
165
165
  repository: string;
166
166
  }
167
+ /** The kinds of thing that stop a run from writing a test AND that the server can
168
+ * check. Free text let an argument close an untested defect — run 34434835959
169
+ * closed one with "the PR description states no redirect or fallback" — so a
170
+ * blocker is now a claim of one of these, and the server probes it. */
171
+ export type PlanBlockerKind = "service-unreachable" | "credential-missing";
172
+ /** What stopped this run from writing the test, as a checkable claim. */
173
+ export interface PlanBlocker {
174
+ kind: PlanBlockerKind;
175
+ /** `service-unreachable`: the URL the run needed. `credential-missing`: the URL
176
+ * of the page that asked for the login. */
177
+ detail: string;
178
+ }
167
179
  export interface ObjectionAnswer {
168
180
  objectionId: string;
169
181
  answer: string;
170
- /** What stopped this run from writing the test: a service that is not running,
171
- * a paired branch that no longer exists, the one credential the run holds.
172
- * An objection about an untested change closes only with one of these. */
173
- blocker?: string;
182
+ /** What stopped this run from writing the test. An objection about an untested
183
+ * change, an untested defect or a reported bug closes only with one. */
184
+ blocker?: PlanBlocker;
174
185
  }
175
186
  /** An existing test this run maintains instead of planning a new one, and the
176
187
  * declared changes that maintenance covers. A removal already covered by a spec
@@ -255,6 +266,12 @@ export interface VerifyContext {
255
266
  * the verifiers filesystem-free. Named a repository it asks that one, and a name
256
267
  * this run does not know answers false. Omitted, it asks every one. */
257
268
  citedFileExists(relativePath: string, repository?: string): boolean;
269
+ /** What the server made of one answer's blocker. Injected for the same reason as
270
+ * `citedFileExists`, and more so: the probes are HTTP requests and `git` calls,
271
+ * and they are awaited before this context is built because the verifiers are
272
+ * synchronous. UNDEFINED where the caller ran no probes — every blocker then
273
+ * reads as unverifiable, which is where they all stood before the checks. */
274
+ blockerVerdict?(blocker: PlanBlocker): "verified" | "refuted" | "unverifiable";
258
275
  /** The text of a `spec:` file a plan change cites, from the first repository that
259
276
  * holds it. UNDEFINED for a path no repository holds, a path outside every
260
277
  * checkout, or a file too large to read. Injected for the same reason as
@@ -126,7 +126,7 @@ export declare const EXPECTED_OUTCOME_CONTRACT: {
126
126
  };
127
127
  readonly executed: {
128
128
  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.";
129
- 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.";
129
+ 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. If a service the test needs is down, answer this objection and set `blocker` to `service-unreachable` with the URL: the test stays committed and runs once the service is back.";
130
130
  };
131
131
  };
132
132
  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?";
@@ -158,7 +158,7 @@ export declare const COVERAGE_CONTRACT: {
158
158
  readonly objections: {
159
159
  readonly change: {
160
160
  readonly message: "This declared change has no test in the plan.";
161
- readonly suggestion: "Plan a test that exercises it. If an existing test already covers it and this run edits that test rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this change's id — maintenance is coverage, and a duplicate spec beside the one you edited is the mistake. 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.";
161
+ readonly suggestion: "Plan a test that exercises it. If an existing test already covers it and this run edits that test, or runs it unchanged as a VERIFY, rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this change's id — maintenance is coverage, and a duplicate spec beside the one you edited is the mistake. If this run cannot write one, answer it and set `blocker` to what stopped the run — a service that is not running, 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.";
162
162
  };
163
163
  readonly noChanges: {
164
164
  readonly message: "The plan declares no changes, so nothing says what the pull request must make different.";
@@ -190,11 +190,11 @@ export declare const COVERAGE_CONTRACT: {
190
190
  };
191
191
  readonly surfaceCase: {
192
192
  readonly message: "This change is on the page and the API, and no ui or e2e test that cites it chooses `{case}` on the page.";
193
- readonly suggestion: "Add a ui or e2e test, or a step in one, that chooses that value on the page and names it in the step, as in `Principal radio`, or answer in one line why the page cannot send it.";
193
+ readonly suggestion: "Add a ui or e2e test, or a step in one, that chooses that value on the page and names it in the step. If this run cannot write the test, answer it and set `blocker` to what stopped the run; an answer with no blocker leaves this open.";
194
194
  };
195
195
  readonly surfaceReject: {
196
196
  readonly message: "This change is on the page and the API, and no ui or e2e test that cites it tries `{case}` on the page or checks that the page no longer offers it.";
197
- readonly suggestion: "Add a ui or e2e test, or a step in one, that names that value in the step, as in `Intern radio absent`: try it and assert the page refuses it, or assert the page no longer offers it. If an existing UI spec already does this and this run edits it, declare it in `maintains` with its real path and name the value in `differsBy`. If this run cannot write the test, answer it and set `blocker` to what stopped the run; an answer with no blocker leaves this open.";
197
+ readonly suggestion: "Add a ui or e2e test, or a step in one, that names that value in the step: try it and assert the page refuses it, or assert the page no longer offers it. If this run cannot write the test, answer it and set `blocker` to what stopped the run; an answer with no blocker leaves this open.";
198
198
  };
199
199
  };
200
200
  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?";
@@ -302,11 +302,11 @@ export declare const DEFECTS_CONTRACT: {
302
302
  };
303
303
  readonly untested: {
304
304
  readonly message: "This declared defect has no test in the plan.";
305
- readonly suggestion: "Add a planned test that cites the defect in `defects` and sets `expected.outcome` to `fail`. A defect in a file this pull request touches gets the test whether or not this pull request introduced it. If an existing test already proves it and this run edits that test rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this defect's id — maintenance is proof, and a duplicate spec beside the one you edited is the mistake. If something stopped this run from writing the test, answer it and set `blocker` to what stopped it.";
305
+ readonly suggestion: "Add a planned test that cites the defect in `defects` and sets `expected.outcome` to `fail`. A defect in a file this pull request touches gets the test whether or not this pull request introduced it. If an existing test already proves it and this run edits that test, or runs it unchanged as a VERIFY, rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this defect's id — maintenance is proof, and a duplicate spec beside the one you edited is the mistake. Only a test closes this objection. If a service the test needs is down, write the test anyway: it is committed and runs once the service is back.";
306
306
  };
307
307
  readonly expectedPass: {
308
308
  readonly message: "Every test that cites this defect expects to pass, and a test that passes today proves nothing about a defect.";
309
- readonly suggestion: "Assert what the fixed code returns, set `expected.outcome` to `fail` with `why`, or answer in one line why a passing test is the proof.";
309
+ readonly suggestion: "Assert what the fixed code returns and set `expected.outcome` to `fail` with `why`. Only a test that expects to fail closes this objection.";
310
310
  };
311
311
  readonly unknownId: {
312
312
  readonly message: "A planned test cites a defect id that this plan does not declare.";
@@ -114,7 +114,7 @@ export const EXPECTED_OUTCOME_CONTRACT = {
114
114
  // 33988304641, P11), which the run that wrote `true` never drew.
115
115
  executed: {
116
116
  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.",
117
- 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.",
117
+ 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. If a service the test needs is down, answer this objection and set `blocker` to `service-unreachable` with the URL: the test stays committed and runs once the service is back.",
118
118
  },
119
119
  },
120
120
  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?",
@@ -149,7 +149,7 @@ export const COVERAGE_CONTRACT = {
149
149
  objections: {
150
150
  change: {
151
151
  message: "This declared change has no test in the plan.",
152
- suggestion: "Plan a test that exercises it. If an existing test already covers it and this run edits that test rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this change's id — maintenance is coverage, and a duplicate spec beside the one you edited is the mistake. 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.",
152
+ suggestion: "Plan a test that exercises it. If an existing test already covers it and this run edits that test, or runs it unchanged as a VERIFY, rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this change's id — maintenance is coverage, and a duplicate spec beside the one you edited is the mistake. If this run cannot write one, answer it and set `blocker` to what stopped the run — a service that is not running, 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.",
153
153
  },
154
154
  noChanges: {
155
155
  message: "The plan declares no changes, so nothing says what the pull request must make different.",
@@ -203,17 +203,18 @@ export const COVERAGE_CONTRACT = {
203
203
  suggestion: "Add a test on that surface that cites this change, or answer in one line why that surface needs no test.",
204
204
  },
205
205
  // `{case}` is the rendered case, as in `level="Principal" (accept)`. SKYR-4529: a form offered Lead and Principal, and
206
- // only an API test sent Principal.
206
+ // only an API test sent Principal. SKYR-4554: a later XR02 run closed this with "a third red for one defect adds
207
+ // nothing", and no UI test saved Principal.
207
208
  surfaceCase: {
208
209
  message: "This change is on the page and the API, and no ui or e2e test that cites it chooses `{case}` on the page.",
209
- suggestion: "Add a ui or e2e test, or a step in one, that chooses that value on the page and names it in the step, as in `Principal radio`, or answer in one line why the page cannot send it.",
210
+ suggestion: "Add a ui or e2e test, or a step in one, that chooses that value on the page and names it in the step. If this run cannot write the test, answer it and set `blocker` to what stopped the run; an answer with no blocker leaves this open.",
210
211
  },
211
212
  // `{case}` is the rendered case, as in `level="Principal" (accept)`. The XR02 run removed Intern from a form; the page
212
213
  // objection closed by a sentence, and no generated test checked the option was
213
214
  // gone.
214
215
  surfaceReject: {
215
216
  message: "This change is on the page and the API, and no ui or e2e test that cites it tries `{case}` on the page or checks that the page no longer offers it.",
216
- suggestion: "Add a ui or e2e test, or a step in one, that names that value in the step, as in `Intern radio absent`: try it and assert the page refuses it, or assert the page no longer offers it. If an existing UI spec already does this and this run edits it, declare it in `maintains` with its real path and name the value in `differsBy`. If this run cannot write the test, answer it and set `blocker` to what stopped the run; an answer with no blocker leaves this open.",
217
+ suggestion: "Add a ui or e2e test, or a step in one, that names that value in the step: try it and assert the page refuses it, or assert the page no longer offers it. If this run cannot write the test, answer it and set `blocker` to what stopped the run; an answer with no blocker leaves this open.",
217
218
  },
218
219
  },
219
220
  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?",
@@ -374,11 +375,11 @@ export const DEFECTS_CONTRACT = {
374
375
  },
375
376
  untested: {
376
377
  message: "This declared defect has no test in the plan.",
377
- suggestion: "Add a planned test that cites the defect in `defects` and sets `expected.outcome` to `fail`. A defect in a file this pull request touches gets the test whether or not this pull request introduced it. If an existing test already proves it and this run edits that test rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this defect's id — maintenance is proof, and a duplicate spec beside the one you edited is the mistake. If something stopped this run from writing the test, answer it and set `blocker` to what stopped it.",
378
+ suggestion: "Add a planned test that cites the defect in `defects` and sets `expected.outcome` to `fail`. A defect in a file this pull request touches gets the test whether or not this pull request introduced it. If an existing test already proves it and this run edits that test, or runs it unchanged as a VERIFY, rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this defect's id — maintenance is proof, and a duplicate spec beside the one you edited is the mistake. Only a test closes this objection. If a service the test needs is down, write the test anyway: it is committed and runs once the service is back.",
378
379
  },
379
380
  expectedPass: {
380
381
  message: "Every test that cites this defect expects to pass, and a test that passes today proves nothing about a defect.",
381
- suggestion: "Assert what the fixed code returns, set `expected.outcome` to `fail` with `why`, or answer in one line why a passing test is the proof.",
382
+ suggestion: "Assert what the fixed code returns and set `expected.outcome` to `fail` with `why`. Only a test that expects to fail closes this objection.",
382
383
  },
383
384
  unknownId: {
384
385
  message: "A planned test cites a defect id that this plan does not declare.",
@@ -3,8 +3,8 @@ import { PlanMaintenance, VerifyContext } from "../types.js";
3
3
  /** Whether a `maintains` entry names a file that stays inside the repository and is
4
4
  * present in the repository the entry names.
5
5
  *
6
- * An entry credits its ids only when it is. `coverage:change:` and
7
- * `defects:untested:` close only with a blocker, so crediting a file nobody can
6
+ * An entry credits its ids only when it is. `coverage:change:` closes only with a
7
+ * blocker and `defects:untested:` only with a test, so crediting a file nobody can
8
8
  * open would route around both. */
9
9
  export declare function maintainedFileIsInItsRepository(entry: PlanMaintenance, ctx: VerifyContext): boolean;
10
10
  /** A cited path reduced to the spelling `git diff --name-status` produces: `./`,
@@ -3,8 +3,8 @@ import { escapesRepo } from "./endpointGrounded.js";
3
3
  /** Whether a `maintains` entry names a file that stays inside the repository and is
4
4
  * present in the repository the entry names.
5
5
  *
6
- * An entry credits its ids only when it is. `coverage:change:` and
7
- * `defects:untested:` close only with a blocker, so crediting a file nobody can
6
+ * An entry credits its ids only when it is. `coverage:change:` closes only with a
7
+ * blocker and `defects:untested:` only with a test, so crediting a file nobody can
8
8
  * open would route around both. */
9
9
  export function maintainedFileIsInItsRepository(entry, ctx) {
10
10
  const path = entry.file.trim();
@@ -744,27 +744,24 @@ const REFUSING_VERBS = new Set([
744
744
  "assert",
745
745
  ]);
746
746
  /** The text that can name a value on the page: the steps of the UI tests whose verb
747
- * is in `verbs`, and the edit note of each maintained spec, which is prose with no
748
- * verb to read. */
749
- function pageTexts(uiTests, maintained, verbs) {
750
- return [
751
- ...uiTests.flatMap((plannedTest) => plannedTest.scenario.steps
752
- .filter((step) => verbs.has(normalizeStepMethod(step.method) ?? ""))
753
- .flatMap((step) => [step.path, step.description ?? ""])),
754
- ...maintained.map((entry) => entry.differsBy),
755
- ];
747
+ * is in `verbs`. A maintained spec's edit note is prose with no verb to read: an
748
+ * XR02 note named Principal in "4 radios (incl. Principal) render" and Intern in
749
+ * "switches from the removed Intern radio", and neither chose or refused it. */
750
+ function pageTexts(uiTests, verbs) {
751
+ return uiTests.flatMap((plannedTest) => plannedTest.scenario.steps
752
+ .filter((step) => verbs.has(normalizeStepMethod(step.method) ?? ""))
753
+ .flatMap((step) => [step.path, step.description ?? ""]));
756
754
  }
757
755
  /** A change on the page AND the API needs each accepted value chosen on the page,
758
756
  * and each refused value tried or shown absent there, not only sent by an API
759
757
  * test. `surfaceObjections` passes the page once any UI test cites the change,
760
758
  * whatever value it picks. A UI step is free text (`Senior radio`), so the check
761
759
  * reads whether a page step of a citing ui or e2e test that chooses the value (or,
762
- * for a refused one, asserts it), or the `differsBy` of a citing maintained spec,
763
- * names the value.
760
+ * for a refused one, asserts it) names the value.
764
761
  *
765
- * A refused value takes a test, a resolving maintained spec, or a blocker, never a
766
- * sentence: in the XR02 run the page objection for a removed option closed by
767
- * prose, and only an existing spec checked that the option was gone. */
762
+ * Either value takes a UI test step or a blocker, never a
763
+ * sentence: in XR02 runs the page objection for a removed option and for an added
764
+ * one each closed by prose, and no generated test checked that value on the page. */
768
765
  function surfaceCaseObjections(registration, ctx) {
769
766
  const objections = [];
770
767
  const seen = new Set();
@@ -777,15 +774,15 @@ function surfaceCaseObjections(registration, ctx) {
777
774
  const uiTests = registration.plannedTests.filter((plannedTest) => citesChange(plannedTest, id) &&
778
775
  pageTypes.has(plannedTest.scenario.testType.toLowerCase()));
779
776
  // A maintained spec counts only when its file resolves, the bar
780
- // `citedBehaviors` sets: the refused half closes by no sentence, so a note on a
781
- // file nobody can open must not close it either.
777
+ // `citedBehaviors` sets. It decides whether the values are checked, never
778
+ // whether one is met.
782
779
  const maintained = (registration.maintains ?? []).filter((entry) => entry.changes.some((cited) => cited.trim() === id) &&
783
780
  maintainedFileIsInItsRepository(entry, ctx));
784
781
  const citedAtAll = maintained.length > 0 ||
785
782
  registration.plannedTests.some((plannedTest) => citesChange(plannedTest, id));
786
783
  const texts = {
787
- surfaceCase: pageTexts(uiTests, maintained, CHOOSING_VERBS),
788
- surfaceReject: pageTexts(uiTests, maintained, REFUSING_VERBS),
784
+ surfaceCase: pageTexts(uiTests, CHOOSING_VERBS),
785
+ surfaceReject: pageTexts(uiTests, REFUSING_VERBS),
789
786
  };
790
787
  for (const testCase of change.testCases ?? []) {
791
788
  const kind = pageObjectionKind(testCase);
@@ -817,19 +814,22 @@ function surfaceCaseObjections(registration, ctx) {
817
814
  if (seen.has(key))
818
815
  continue;
819
816
  seen.add(key);
820
- const citers = [
821
- ...uiTests.map((plannedTest) => plannedTest.plannedTestId),
822
- ...maintained.map((entry) => entry.file),
823
- ];
817
+ const citers = uiTests.map((plannedTest) => plannedTest.plannedTestId);
818
+ // A maintained spec has no steps to name the value, so say its note is not
819
+ // read rather than fault it for a step it cannot carry.
820
+ const notes = maintained.length > 0
821
+ ? ` The edit note of ${maintained.map((entry) => entry.file).join(", ")} is not read.`
822
+ : "";
824
823
  objections.push({
825
824
  objectionId,
826
825
  verifier: "coverage",
827
826
  message: fillPlaceholders(COVERAGE_CONTRACT.objections[kind].message, {
828
827
  case: rendered,
829
828
  }),
830
- evidence: citers.length === 0
831
- ? `No ui or e2e test or maintained spec cites ${id}.`
832
- : `${citers.join(", ")} cite ${id}; no step or maintained edit of theirs names ${word}.`,
829
+ evidence: (citers.length === 0
830
+ ? `No ui or e2e test cites ${id}.`
831
+ : `${citers.join(", ")} cite ${id}; no page step of theirs names ${word}.`) +
832
+ notes,
833
833
  suggestion: COVERAGE_CONTRACT.objections[kind].suggestion,
834
834
  });
835
835
  }
@@ -81,9 +81,9 @@ export function checkMaintenanceDelivered(plan, maintained) {
81
81
  objections.push({
82
82
  objectionId,
83
83
  verifier: "deliveredMatchesPlan",
84
- message: "The plan says this existing test covers a change, and no maintenance row says it was edited.",
85
- evidence: `plan maintains ${file}; the maintenance rows that changed a file name ${editedPaths.length > 0 ? editedPaths.join(", ") : "no file"}`,
86
- suggestion: "The maintenance rows come from the triage, not from this call, so this is not a field to fill in here: make the edit on the file you claimed and let the maintenance step record it, or drop the entry from `maintains` and register the plan again — coverage counted the change because the plan said you were editing that test. If the test already covered the change and needed no edit, or the edit landed somewhere the rows do not name, say which as the answer.",
84
+ message: "The plan says this existing test covers a change, and no maintenance row says it was edited or run unchanged after the edits.",
85
+ evidence: `plan maintains ${file}; the maintenance rows that edited or ran a file name ${editedPaths.length > 0 ? editedPaths.join(", ") : "no file"}`,
86
+ suggestion: 'The maintenance rows come from the triage, not from this call, so this is not a field to fill in here: make the edit on the file you claimed and let the maintenance step record it, run a file you marked VERIFY with `skyramp_execute_test` and `phase: "after"`, or drop the entry from `maintains` and register the plan again — coverage counted the change because the plan said you were editing that test. If the test already covered the change and needed no edit, or the edit landed somewhere the rows do not name, say which as the answer.',
87
87
  });
88
88
  }
89
89
  return objections;
@@ -46,11 +46,7 @@ export interface TraceabilityLane {
46
46
  * accepted that entry — the same claim a planned test makes. See
47
47
  * {@link maintainedDefects} for how that acceptance is read.
48
48
  * `deliveredMatchesPlan:maintains:` checks separately that the edit landed.
49
- *
50
- * A declared `defectId` also counts when the agent answered `defects:untested:<id>`
51
- * at plan time (or closed it with a blocker). The plan-time verifier asked why no
52
- * test proves that defect and the agent said; asking again here is the same
53
- * question twice, and the answer is already published with the plan. */
49
+ */
54
50
  export declare function checkIssueTraceability(plan: Plan, delivered: DeliveredForTraceability[], issues: ReportedIssueForTraceability[], lane?: TraceabilityLane): Objection[];
55
51
  /** Report-time. Every defect the plan declared is an `issuesFound` entry that
56
52
  * names it by `defectId`. The review found it; the report must show it. */