@skyramp/mcp 0.4.3-rc.1 → 0.4.3-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 (37) hide show
  1. package/build/recommendation/answers.d.ts +5 -3
  2. package/build/recommendation/answers.js +5 -2
  3. package/build/recommendation/verifierContracts.d.ts +8 -0
  4. package/build/recommendation/verifierContracts.js +13 -0
  5. package/build/recommendation/verifiers/coverage.js +143 -4
  6. package/build/tools/code-refactor/removed-modules.d.ts +33 -0
  7. package/build/tools/code-refactor/removed-modules.js +61 -0
  8. package/build/tools/code-refactor/reuse-outcome.d.ts +27 -1
  9. package/build/tools/code-refactor/reuse-record-store.d.ts +5 -0
  10. package/build/tools/code-refactor/reuse-record-store.js +15 -1
  11. package/build/tools/code-refactor/reuse-state.d.ts +17 -4
  12. package/build/tools/code-refactor/reuse-state.js +67 -9
  13. package/build/tools/code-refactor/utils-verify-gates.js +39 -15
  14. package/build/tools/submitReportTool.js +47 -18
  15. package/build/tools/test-management/registerTestPlanTool.js +1 -1
  16. package/build/types/ReuseOutcome.d.ts +27 -0
  17. package/build/utils/utils-verify/allow.d.ts +1 -1
  18. package/build/utils/utils-verify/allow.js +15 -0
  19. package/build/utils/utils-verify/changed-lines.d.ts +48 -0
  20. package/build/utils/utils-verify/changed-lines.js +106 -0
  21. package/build/utils/utils-verify/head.d.ts +32 -7
  22. package/build/utils/utils-verify/head.js +55 -14
  23. package/build/utils/utils-verify/typecheck.d.ts +25 -4
  24. package/build/utils/utils-verify/typecheck.js +95 -13
  25. package/build/utils/utils-verify/verify.d.ts +24 -5
  26. package/build/utils/utils-verify/verify.js +156 -8
  27. package/build/utils/versions.d.ts +3 -3
  28. package/build/utils/versions.js +1 -1
  29. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +4 -2
  30. package/node_modules/playwright/lib/mcp/skyramp/snapshotLocators.js +246 -0
  31. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +221 -233
  32. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +21 -0
  33. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  34. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.ZYuEFHsi.js → index.uiPTFncY.js} +1 -1
  35. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  36. package/node_modules/playwright/package.json +1 -1
  37. package/package.json +3 -3
@@ -20,8 +20,10 @@ export declare function unknownAnswerObjections(objections: Objection[], acknowl
20
20
  export declare const NON_ANSWERABLE_PREFIX = "coverage:stateTest:";
21
21
  export declare function isNonAnswerable(objectionId: unknown): boolean;
22
22
  /** The objections a sentence alone does not close either. A change with no test
23
- * needs the test, and a defect with no test needs the test a defect the code
24
- * already had before this pull request included. Either one closes only when the
23
+ * needs the test, a value a page and API change refuses needs a UI test or a
24
+ * maintained spec that names it (the XR02 evidence is at `surfaceReject` in
25
+ * `verifierContracts.ts`), and a defect with no test needs the test — a defect
26
+ * the code already had before this pull request included. Each closes only when the
25
27
  * answer also names what stopped the test: a service that is not running, a
26
28
  * branch that no longer exists, the one credential the run holds. Measured on run
27
29
  * 34176038240: across eight fixtures 45 objections were raised and 45 were
@@ -29,7 +31,7 @@ export declare function isNonAnswerable(objectionId: unknown): boolean;
29
31
  * Prose closed every uncovered change, including changes nothing prevented a test
30
32
  * from reaching. Runs 34423214322 and 34423217796 then lost three defects the
31
33
  * baseline reported: each was listed and closed by a sentence. */
32
- export declare const BLOCKER_ONLY_PREFIXES: readonly ["coverage:change:", "defects:untested:"];
34
+ export declare const BLOCKER_ONLY_PREFIXES: readonly ["coverage:change:", "coverage:surfaceReject:", "defects:untested:"];
33
35
  export declare function needsBlocker(objectionId: unknown): boolean;
34
36
  /** The objection raised below carries this prefix and the id it objects to. */
35
37
  export declare const REFUSED_ANSWER_PREFIX = "answers:refused:";
@@ -53,8 +53,10 @@ export function isNonAnswerable(objectionId) {
53
53
  objectionId.startsWith(NON_ANSWERABLE_PREFIX));
54
54
  }
55
55
  /** The objections a sentence alone does not close either. A change with no test
56
- * needs the test, and a defect with no test needs the test a defect the code
57
- * already had before this pull request included. Either one closes only when the
56
+ * needs the test, a value a page and API change refuses needs a UI test or a
57
+ * maintained spec that names it (the XR02 evidence is at `surfaceReject` in
58
+ * `verifierContracts.ts`), and a defect with no test needs the test — a defect
59
+ * the code already had before this pull request included. Each closes only when the
58
60
  * answer also names what stopped the test: a service that is not running, a
59
61
  * branch that no longer exists, the one credential the run holds. Measured on run
60
62
  * 34176038240: across eight fixtures 45 objections were raised and 45 were
@@ -64,6 +66,7 @@ export function isNonAnswerable(objectionId) {
64
66
  * baseline reported: each was listed and closed by a sentence. */
65
67
  export const BLOCKER_ONLY_PREFIXES = [
66
68
  "coverage:change:",
69
+ "coverage:surfaceReject:",
67
70
  "defects:untested:",
68
71
  ];
69
72
  export function needsBlocker(objectionId) {
@@ -188,6 +188,14 @@ export declare const COVERAGE_CONTRACT: {
188
188
  readonly message: "No {kind} test cites this change, which is on the {surface}.";
189
189
  readonly suggestion: "Add a test on that surface that cites this change, or answer in one line why that surface needs no test.";
190
190
  };
191
+ readonly surfaceCase: {
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.";
194
+ };
195
+ readonly surfaceReject: {
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.";
198
+ };
191
199
  };
192
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?";
193
201
  };
@@ -202,6 +202,19 @@ export const COVERAGE_CONTRACT = {
202
202
  message: "No {kind} test cites this change, which is on the {surface}.",
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
+ // `{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.
207
+ surfaceCase: {
208
+ 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
+ },
211
+ // `{case}` is the rendered case, as in `level="Principal" (accept)`. The XR02 run removed Intern from a form; the page
212
+ // objection closed by a sentence, and no generated test checked the option was
213
+ // gone.
214
+ surfaceReject: {
215
+ 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
+ },
205
218
  },
206
219
  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?",
207
220
  };
@@ -5,6 +5,7 @@ import { isTestPath } from "../../utils/testFileClassification.js";
5
5
  import { maintainedFileIsInItsRepository, normalizeCitedPath, } from "./citedPath.js";
6
6
  import { COVERAGE_CONTRACT, fillPlaceholders } from "../verifierContracts.js";
7
7
  import { isUIPlannedTest, stateRouteKey, subjectStep } from "../subjectStep.js";
8
+ import { normalizeStepMethod } from "../../types/StepMethod.js";
8
9
  /** Whether a path can be product code at all, by the deny list route grounding
9
10
  * already applies to a cited file. Nothing else is excluded: a file's kind is
10
11
  * not this check's judgment to make. */
@@ -640,9 +641,6 @@ function rejectObjections(registration) {
640
641
  }
641
642
  return objections;
642
643
  }
643
- // ── The surface a change is on ──────────────────────────────────────────────
644
- /** The test types that reach each surface. An `e2e` test drives the page AND the
645
- * requests behind it, so it counts for both. */
646
644
  const TEST_TYPES_FOR = {
647
645
  api: {
648
646
  types: new Set(["contract", "integration", "e2e"]),
@@ -658,7 +656,7 @@ function declaredSurfaces(change) {
658
656
  return [];
659
657
  return [
660
658
  ...new Set(named.map((surface) => String(surface ?? "").trim())),
661
- ].filter((surface) => surface in TEST_TYPES_FOR);
659
+ ].filter((surface) => Object.hasOwn(TEST_TYPES_FOR, surface));
662
660
  }
663
661
  /** A change on a page needs a test that opens the page, and a change on the API
664
662
  * needs one that calls it. Run 34065056830 cited a page change from an API test
@@ -698,6 +696,146 @@ function surfaceObjections(registration) {
698
696
  }
699
697
  return objections;
700
698
  }
699
+ /** Whether `text` names `value` as a whole word, ignoring case. Letters in any
700
+ * script: an ASCII boundary found "Gr" in "Größe". */
701
+ function namesWord(text, value) {
702
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
703
+ return new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, "iu").test(text);
704
+ }
705
+ /** An `absent` case names no value, so it draws no page objection. */
706
+ function pageObjectionKind(testCase) {
707
+ if (testCase.absent === true)
708
+ return undefined;
709
+ if (testCase.expect === "accept")
710
+ return "surfaceCase";
711
+ if (testCase.expect === "reject")
712
+ return "surfaceReject";
713
+ return undefined;
714
+ }
715
+ /** The value as a UI step would spell it, or undefined when no step can: a number,
716
+ * a boolean, text with no letter and a `{placeholder}` are never spelled in a step,
717
+ * and a page chooses no HTTP verb. */
718
+ function valueAPageSpells(testCase) {
719
+ const param = testCase.param.trim();
720
+ const value = testCase.value;
721
+ if (!param || param.toLowerCase() === METHOD_PARAM)
722
+ return undefined;
723
+ if (typeof value !== "string")
724
+ return undefined;
725
+ const word = value.trim();
726
+ if (!/\p{L}/u.test(word))
727
+ return undefined;
728
+ if (/^(true|false)$/i.test(word) || PLACEHOLDER_VALUE.test(word))
729
+ return undefined;
730
+ return word;
731
+ }
732
+ /** The page steps that choose a value. An `assert` that the radio is visible
733
+ * chose nothing: the XR02 run passed Principal on one, and no test saved at it.
734
+ * An e2e test's HTTP step is not the page either. */
735
+ const CHOOSING_VERBS = new Set([
736
+ "click",
737
+ "type",
738
+ "press",
739
+ "tap",
740
+ ]);
741
+ /** A refused value is also met by asserting the page no longer offers it. */
742
+ const REFUSING_VERBS = new Set([
743
+ ...CHOOSING_VERBS,
744
+ "assert",
745
+ ]);
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
+ ];
756
+ }
757
+ /** A change on the page AND the API needs each accepted value chosen on the page,
758
+ * and each refused value tried or shown absent there, not only sent by an API
759
+ * test. `surfaceObjections` passes the page once any UI test cites the change,
760
+ * whatever value it picks. A UI step is free text (`Senior radio`), so the check
761
+ * 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.
764
+ *
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. */
768
+ function surfaceCaseObjections(registration, ctx) {
769
+ const objections = [];
770
+ const seen = new Set();
771
+ const pageTypes = TEST_TYPES_FOR.page.types;
772
+ for (const change of registration.changes) {
773
+ const id = change.id.trim();
774
+ const surfaces = declaredSurfaces(change);
775
+ if (!id || !surfaces.includes("page") || !surfaces.includes("api"))
776
+ continue;
777
+ const uiTests = registration.plannedTests.filter((plannedTest) => citesChange(plannedTest, id) &&
778
+ pageTypes.has(plannedTest.scenario.testType.toLowerCase()));
779
+ // 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.
782
+ const maintained = (registration.maintains ?? []).filter((entry) => entry.changes.some((cited) => cited.trim() === id) &&
783
+ maintainedFileIsInItsRepository(entry, ctx));
784
+ const citedAtAll = maintained.length > 0 ||
785
+ registration.plannedTests.some((plannedTest) => citesChange(plannedTest, id));
786
+ const texts = {
787
+ surfaceCase: pageTexts(uiTests, maintained, CHOOSING_VERBS),
788
+ surfaceReject: pageTexts(uiTests, maintained, REFUSING_VERBS),
789
+ };
790
+ for (const testCase of change.testCases ?? []) {
791
+ const kind = pageObjectionKind(testCase);
792
+ // An accepted value with no UI test and no maintained spec already draws
793
+ // `coverage:surface:<id>:page`, and a second objection would need a second
794
+ // answer for one gap. A maintained spec alone does not stop that objection,
795
+ // but a sentence citing the spec closed it while a value went untried, so the
796
+ // value is checked then. A refused value is always checked: that sentence is
797
+ // the gap. A change nothing cites draws `coverage:change:<id>`.
798
+ if (!kind || !citedAtAll)
799
+ continue;
800
+ if (kind === "surfaceCase" &&
801
+ uiTests.length === 0 &&
802
+ maintained.length === 0)
803
+ continue;
804
+ const word = valueAPageSpells(testCase);
805
+ if (word === undefined)
806
+ continue;
807
+ if (texts[kind].some((text) => namesWord(text, word)))
808
+ continue;
809
+ const rendered = renderTestCase({
810
+ ...testCase,
811
+ param: testCase.param.trim(),
812
+ value: word,
813
+ });
814
+ const objectionId = `coverage:${kind}:${id}:page:${rendered}`;
815
+ // One step closes `Intern` and `intern` alike, so they are one objection.
816
+ const key = objectionId.toLowerCase();
817
+ if (seen.has(key))
818
+ continue;
819
+ seen.add(key);
820
+ const citers = [
821
+ ...uiTests.map((plannedTest) => plannedTest.plannedTestId),
822
+ ...maintained.map((entry) => entry.file),
823
+ ];
824
+ objections.push({
825
+ objectionId,
826
+ verifier: "coverage",
827
+ message: fillPlaceholders(COVERAGE_CONTRACT.objections[kind].message, {
828
+ case: rendered,
829
+ }),
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}.`,
833
+ suggestion: COVERAGE_CONTRACT.objections[kind].suggestion,
834
+ });
835
+ }
836
+ }
837
+ return objections;
838
+ }
701
839
  /** One repository's changed files, and the name to attribute uncovered ones to.
702
840
  * Keying the objection id on the path alone let two repositories that each change
703
841
  * `src/App.tsx` share one id, so every id names its repository. */
@@ -759,6 +897,7 @@ export const coverage = {
759
897
  ...testCaseObjections(registration),
760
898
  ...rejectObjections(registration),
761
899
  ...surfaceObjections(registration),
900
+ ...surfaceCaseObjections(registration, ctx),
762
901
  ];
763
902
  if (allChangedFiles(ctx).length === 0)
764
903
  return objections;
@@ -0,0 +1,33 @@
1
+ import type { UnifiedAnalysisState } from "../../utils/AnalysisStateManager.js";
2
+ import type { RemovedModuleEntry } from "../../types/ReuseOutcome.js";
3
+ /**
4
+ * A shared module the run created and then removed — the one thing about a
5
+ * module that cannot be re-derived from the delivered files, because there is no
6
+ * file left to derive from. The verify pass records what it located and how the
7
+ * pass ended (`locatedModules`, in reuse-state.ts beside the record writes); the
8
+ * report reads the records for a run-created module that is no longer on disk
9
+ * (`removedModulesFrom`). This module imports reuse-state.ts and nothing here
10
+ * is imported back, so the two cannot load each other half-initialised.
11
+ */
12
+ /** A shared module a run created and removed again, read off the records. `file`
13
+ * is the absolute path for the caller's directory match; the rest is the wire
14
+ * entry. */
15
+ export type RemovedModule = RemovedModuleEntry & {
16
+ file: string;
17
+ };
18
+ /**
19
+ * The modules the run created and then removed, across every record: each module a
20
+ * verify pass located that HEAD did not hold and that is no longer on disk. One
21
+ * entry per module, however many specs' passes located it.
22
+ *
23
+ * The reason is what the record holds and nothing more: the last verify pass that
24
+ * had the module, and how it ended. The run's own motive is not on record — an
25
+ * agent deletes a module after a failed pass, and the failures are the closest
26
+ * thing to its reason — so the entry states the verdict and lets the reader draw
27
+ * the line. A module removed after a PASSED verify is stated as such. Nor does
28
+ * the entry say what the delivered tests import instead: a module the run
29
+ * RENAMED is gone from this path and present under another, and the row's
30
+ * `helpers` field names that one. The entry claims only what it measured — the
31
+ * path is not in the delivery.
32
+ */
33
+ export declare function removedModulesFrom(outcomes: UnifiedAnalysisState["reuseOutcomes"]): Promise<RemovedModule[]>;
@@ -0,0 +1,61 @@
1
+ import { stat } from "fs/promises";
2
+ import { utilsFileLabel } from "./reuse-state.js";
3
+ /**
4
+ * The modules the run created and then removed, across every record: each module a
5
+ * verify pass located that HEAD did not hold and that is no longer on disk. One
6
+ * entry per module, however many specs' passes located it.
7
+ *
8
+ * The reason is what the record holds and nothing more: the last verify pass that
9
+ * had the module, and how it ended. The run's own motive is not on record — an
10
+ * agent deletes a module after a failed pass, and the failures are the closest
11
+ * thing to its reason — so the entry states the verdict and lets the reader draw
12
+ * the line. A module removed after a PASSED verify is stated as such. Nor does
13
+ * the entry say what the delivered tests import instead: a module the run
14
+ * RENAMED is gone from this path and present under another, and the row's
15
+ * `helpers` field names that one. The entry claims only what it measured — the
16
+ * path is not in the delivery.
17
+ */
18
+ export async function removedModulesFrom(outcomes) {
19
+ // Several specs' records can hold one module; the LATEST pass over it is the
20
+ // verdict it was removed under, and the latest answer on whether this run
21
+ // created it — whichever record holds it. Latest FIRST, then the claim: an
22
+ // older "created" must not outrank a newer "found at HEAD".
23
+ const latest = new Map();
24
+ for (const record of Object.values(outcomes ?? {}))
25
+ for (const m of record.utils?.modules ?? []) {
26
+ const held = latest.get(m.file);
27
+ if (!held || m.lastPass.at > held.lastPass.at)
28
+ latest.set(m.file, m);
29
+ }
30
+ const out = [];
31
+ for (const m of latest.values()) {
32
+ if (!m.createdByThisRun)
33
+ continue;
34
+ if (await exists(m.file))
35
+ continue;
36
+ const { failed, failures, errored } = m.lastPass;
37
+ const verdict = errored
38
+ ? "could not run (the verifier threw)"
39
+ : !failed
40
+ ? "passed, so the run gave no reason"
41
+ : failures
42
+ ? `failed on ${failures.map((f) => `${f.kind} ×${f.count}`).join(", ")}`
43
+ : "failed outside the file's invariants (a staging refusal)";
44
+ out.push({
45
+ file: m.file,
46
+ utilsFile: utilsFileLabel([m.file]),
47
+ reason: `this run created the module, and it is not in the delivery; the last verify pass that had it ${verdict}`,
48
+ ...(failures ? { verificationFailures: failures } : {}),
49
+ });
50
+ }
51
+ return out;
52
+ }
53
+ async function exists(file) {
54
+ try {
55
+ await stat(file);
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
@@ -1,6 +1,6 @@
1
1
  import type { VerifyResult } from "../../utils/pom-verify/index.js";
2
2
  import { ReuseVerificationOutcome, type InHouseDeclinedEntry, type ReuseSkippedEntry } from "../../types/ReuseOutcome.js";
3
- import { HelperVerificationOutcome } from "../../types/ReuseOutcome.js";
3
+ import { HelperVerificationOutcome, type ReuseVerificationFailure } from "../../types/ReuseOutcome.js";
4
4
  export { ReuseDeclinedBy, ReuseVerificationOutcome, HelperVerificationOutcome, type ReuseOutcome, type ReuseSkippedEntry, } from "../../types/ReuseOutcome.js";
5
5
  /** A member the verifier flagged at some point in this run. NOT the same as "a
6
6
  * member that was demoted": the workflow allows one remap attempt, so a flagged
@@ -87,6 +87,32 @@ export interface UtilsReuseRecord {
87
87
  * repository's own browser helper modules: the files it read, the helpers it
88
88
  * declined. Testimony, kept for the report and labelled there as such. */
89
89
  inHouseDeclared?: InHouseDeclaration;
90
+ /** Every shared module a verify pass of this spec located, accumulated across
91
+ * passes. A pass that locates a module overwrites its entry; a pass that
92
+ * locates none touches nothing — that is the pass after the run DELETED the
93
+ * module, and the record is the only place the module's existence survives. */
94
+ modules?: LocatedModule[];
95
+ }
96
+ export interface LocatedModule {
97
+ file: string;
98
+ /** HEAD lacked it: this run created it. `false` for an incumbent. ABSENT when
99
+ * git gave no answer — outside a repository, or a git failure — so the report
100
+ * never says "this run created it" on no evidence. */
101
+ createdByThisRun?: boolean;
102
+ /** The last verify pass that had this module: when, whether it failed, and on
103
+ * what. The PASS's failures, filed under each module it had: a failure on the
104
+ * spec side (a type error at a call site, an assertion loss) names no module,
105
+ * and it is still the verdict the module was removed under. A failed pass with
106
+ * no failures failed outside the file's invariants — a staging refusal. */
107
+ lastPass: {
108
+ /** ISO time, so the latest pass wins when several specs' records hold the
109
+ * same module. */
110
+ at: string;
111
+ failed: boolean;
112
+ failures?: ReuseVerificationFailure[];
113
+ /** The pass threw before it could judge anything. `failed` is true too. */
114
+ errored?: true;
115
+ };
90
116
  }
91
117
  export interface InHouseDeclaration {
92
118
  filesRead: string[];
@@ -47,6 +47,11 @@ export declare function owns(record: ReuseRecord, testFile: string): boolean;
47
47
  export declare function samePath(a: string, b: string): boolean;
48
48
  /** Exported for retrofit-state: the same canonicalisation the records use. */
49
49
  export declare function canonPath(p: string): string;
50
+ /** Canonical path for a file that may be GONE: `realpath` fails on a missing
51
+ * file and the fallback leaves a symlinked parent (macOS `/tmp` → `/private/tmp`)
52
+ * unresolved, so a deleted module never matched a canonical spec path. The
53
+ * nearest existing ancestor is canonicalised and the missing tail put back. */
54
+ export declare function canonPathEvenIfGone(p: string): string;
50
55
  /** Whether the run state file can be written — the precondition for any debt this
51
56
  * module holds, since only a write clears it. */
52
57
  export declare function runStateWritable(explicitStateFile?: string): Promise<boolean>;
@@ -1,6 +1,6 @@
1
1
  import * as path from "path";
2
2
  import { access, constants as fsConstants } from "fs/promises";
3
- import { realpathSync } from "fs";
3
+ import { existsSync, realpathSync } from "fs";
4
4
  import { logger } from "../../utils/logger.js";
5
5
  import { resolveRunStatePath, StateManager, } from "../../utils/AnalysisStateManager.js";
6
6
  import { realpath } from "../../utils/utils-verify/index.js";
@@ -86,6 +86,7 @@ export function freshRecord() {
86
86
  utils: undefined,
87
87
  modularized: undefined,
88
88
  pomPassFinished: undefined,
89
+ baseline: undefined,
89
90
  };
90
91
  }
91
92
  /** Whether `record` describes `testFile` (or nobody yet). Basename-keyed run state
@@ -102,6 +103,19 @@ export function samePath(a, b) {
102
103
  export function canonPath(p) {
103
104
  return canon(p);
104
105
  }
106
+ /** Canonical path for a file that may be GONE: `realpath` fails on a missing
107
+ * file and the fallback leaves a symlinked parent (macOS `/tmp` → `/private/tmp`)
108
+ * unresolved, so a deleted module never matched a canonical spec path. The
109
+ * nearest existing ancestor is canonicalised and the missing tail put back. */
110
+ export function canonPathEvenIfGone(p) {
111
+ let probe = p;
112
+ const tail = [];
113
+ while (!existsSync(probe) && path.dirname(probe) !== probe) {
114
+ tail.unshift(path.basename(probe));
115
+ probe = path.dirname(probe);
116
+ }
117
+ return path.join(canon(probe), ...tail);
118
+ }
105
119
  function canon(p) {
106
120
  try {
107
121
  return realpathSync(p);
@@ -1,11 +1,11 @@
1
1
  import { type ReuseHandOff, type ReuseHandOffSource } from "../../utils/AnalysisStateManager.js";
2
2
  import { type VerifyResult } from "../../utils/pom-verify/index.js";
3
- import { type InHouseDeclaration, type ReuseOutcome, type ReuseRecord } from "./reuse-outcome.js";
3
+ import { type InHouseDeclaration, type LocatedModule, type ReuseOutcome, type ReuseRecord } from "./reuse-outcome.js";
4
4
  import { type UtilsVerifyResult, type UtilsViolationKind } from "../../utils/utils-verify/index.js";
5
5
  import type { HelperReuseOutcome, ReuseVerificationFailure } from "../../types/ReuseOutcome.js";
6
6
  /** The canonical path comparison the records use — declared in the store, re-exported
7
7
  * here for the readers that always imported it from this module. */
8
- export { samePath, canonPath } from "./reuse-record-store.js";
8
+ export { samePath, canonPath, canonPathEvenIfGone, } from "./reuse-record-store.js";
9
9
  /**
10
10
  * Persistence for the server-derived POM-reuse outcome, keyed by test-file
11
11
  * basename (what `newTestsCreated[].fileName` carries, so
@@ -105,8 +105,21 @@ export declare function recordUtilsVerifyError(testFile: string, explicitStateFi
105
105
  * repository, or for a file the root does not contain, the basename stands in.
106
106
  */
107
107
  export declare function utilsFileLabel(files: string[]): string;
108
- /** Verdict of one utils verify pass. The verdict alone is stored — counts and the
109
- * file list are re-derived from the delivered files at report time. */
108
+ /** The record's module list after one verify pass: every module the pass located
109
+ * gets a fresh entry whether HEAD lacked it, and the pass's failures when it
110
+ * failed — and every module the pass did not locate keeps its entry. A pass that
111
+ * locates nothing is the pass after the run DELETED the module, and the record
112
+ * is the only place the module's existence survives. */
113
+ export declare function locatedModules(prior: LocatedModule[] | undefined, r: UtilsVerifyResult): LocatedModule[];
114
+ /** The record's module list after a verify pass that THREW: every module keeps
115
+ * its entry and is stamped with the failed pass, so a module removed after it
116
+ * never reads as removed after a pass that passed. */
117
+ export declare function modulesAfterVerifyError(prior: LocatedModule[] | undefined): LocatedModule[];
118
+ /** Verdict of one utils verify pass. The verdict is stored — counts and the file
119
+ * list are re-derived from the delivered files at report time — and so are the
120
+ * modules the pass located and the failures of a failed pass: a module the run
121
+ * removes afterwards can be re-derived from nothing, and its row must still say
122
+ * it existed and why it went. */
110
123
  export declare function recordUtilsVerifyOutcome(testFile: string, r: UtilsVerifyResult, language: string, framework: string, testType: string | undefined, explicitStateFile?: string,
111
124
  /** The agent's declarations from this verify call, when it made any. */
112
125
  inHouseDeclared?: InHouseDeclaration): Promise<boolean>;
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "fs";
1
2
  import * as fsp from "fs/promises";
2
3
  import * as path from "path";
3
4
  import { readFile, stat } from "fs/promises";
@@ -18,11 +19,11 @@ const execFileAsync = promisify(execFile);
18
19
  import { resolvePomCatalogPath } from "../../utils/pom-catalog.js";
19
20
  import { parsePomCatalog, findMissedReuse, } from "../../utils/pom-catalog-parse.js";
20
21
  import { ProgrammingLanguage } from "../../types/TestTypes.js";
21
- import { canonPath, freshRecord, owns, readHandOff, readReuseRecord, readRunState, runStateWritable, samePath, updateReuseOutcome, } from "./reuse-record-store.js";
22
+ import { canonPath, canonPathEvenIfGone, freshRecord, owns, readHandOff, readReuseRecord, readRunState, runStateWritable, samePath, updateReuseOutcome, } from "./reuse-record-store.js";
22
23
  import { combineRederivations, pomFirstHandOff } from "./pom-pass-state.js";
23
24
  /** The canonical path comparison the records use — declared in the store, re-exported
24
25
  * here for the readers that always imported it from this module. */
25
- export { samePath, canonPath } from "./reuse-record-store.js";
26
+ export { samePath, canonPath, canonPathEvenIfGone, } from "./reuse-record-store.js";
26
27
  /**
27
28
  * Persistence for the server-derived POM-reuse outcome, keyed by test-file
28
29
  * basename (what `newTestsCreated[].fileName` carries, so
@@ -184,7 +185,15 @@ export async function recordUtilsReuseIssued(testFile, language, framework, test
184
185
  * run must not leave a debt nothing can pay — and cleared by the next successful pass. */
185
186
  export async function recordUtilsVerifyError(testFile, explicitStateFile) {
186
187
  await updateReuseOutcome(testFile, (existing) => owns(existing, testFile) && existing.utils
187
- ? { utils: { ...existing.utils, verifyError: true } }
188
+ ? {
189
+ utils: {
190
+ ...existing.utils,
191
+ verifyError: true,
192
+ ...(existing.utils.modules
193
+ ? { modules: modulesAfterVerifyError(existing.utils.modules) }
194
+ : {}),
195
+ },
196
+ }
188
197
  : {}, explicitStateFile);
189
198
  }
190
199
  /**
@@ -202,12 +211,17 @@ export function utilsFileLabel(files) {
202
211
  return files.map(repoRelativeLabel).join(", ");
203
212
  }
204
213
  function repoRelativeLabel(file) {
205
- const root = detectGitRoot(file);
214
+ // Canonical even when GONE: a module the run REMOVED has no file to stat, and
215
+ // its label must still name where in the repository it was.
216
+ const canonFile = canonPathEvenIfGone(file);
217
+ let probe = path.dirname(canonFile);
218
+ while (!existsSync(probe) && path.dirname(probe) !== probe)
219
+ probe = path.dirname(probe);
220
+ const root = detectGitRoot(probe);
206
221
  if (!root)
207
222
  return path.basename(file);
208
- // Both sides canonical: git reports the REAL root, and a path through a symlink
209
- // (macOS /tmp /private/tmp) would otherwise relativise to `../../…`.
210
- const rel = path.relative(canonPath(root), canonPath(file));
223
+ // Both sides canonical: git reports the REAL root.
224
+ const rel = path.relative(canonPath(root), canonFile);
211
225
  // Outside the root means a leading `..` SEGMENT — not any name starting with two
212
226
  // dots (`..generated/SkyrampUtils.py` is a valid in-repository path).
213
227
  const outside = rel === ".." || rel.startsWith(`..${path.sep}`);
@@ -215,8 +229,50 @@ function repoRelativeLabel(file) {
215
229
  return path.basename(file);
216
230
  return rel.split(path.sep).join("/");
217
231
  }
218
- /** Verdict of one utils verify pass. The verdict alone is stored — counts and the
219
- * file list are re-derived from the delivered files at report time. */
232
+ /** The record's module list after one verify pass: every module the pass located
233
+ * gets a fresh entry whether HEAD lacked it, and the pass's failures when it
234
+ * failed — and every module the pass did not locate keeps its entry. A pass that
235
+ * locates nothing is the pass after the run DELETED the module, and the record
236
+ * is the only place the module's existence survives. */
237
+ export function locatedModules(prior, r) {
238
+ const incumbents = new Set(r.incumbentModules);
239
+ const unknown = new Set(r.headUnknownModules);
240
+ const modules = new Map((prior ?? []).map((m) => [m.file, m]));
241
+ const failures = r.ok ? [] : verificationFailures(r);
242
+ const lastPass = {
243
+ at: new Date().toISOString(),
244
+ failed: !r.ok,
245
+ ...(failures.length > 0 ? { failures } : {}),
246
+ };
247
+ for (const file of r.utilsFiles) {
248
+ // Git gave no answer this pass: an earlier pass's answer stands. A transient
249
+ // failure on the last pass before a deletion must not erase the claim.
250
+ const createdByThisRun = unknown.has(file)
251
+ ? modules.get(file)?.createdByThisRun
252
+ : !incumbents.has(file);
253
+ modules.set(file, {
254
+ file,
255
+ ...(createdByThisRun === undefined ? {} : { createdByThisRun }),
256
+ lastPass,
257
+ });
258
+ }
259
+ return [...modules.values()];
260
+ }
261
+ /** The record's module list after a verify pass that THREW: every module keeps
262
+ * its entry and is stamped with the failed pass, so a module removed after it
263
+ * never reads as removed after a pass that passed. */
264
+ export function modulesAfterVerifyError(prior) {
265
+ const at = new Date().toISOString();
266
+ return (prior ?? []).map((m) => ({
267
+ ...m,
268
+ lastPass: { at, failed: true, errored: true },
269
+ }));
270
+ }
271
+ /** Verdict of one utils verify pass. The verdict is stored — counts and the file
272
+ * list are re-derived from the delivered files at report time — and so are the
273
+ * modules the pass located and the failures of a failed pass: a module the run
274
+ * removes afterwards can be re-derived from nothing, and its row must still say
275
+ * it existed and why it went. */
220
276
  export async function recordUtilsVerifyOutcome(testFile, r, language, framework, testType, explicitStateFile,
221
277
  /** The agent's declarations from this verify call, when it made any. */
222
278
  inHouseDeclared) {
@@ -234,6 +290,7 @@ inHouseDeclared) {
234
290
  return updateReuseOutcome(testFile, (existing) => {
235
291
  const mine = owns(existing, testFile);
236
292
  const prior = mine ? existing.utils : undefined;
293
+ const modules = locatedModules(prior?.modules, r);
237
294
  return {
238
295
  ...(mine ? {} : freshRecord()),
239
296
  testFilePath: testFile,
@@ -255,6 +312,7 @@ inHouseDeclared) {
255
312
  ...((inHouseDeclared ?? prior?.inHouseDeclared)
256
313
  ? { inHouseDeclared: inHouseDeclared ?? prior?.inHouseDeclared }
257
314
  : {}),
315
+ ...(modules.length > 0 ? { modules } : {}),
258
316
  },
259
317
  };
260
318
  }, explicitStateFile);