@skyramp/mcp 0.3.8 → 0.4.0-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 (229) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -1
  2. package/build/commands/commandLibrary.js +3 -3
  3. package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
  4. package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
  5. package/build/commands/testThisEndpointCommand.js +35 -19
  6. package/build/index.js +9 -3
  7. package/build/playwright/blueprintDigest.d.ts +15 -0
  8. package/build/playwright/blueprintDigest.js +152 -0
  9. package/build/playwright/blueprintDigestStore.d.ts +31 -0
  10. package/build/playwright/blueprintDigestStore.js +117 -0
  11. package/build/playwright/registerPlaywrightTools.js +60 -12
  12. package/build/playwright/traceRecordingPrompt.js +8 -7
  13. package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
  14. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
  15. package/build/prompts/promptAssets.d.ts +20 -0
  16. package/build/prompts/promptAssets.js +55 -0
  17. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
  18. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  19. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
  23. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
  24. package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
  25. package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
  26. package/build/prompts/test-recommendation/recommendationSections.js +67 -309
  27. package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
  28. package/build/prompts/test-recommendation/recommendationShared.js +49 -155
  29. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
  30. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
  31. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
  32. package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
  33. package/build/prompts/testbot/planDeclarations.d.ts +6 -0
  34. package/build/prompts/testbot/planDeclarations.js +9 -0
  35. package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
  36. package/build/prompts/testbot/testbot-prompts.js +256 -381
  37. package/build/recommendation/answers.d.ts +35 -0
  38. package/build/recommendation/answers.js +96 -0
  39. package/build/recommendation/registerPlan.d.ts +49 -0
  40. package/build/recommendation/registerPlan.js +117 -0
  41. package/build/recommendation/runVerifiers.d.ts +10 -0
  42. package/build/recommendation/runVerifiers.js +49 -0
  43. package/build/recommendation/subjectStep.d.ts +42 -0
  44. package/build/recommendation/subjectStep.js +86 -0
  45. package/build/recommendation/types.d.ts +163 -0
  46. package/build/recommendation/types.js +20 -0
  47. package/build/recommendation/verifierContracts.d.ts +382 -0
  48. package/build/recommendation/verifierContracts.js +263 -0
  49. package/build/recommendation/verifiers/changedFile.d.ts +2 -0
  50. package/build/recommendation/verifiers/changedFile.js +82 -0
  51. package/build/recommendation/verifiers/citedPath.d.ts +12 -0
  52. package/build/recommendation/verifiers/citedPath.js +35 -0
  53. package/build/recommendation/verifiers/coverage.d.ts +7 -0
  54. package/build/recommendation/verifiers/coverage.js +617 -0
  55. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
  56. package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
  57. package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
  58. package/build/recommendation/verifiers/endpointGrounded.js +128 -0
  59. package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
  60. package/build/recommendation/verifiers/existingCoverage.js +51 -0
  61. package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
  62. package/build/recommendation/verifiers/expectedOutcome.js +105 -0
  63. package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
  64. package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
  65. package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
  66. package/build/recommendation/verifiers/reportedCategory.js +84 -0
  67. package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
  68. package/build/recommendation/verifiers/screenRoute.js +118 -0
  69. package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
  70. package/build/recommendation/verifiers/statedDifference.js +140 -0
  71. package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
  72. package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
  73. package/build/resources/analysisResources.js +1 -114
  74. package/build/resources/testbotResource.js +23 -13
  75. package/build/services/ModularizationService.js +2 -1
  76. package/build/services/TestDiscoveryService.d.ts +3 -72
  77. package/build/services/TestDiscoveryService.js +10 -303
  78. package/build/services/containerEnv.d.ts +1 -1
  79. package/build/services/containerEnv.js +12 -0
  80. package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
  81. package/build/skills/fixTestImportErrorsSkill.js +20 -0
  82. package/build/toolNames.d.ts +1 -0
  83. package/build/toolNames.js +1 -0
  84. package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
  85. package/build/tools/code-refactor/modularizationTool.js +2 -1
  86. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  87. package/build/tools/executeSkyrampTestTool.js +246 -19
  88. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
  89. package/build/tools/generate-tests/generateContractRestTool.js +3 -3
  90. package/build/tools/generate-tests/planGuard.d.ts +2 -2
  91. package/build/tools/generate-tests/planGuard.js +78 -18
  92. package/build/tools/one-click/oneClickTool.d.ts +0 -1
  93. package/build/tools/one-click/oneClickTool.js +0 -5
  94. package/build/tools/submitReportTool.d.ts +48 -42
  95. package/build/tools/submitReportTool.js +576 -193
  96. package/build/tools/test-management/actionsTool.js +72 -4
  97. package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
  98. package/build/tools/test-management/analyzeChangesTool.js +212 -1219
  99. package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
  100. package/build/tools/test-management/index.d.ts +1 -0
  101. package/build/tools/test-management/index.js +1 -0
  102. package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
  103. package/build/tools/test-management/registerTestPlanTool.js +609 -542
  104. package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
  105. package/build/tools/test-management/resolveScreenTool.js +289 -0
  106. package/build/types/BlueprintDigest.d.ts +34 -0
  107. package/build/types/BlueprintDigest.js +1 -0
  108. package/build/types/RepositoryAnalysis.d.ts +20 -1559
  109. package/build/types/RepositoryAnalysis.js +2 -58
  110. package/build/types/StepMethod.d.ts +40 -0
  111. package/build/types/StepMethod.js +77 -0
  112. package/build/types/TestAnalysis.d.ts +12 -0
  113. package/build/types/TestExecution.d.ts +4 -0
  114. package/build/types/TestRecommendation.d.ts +24 -24
  115. package/build/types/TestRecommendation.js +91 -89
  116. package/build/types/TestbotPromptOptions.d.ts +0 -4
  117. package/build/types/TestbotReport.d.ts +64 -2
  118. package/build/utils/AnalysisStateManager.d.ts +79 -113
  119. package/build/utils/AnalysisStateManager.js +147 -57
  120. package/build/utils/assertion-verify/api-shared-lints.js +1 -1
  121. package/build/utils/assertion-verify/metrics.js +85 -36
  122. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  123. package/build/utils/assertion-verify/ui-lints.js +32 -0
  124. package/build/utils/branchDiff.d.ts +63 -31
  125. package/build/utils/branchDiff.js +242 -94
  126. package/build/utils/containedPath.d.ts +18 -0
  127. package/build/utils/containedPath.js +73 -0
  128. package/build/utils/dartRouteExtractor.d.ts +18 -34
  129. package/build/utils/dartRouteExtractor.js +101 -173
  130. package/build/utils/featureFlags.d.ts +12 -0
  131. package/build/utils/featureFlags.js +14 -0
  132. package/build/utils/frontendSelectors.d.ts +48 -27
  133. package/build/utils/frontendSelectors.js +241 -80
  134. package/build/utils/pathMatching.d.ts +2 -4
  135. package/build/utils/pathMatching.js +2 -4
  136. package/build/utils/planMatchKeys.d.ts +38 -47
  137. package/build/utils/planMatchKeys.js +143 -81
  138. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  139. package/build/utils/rebaselineSnapshots.js +65 -0
  140. package/build/utils/removedUiElements.d.ts +22 -0
  141. package/build/utils/removedUiElements.js +106 -0
  142. package/build/utils/reportVerification.d.ts +2 -6
  143. package/build/utils/reportVerification.js +61 -2
  144. package/build/utils/screenRoutes.d.ts +66 -0
  145. package/build/utils/screenRoutes.js +727 -0
  146. package/build/utils/sourceRouteExtractor.js +320 -112
  147. package/build/utils/testFileClassification.d.ts +11 -2
  148. package/build/utils/testFileClassification.js +44 -2
  149. package/build/utils/testFixtures.d.ts +5 -0
  150. package/build/utils/testFixtures.js +13 -0
  151. package/build/utils/utils.d.ts +0 -1
  152. package/build/utils/utils.js +0 -11
  153. package/build/utils/versions.d.ts +3 -3
  154. package/build/utils/versions.js +1 -1
  155. package/build/workspace/workspace.d.ts +12 -12
  156. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  157. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  158. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  160. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  161. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  162. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  168. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  171. package/node_modules/playwright/package.json +1 -1
  172. package/package.json +4 -3
  173. package/plugin/.claude-plugin/plugin.json +8 -0
  174. package/plugin/plugin.json +6 -0
  175. package/plugin/prompts/declaring-a-plan.md +20 -0
  176. package/plugin/prompts/generate-tests/context-fetching.md +4 -0
  177. package/plugin/prompts/generate-tests/execution-plan.md +63 -0
  178. package/plugin/prompts/generate-tests/generation.md +108 -0
  179. package/plugin/prompts/generate-tests/path-parameters.md +1 -0
  180. package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
  181. package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
  182. package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
  183. package/plugin/prompts/plan-tests.md +42 -0
  184. package/plugin/prompts/testbot-task1.md +82 -0
  185. package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
  186. package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
  187. package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
  188. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
  189. package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
  190. package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
  191. package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
  192. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
  193. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
  194. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
  195. package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
  196. package/build/recommendation/budgeters/shared.d.ts +0 -32
  197. package/build/recommendation/budgeters/shared.js +0 -246
  198. package/build/recommendation/discriminators.d.ts +0 -37
  199. package/build/recommendation/discriminators.js +0 -379
  200. package/build/recommendation/diversity.d.ts +0 -47
  201. package/build/recommendation/diversity.js +0 -101
  202. package/build/recommendation/planRanker.d.ts +0 -65
  203. package/build/recommendation/planRanker.js +0 -83
  204. package/build/recommendation/testFixtures.d.ts +0 -25
  205. package/build/recommendation/testFixtures.js +0 -45
  206. package/build/types/FrontendIntegration.d.ts +0 -28
  207. package/build/types/FrontendIntegration.js +0 -22
  208. package/build/types/Recommendation.d.ts +0 -146
  209. package/build/types/Recommendation.js +0 -74
  210. package/build/utils/changedRoutes.d.ts +0 -29
  211. package/build/utils/changedRoutes.js +0 -87
  212. package/build/utils/frontendIntegration.d.ts +0 -9
  213. package/build/utils/frontendIntegration.js +0 -243
  214. package/build/utils/importerHop.d.ts +0 -135
  215. package/build/utils/importerHop.js +0 -489
  216. package/build/utils/pathAffinityClassification.d.ts +0 -49
  217. package/build/utils/pathAffinityClassification.js +0 -180
  218. package/build/utils/pythonMountPrefixes.d.ts +0 -25
  219. package/build/utils/pythonMountPrefixes.js +0 -347
  220. package/build/utils/repoScanner.d.ts +0 -34
  221. package/build/utils/repoScanner.js +0 -300
  222. package/build/utils/routeParsers.d.ts +0 -95
  223. package/build/utils/routeParsers.js +0 -951
  224. package/build/utils/scenarioDrafting.d.ts +0 -92
  225. package/build/utils/scenarioDrafting.js +0 -951
  226. package/build/utils/subjectEndpoints.d.ts +0 -19
  227. package/build/utils/subjectEndpoints.js +0 -98
  228. package/build/utils/uiPageEnumerator.d.ts +0 -172
  229. package/build/utils/uiPageEnumerator.js +0 -474
@@ -24,11 +24,9 @@ export function setTestsRepoDir(dir) {
24
24
  *
25
25
  * The tests repo is a RUN-scoped fact, but analyze_changes is called once per
26
26
  * repository and only the PRIMARY call carries `testsRepoDir` — a related
27
- * repo's call omits it. An unconditional set therefore wiped the primary's
28
- * value on the second call of every multi-repo run, and by report time the
29
- * tests repo was unknown: a UI test generated there looked unbacked by any
30
- * working tree and the agent demoted it out of newTestsCreated (SKYR-4204,
31
- * run 32536569467). Callers that mean to clear it use setTestsRepoDir.
27
+ * repo's call omits it. An unconditional set therefore wipes the primary's
28
+ * value on the second call of every multi-repo run, leaving the tests repo
29
+ * unknown by report time. Callers that mean to clear it use setTestsRepoDir.
32
30
  */
33
31
  export function rememberTestsRepoDir(dir) {
34
32
  if (dir)
@@ -39,21 +37,53 @@ export function getTestsRepoDir() {
39
37
  }
40
38
  /**
41
39
  * Multi-repo run anchor: the path of the run-scoped analysis state file for the
42
- * current Testbot run. One Testbot run = one MCP server process, so a single
43
- * module-level value is naturally run-scoped (same pattern as _testsRepoDir and
44
- * processSessionRegistry above).
40
+ * current Testbot run.
45
41
  *
46
42
  * skyramp_analyze_changes sets this on the FIRST call of a multi-repo run (the one
47
43
  * carrying a `repository`) and REUSES it on subsequent related-repo calls, so every
48
44
  * repo's analysis lands in ONE state file with per-repo sections — without the agent
49
45
  * ever passing a file path (which previously caused filename hallucinations).
50
46
  * Single-repo / standalone calls (no `repository`) never set or read this.
47
+ *
48
+ * A staged Testbot run starts a NEW server process per stage, so this value is gone
49
+ * at every stage boundary. That is what the pointer file below is for.
51
50
  */
52
51
  let _activeRunStatePath;
53
52
  /** Filename of the run-scoped analysis state file under `runArtifactDir()`.
54
53
  * Single-sourced so the constructor that WRITES there and `resolveRunStatePath`
55
54
  * which LOOKS there cannot drift apart. */
56
55
  export const RUN_STATE_FILE_NAME = "analyze-changes-state.json";
56
+ /** Filename of the pointer that carries the multi-repo anchor across a server
57
+ * restart. It holds one line: the absolute path of the run's state file. */
58
+ export const RUN_STATE_POINTER_FILE_NAME = "run-state-path";
59
+ function runStatePointerFile() {
60
+ const runDir = runArtifactDir();
61
+ return runDir ? path.join(runDir, RUN_STATE_POINTER_FILE_NAME) : undefined;
62
+ }
63
+ /** The anchored path a previous process wrote, or undefined. A pointer naming a
64
+ * file that is gone is deleted rather than returned: the run it belonged to is
65
+ * over, and a stale path would send this stage to the wrong file. */
66
+ function readRunStatePointer() {
67
+ const pointer = runStatePointerFile();
68
+ if (!pointer)
69
+ return undefined;
70
+ let pointed;
71
+ try {
72
+ pointed = fs.readFileSync(pointer, "utf-8").trim();
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ if (pointed && fs.existsSync(pointed))
78
+ return pointed;
79
+ try {
80
+ fs.unlinkSync(pointer);
81
+ }
82
+ catch {
83
+ // Nothing depends on the delete succeeding; the path is unusable either way.
84
+ }
85
+ return undefined;
86
+ }
57
87
  /**
58
88
  * Directory holding this run's Skyramp artifacts — the state file, and the report written
59
89
  * beside it (SKYR-4147) — or undefined outside a run.
@@ -62,15 +92,13 @@ export const RUN_STATE_FILE_NAME = "analyze-changes-state.json";
62
92
  * work it out from the environment on their own. So neither has to tell the other where
63
93
  * the run's files live, and the path never passes through the model.
64
94
  *
65
- * The GitHub Action is the only caller customers run, and GitHub sets `RUNNER_TEMP` for
66
- * it; the eval harness sets it itself. Nothing customers run is on another CI system, so
67
- * the GitHub-specific name is only a naming problem — it does not break anything today.
68
- * If a caller on another CI system is added, rename this then, or let it read that
69
- * system's variable. Do not add a second variable for a caller that does not exist.
95
+ * The GitHub Action is the only caller customers run and GitHub sets `RUNNER_TEMP` for it;
96
+ * the eval harness sets it itself. If a caller on another CI system is added, rename this
97
+ * then, or let it read that system's variable. Do not add a second variable for a caller
98
+ * that does not exist.
70
99
  *
71
- * Kept in one place for the same reason `RUN_STATE_FILE_NAME` is: the directory used to be
72
- * written out at three call sites, so only the filename was safe from the three copies
73
- * drifting apart. testbot keeps its matching copy in one place too, in `skyrampTempDir()`.
100
+ * Kept in ONE place, like `RUN_STATE_FILE_NAME`: written out per call site, the copies
101
+ * drift. testbot keeps its matching copy in `skyrampTempDir()`.
74
102
  */
75
103
  export function runArtifactDir() {
76
104
  const runnerTemp = process.env.RUNNER_TEMP?.trim();
@@ -78,6 +106,21 @@ export function runArtifactDir() {
78
106
  }
79
107
  export function setActiveRunStatePath(stateFilePath) {
80
108
  _activeRunStatePath = stateFilePath;
109
+ const pointer = runStatePointerFile();
110
+ if (!pointer)
111
+ return;
112
+ try {
113
+ fs.mkdirSync(path.dirname(pointer), { recursive: true });
114
+ fs.writeFileSync(pointer, stateFilePath, "utf-8");
115
+ }
116
+ catch (error) {
117
+ // The in-memory anchor still holds for this process, so this stage works
118
+ // either way. Only a later stage loses the path.
119
+ logger.warning("Could not write the run state pointer", {
120
+ pointer,
121
+ error: error instanceof Error ? error.message : String(error),
122
+ });
123
+ }
81
124
  }
82
125
  export function getActiveRunStatePath() {
83
126
  return _activeRunStatePath;
@@ -85,21 +128,30 @@ export function getActiveRunStatePath() {
85
128
  /** Reset the run anchor — for tests, and as a safety hook between runs. */
86
129
  export function clearActiveRunStatePath() {
87
130
  _activeRunStatePath = undefined;
131
+ const pointer = runStatePointerFile();
132
+ if (!pointer)
133
+ return;
134
+ try {
135
+ fs.unlinkSync(pointer);
136
+ }
137
+ catch {
138
+ // Absent already, which is the state this asks for.
139
+ }
88
140
  }
89
141
  /**
90
142
  * The run's state-file path, for tools that need to read or amend it without the
91
143
  * agent naming it.
92
144
  *
93
145
  * `setActiveRunStatePath` is only called by skyramp_analyze_changes on multi-repo
94
- * runs (the ones carrying a `repository` param), but a single-repo Testbot run's
95
- * path is still deterministic in CI: the StateManager constructor above roots
96
- * "analysis" state under `runArtifactDir()`. Falling back to that covers
97
- * single-repo runs without a new anchor mechanism. Standalone/IDE usage has no
98
- * RUNNER_TEMP and resolves to undefined — correct, since there is no run state.
146
+ * runs (the ones carrying a `repository` param). It is read back two ways: from
147
+ * memory in this process, and from the pointer file a previous stage of the same
148
+ * run wrote — a staged run starts a fresh server per stage, so memory alone would
149
+ * send later stages to the wrong file.
99
150
  *
100
- * Lives here rather than in each caller so the convention has one home: this is
101
- * the module that decides where the file goes, so it is the module that should
102
- * decide where to look for it.
151
+ * A single-repo Testbot run's path is still deterministic in CI: the StateManager
152
+ * constructor above roots "analysis" state under `runArtifactDir()`. Falling back
153
+ * to that covers single-repo runs. Standalone/IDE usage has no RUNNER_TEMP and
154
+ * resolves to undefined — correct, since there is no run state.
103
155
  */
104
156
  export function resolveRunStatePath(explicitPath) {
105
157
  if (explicitPath)
@@ -107,6 +159,9 @@ export function resolveRunStatePath(explicitPath) {
107
159
  const anchored = getActiveRunStatePath();
108
160
  if (anchored)
109
161
  return anchored;
162
+ const pointed = readRunStatePointer();
163
+ if (pointed)
164
+ return pointed;
110
165
  const runDir = runArtifactDir();
111
166
  if (runDir) {
112
167
  return path.join(runDir, RUN_STATE_FILE_NAME);
@@ -124,7 +179,7 @@ export function resolveRunStatePath(explicitPath) {
124
179
  * write to a file that holds no run data is already a no-op.
125
180
  *
126
181
  * Returns the path rather than a boolean so a caller can also key per-run state on
127
- * it, and lives here because this is the module that decides where the file goes.
182
+ * it. It lives here because this is the module that decides where the file goes.
128
183
  */
129
184
  export function currentRunStateFile() {
130
185
  const stateFile = resolveRunStatePath();
@@ -187,7 +242,7 @@ export function hasSessionData(sessionId) {
187
242
  * and wrap it properly.
188
243
  */
189
244
  export function normalizeRecommendationState(data) {
190
- if (!data.analysis && data.apiEndpoints) {
245
+ if (!data.analysis && data.metadata) {
191
246
  logger.info("Detected unwrapped RepositoryAnalysis — adapting");
192
247
  return {
193
248
  repositoryPath: data.repositoryPath ||
@@ -214,6 +269,44 @@ const STATE_FILE_PREFIXES = {
214
269
  * - analysis: Test maintenance workflow (discovery, drift, execution, health)
215
270
  * - recommendation: Test recommendation workflow (analyze repo, map tests, recommendations)
216
271
  */
272
+ /** The run-wide root fields no analysis payload owns. Read by `writeData` so a
273
+ * rebuild of the root cannot delete what another tool wrote there. */
274
+ /** Write through a temporary file and rename over the target: `writeFile` truncates
275
+ * then writes, and both readers THROW on a parse failure. */
276
+ async function writeFileAtomically(target, contents) {
277
+ // `rename` needs only a writable DIRECTORY, so it would replace a read-only
278
+ // state file a plain write refuses. Two callers depend on that refusal.
279
+ try {
280
+ await fs.promises.access(target, fs.constants.W_OK);
281
+ }
282
+ catch (error) {
283
+ if (error?.code !== "ENOENT")
284
+ throw error;
285
+ }
286
+ // Per CALL, not per process: the run is ONE process, so two writes racing
287
+ // inside it share a pid, interleave their bytes and publish unparseable JSON.
288
+ const temp = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
289
+ try {
290
+ await fs.promises.writeFile(temp, contents, "utf-8");
291
+ await fs.promises.rename(temp, target);
292
+ }
293
+ catch (error) {
294
+ await fs.promises.rm(temp, { force: true }).catch(() => undefined);
295
+ throw error;
296
+ }
297
+ }
298
+ const ROOT_FIELDS_OWNED_ELSEWHERE = ["plan", "registrationCount", "reportObjections"];
299
+ function carryForwardRootFields(data, existing) {
300
+ if (!existing)
301
+ return {};
302
+ const carried = {};
303
+ for (const field of ROOT_FIELDS_OWNED_ELSEWHERE) {
304
+ const incoming = data?.[field];
305
+ if (incoming === undefined && existing[field] !== undefined)
306
+ carried[field] = existing[field];
307
+ }
308
+ return carried;
309
+ }
217
310
  export class StateManager {
218
311
  stateFile;
219
312
  sessionId;
@@ -318,13 +411,16 @@ export class StateManager {
318
411
  // rewriting the primary (root) payload preserves the multi-repo sections.
319
412
  let existingMetadata;
320
413
  let existingRelated;
414
+ let existingRoot;
321
415
  if (this.exists()) {
322
416
  const existing = await this.readFullState();
323
417
  existingMetadata = existing?.metadata;
324
418
  existingRelated = existing?.relatedRepos;
419
+ existingRoot = existing ?? undefined;
325
420
  }
326
421
  const state = {
327
422
  ...data,
423
+ ...carryForwardRootFields(data, existingRoot),
328
424
  metadata: {
329
425
  sessionId: this.sessionId,
330
426
  stateType: this.stateType,
@@ -343,7 +439,7 @@ export class StateManager {
343
439
  await fs.promises.mkdir(path.dirname(this.stateFile), {
344
440
  recursive: true,
345
441
  });
346
- await fs.promises.writeFile(this.stateFile, JSON.stringify(state, null, 2), "utf-8");
442
+ await writeFileAtomically(this.stateFile, JSON.stringify(state, null, 2));
347
443
  logger.debug(`Wrote data to state file: ${this.stateFile}`);
348
444
  }
349
445
  catch (error) {
@@ -359,16 +455,18 @@ export class StateManager {
359
455
  // matching the root's metadata.repo) targets the PRIMARY section, so all
360
456
  // single-repo behavior is unchanged.
361
457
  // -----------------------------------------------------------------------
362
- /** Is `repo` the primary section? Primary when `repo` is falsy (single-repo /
363
- * legacy callers), when no state file exists yet (the FIRST write of a run is
364
- * the primary — the testbot prompt mandates the primary is analyzed first), or
365
- * when `repo` matches the `repo` recorded on the root metadata. A non-empty
366
- * `repo` that doesn't match an already-recorded root is a RELATED repo. */
367
- async isPrimaryRepo(repo) {
458
+ /** Is `repo` the primary section? By NAME when the caller declares one: the run
459
+ * says which repo is primary, so whichever call lands first cannot decide it.
460
+ * Otherwise the legacy rule — falsy `repo` (single-repo callers), the first
461
+ * write of the run, or a `repo` matching the root metadata (SKYR-4333: a
462
+ * docs-only primary diff let the related repo write first and take the root). */
463
+ async isPrimaryRepo(repo, primaryRepo) {
368
464
  if (!repo)
369
465
  return true;
370
- // First write of the run is the primary (the prompt mandates the primary is
371
- // analyzed first), even when it carries an explicit owner/repo.
466
+ if (primaryRepo)
467
+ return repo === primaryRepo;
468
+ // No primary declared: the first write of the run is the primary, even when
469
+ // it carries an explicit owner/repo.
372
470
  if (!this.exists())
373
471
  return true;
374
472
  const full = await this.readFullState();
@@ -379,13 +477,18 @@ export class StateManager {
379
477
  }
380
478
  /**
381
479
  * Write one repo's analysis into the run-scoped state file. The primary repo
382
- * (repo absent, or matching the root's recorded repo, or the first write of the
383
- * run) is written to the root via writeData(); a related repo is upserted into
480
+ * (repo absent, matching the declared `primaryRepo`, matching the root's
481
+ * recorded repo, or the first write of a run that declares no primary) is
482
+ * written to the root via writeData(); a related repo is upserted into
384
483
  * `relatedRepos[repo]`, preserving the primary + other sections. Used by
385
484
  * skyramp_analyze_changes — every repo in the run writes the SAME stateFile path.
386
485
  */
486
+ /** Fields another tool owns, carried forward when this caller's payload omits
487
+ * them: `writeData` rebuilds the root from its payload, and an analyze call that
488
+ * did so deleted the plan the agent had registered. NAMED rather than "keep
489
+ * everything omitted", which would resurrect fields a re-analysis clears. */
387
490
  async writeRepoData(data, options) {
388
- if (await this.isPrimaryRepo(options.repo)) {
491
+ if (await this.isPrimaryRepo(options.repo, options.primaryRepo)) {
389
492
  await this.writeData(data, {
390
493
  repositoryPath: options.repositoryPath,
391
494
  step: options.step,
@@ -395,14 +498,17 @@ export class StateManager {
395
498
  });
396
499
  return;
397
500
  }
398
- // Related repo: read the current file (must already exist — primary is
399
- // analyzed first), upsert this repo's section, write back.
501
+ // Related repo: upsert this repo's section into the current file. The file
502
+ // need not exist yet — with a declared primary a related repo may write
503
+ // first, and then the root is created EMPTY and tagged with the primary's
504
+ // name, so the primary's own call still lands at the root when it arrives.
400
505
  const existing = (await this.readFullState());
401
506
  const base = existing ??
402
507
  {
403
508
  metadata: {
404
509
  sessionId: this.sessionId,
405
510
  stateType: this.stateType,
511
+ repository: options.primaryRepo,
406
512
  createdAt: new Date().toISOString(),
407
513
  updatedAt: new Date().toISOString(),
408
514
  },
@@ -421,7 +527,7 @@ export class StateManager {
421
527
  base.relatedRepos = relatedRepos;
422
528
  base.metadata = { ...base.metadata, updatedAt: new Date().toISOString() };
423
529
  await fs.promises.mkdir(path.dirname(this.stateFile), { recursive: true });
424
- await fs.promises.writeFile(this.stateFile, JSON.stringify(base, null, 2), "utf-8");
530
+ await writeFileAtomically(this.stateFile, JSON.stringify(base, null, 2));
425
531
  logger.debug(`Wrote ${options.repo} section to state file: ${this.stateFile}`);
426
532
  }
427
533
  /**
@@ -494,22 +600,6 @@ export class StateManager {
494
600
  logger.debug(`Deleted state file: ${this.stateFile}`);
495
601
  }
496
602
  }
497
- async getSize() {
498
- if (!this.exists()) {
499
- return 0;
500
- }
501
- const stats = await fs.promises.stat(this.stateFile);
502
- return stats.size;
503
- }
504
- async getSizeFormatted() {
505
- const bytes = await this.getSize();
506
- if (bytes === 0)
507
- return "0 B";
508
- const k = 1024;
509
- const sizes = ["B", "KB", "MB", "GB"];
510
- const i = Math.floor(Math.log(bytes) / Math.log(k));
511
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
512
- }
513
603
  /**
514
604
  * Cleanup old state files
515
605
  * @param maxAgeHours Maximum age in hours
@@ -288,7 +288,7 @@ function lintArrayDepth(commentless) {
288
288
  rule: "array-missing-next-index-check",
289
289
  severity: "warn",
290
290
  message: `Items of \`${base}\` are asserted up to index ${maxIdx}, but index ${maxIdx + 1} is never asserted absent.`,
291
- remediation: `Add an absence assertion for the index after the last expected item (e.g. \`getValue(response, "${nextPath}")\` is undefined / None).`,
291
+ remediation: `Add an absence assertion for the index after the last expected item (e.g. \`getValue(response, "${nextPath}")\` is null / None).`,
292
292
  });
293
293
  }
294
294
  }
@@ -15,6 +15,8 @@ const EXACT_MATCHERS = new Set([
15
15
  "toEqual",
16
16
  "toStrictEqual",
17
17
  "toBeUndefined",
18
+ "toBeNull",
19
+ "to_be_null",
18
20
  "to_have_text",
19
21
  "to_have_value",
20
22
  "to_have_attribute",
@@ -47,6 +49,8 @@ const PARTIAL_MATCHERS = new Set([
47
49
  "to_be_enabled",
48
50
  "to_be_hidden",
49
51
  ]);
52
+ /** Absence matchers whose tier depends on whether the chain negates them. */
53
+ const NULL_MATCHERS = new Set(["toBeNull", "to_be_null"]);
50
54
  /** Chain links to skip between `expect(...)` and the matcher. */
51
55
  const CHAIN_LINKS = new Set([
52
56
  "not",
@@ -75,9 +79,14 @@ export function detectAssertionLanguage(testFile) {
75
79
  return undefined;
76
80
  }
77
81
  }
78
- function matcherWeight(matcher) {
82
+ function matcherWeight(matcher, links = []) {
79
83
  if (matcher === undefined)
80
84
  return 1;
85
+ // `not.toBeNull()` is the weak existence idiom the rubric bans, while a bare
86
+ // `toBeNull()` is the exact absence assertion it asks for. Same matcher, so
87
+ // only the chain separates them.
88
+ if (NULL_MATCHERS.has(matcher) && links.includes("not"))
89
+ return 1;
81
90
  if (EXACT_MATCHERS.has(matcher))
82
91
  return 3;
83
92
  if (PARTIAL_MATCHERS.has(matcher))
@@ -85,12 +94,45 @@ function matcherWeight(matcher) {
85
94
  // Unknown matchers weigh 1, never 0 — any added assertion raises the score.
86
95
  return 1;
87
96
  }
88
- function fingerprintOf(raw, start, end) {
89
- return raw
90
- .slice(start, end)
91
- .replace(/\s+/g, " ")
92
- .trim()
93
- .slice(0, FINGERPRINT_CHAR_CAP);
97
+ function fingerprintOf(statement) {
98
+ return statement.replace(/\s+/g, " ").trim().slice(0, FINGERPRINT_CHAR_CAP);
99
+ }
100
+ /** The first comma at paren/bracket/brace depth 0 and outside any quoted literal,
101
+ * or -1. A call's own arguments sit deeper, so `f(a, b)` yields no split, and a
102
+ * comma inside `'…'`, `"…"` or `` `…` `` is text, not a separator. Callers pass
103
+ * raw source here (a fingerprint), where quotes are still present; the python
104
+ * scanner uses the same two quote forms, so one scan covers both dialects. */
105
+ function topLevelCommaIndex(text) {
106
+ let depth = 0;
107
+ for (let i = 0; i < text.length; i++) {
108
+ const c = text[i];
109
+ if (c === "'" || c === '"' || c === "`") {
110
+ // Skip to the matching close quote. An unterminated literal ends the scan,
111
+ // so a truncated fingerprint yields no split rather than a bogus one.
112
+ i++;
113
+ while (i < text.length && text[i] !== c) {
114
+ if (text[i] === "\\")
115
+ i++;
116
+ i++;
117
+ }
118
+ if (i >= text.length)
119
+ return -1;
120
+ }
121
+ else if (c === "(" || c === "[" || c === "{")
122
+ depth++;
123
+ else if (c === ")" || c === "]" || c === "}")
124
+ depth--;
125
+ else if (depth === 0 && c === ",")
126
+ return i;
127
+ }
128
+ return -1;
129
+ }
130
+ /** An argument list without whatever follows its first argument. Every assertion
131
+ * form here takes the subject first and an optional MESSAGE after it, and the
132
+ * message describes the assertion rather than being part of what it checks. */
133
+ function firstArgument(args) {
134
+ const comma = topLevelCommaIndex(args);
135
+ return comma === -1 ? args : args.slice(0, comma);
94
136
  }
95
137
  function startOfLine(src, offset) {
96
138
  const nl = src.lastIndexOf("\n", offset - 1);
@@ -107,39 +149,51 @@ function expectSites(raw, stripped) {
107
149
  const argClose = balancedCloseIndex(stripped, openIdx);
108
150
  if (argClose === -1)
109
151
  continue;
152
+ // `expect(actual, "message")` takes the message second. Cut it at the first
153
+ // top-level comma of the STRIPPED args, where a comma inside a string or a
154
+ // template literal has already been blanked and so is not one.
155
+ const commaInArgs = topLevelCommaIndex(stripped.slice(openIdx + 1, argClose));
156
+ const actualEnd = commaInArgs === -1 ? argClose : openIdx + 1 + commaInArgs;
157
+ const actual = raw.slice(openIdx + 1, actualEnd);
110
158
  // Walk the chain past link members (.not / .resolves / …) to the matcher.
111
159
  let cursor = argClose + 1;
112
160
  let matcher;
161
+ const links = [];
113
162
  for (;;) {
114
163
  const link = /^\s*\.\s*([A-Za-z_]\w*)/.exec(stripped.slice(cursor));
115
164
  if (!link)
116
165
  break;
117
166
  cursor += link[0].length;
118
- if (CHAIN_LINKS.has(link[1]))
167
+ if (CHAIN_LINKS.has(link[1])) {
168
+ links.push(link[1]);
119
169
  continue;
170
+ }
120
171
  matcher = link[1];
121
172
  break;
122
173
  }
123
- // Statement extent: start of the site's line through the matcher call's
124
- // closing paren (or the expect close when there is no chained call).
125
- let stmtEnd = argClose + 1;
174
+ let matcherArgs;
126
175
  if (matcher !== undefined) {
127
176
  const matcherOpen = /^\s*\(/.exec(stripped.slice(cursor));
128
177
  if (matcherOpen) {
129
- const close = balancedCloseIndex(stripped, cursor + matcherOpen[0].length - 1);
178
+ const open = cursor + matcherOpen[0].length - 1;
179
+ const close = balancedCloseIndex(stripped, open);
130
180
  if (close !== -1)
131
- stmtEnd = close + 1;
132
- }
133
- else {
134
- stmtEnd = cursor;
181
+ matcherArgs = raw.slice(open + 1, close);
135
182
  }
136
183
  }
137
- const stmtStart = startOfLine(raw, m.index);
184
+ // Built from parts rather than sliced off the line, so a reworded message
185
+ // and any code sharing the line are both outside the key. Run 34073154568
186
+ // improved a message, was told the assertion was removed, and reverted the
187
+ // wording to satisfy the check.
188
+ const soft = /\.\s*soft/.test(m[0]) ? ".soft" : "";
138
189
  sites.push({
139
190
  line: lineOfOffset(stripped, m.index),
140
191
  matcher,
141
- weight: matcherWeight(matcher),
142
- fingerprint: fingerprintOf(raw, stmtStart, stmtEnd),
192
+ weight: matcherWeight(matcher, links),
193
+ fingerprint: fingerprintOf(`expect${soft}(${actual})` +
194
+ links.map((link) => `.${link}`).join("") +
195
+ (matcher === undefined ? "" : `.${matcher}`) +
196
+ (matcherArgs === undefined ? "" : `(${matcherArgs})`)),
143
197
  });
144
198
  re.lastIndex = Math.max(re.lastIndex, argClose + 1);
145
199
  }
@@ -149,6 +203,9 @@ function expectSites(raw, stripped) {
149
203
  function pythonAssertWeight(stmt) {
150
204
  if (stmt.includes("==") ||
151
205
  stmt.includes("!=") ||
206
+ // `is None` is the python spelling of `toBeNull` — an exact absence check.
207
+ // `is not None` stays weak: it is the existence idiom the rubric bans.
208
+ /\bis\s+None\b/.test(stmt) ||
152
209
  // Same tier rule (and same spelling tolerance) as the node-assert scanner
153
210
  // — the two had drifted, scoring an identical schema check 3 in JS and 1
154
211
  // in python when spelled checkSchema.
@@ -182,7 +239,7 @@ function pythonAssertSites(raw, stripped) {
182
239
  sites.push({
183
240
  line: i + 1,
184
241
  weight: pythonAssertWeight(stmt),
185
- fingerprint: fingerprintOf(raw, offset + (line.length - line.trimStart().length), stmtEnd),
242
+ fingerprint: fingerprintOf(raw.slice(offset + (line.length - line.trimStart().length), stmtEnd)),
186
243
  });
187
244
  i = last;
188
245
  offset = stmtEnd + 1;
@@ -220,7 +277,7 @@ function callSites(raw, stripped, re, matcherOf, weightOf) {
220
277
  line: lineOfOffset(stripped, m.index),
221
278
  matcher: matcherOf(m),
222
279
  weight: weightOf(m, args),
223
- fingerprint: fingerprintOf(raw, startOfLine(raw, m.index), close === -1 ? m.index + m[0].length : close + 1),
280
+ fingerprint: fingerprintOf(raw.slice(startOfLine(raw, m.index), close === -1 ? m.index + m[0].length : close + 1)),
224
281
  });
225
282
  }
226
283
  return sites;
@@ -256,7 +313,12 @@ export function subjectOf(fingerprint) {
256
313
  const close = balancedCloseIndex(fingerprint, openIdx);
257
314
  if (close === -1)
258
315
  return undefined;
259
- return fingerprint.slice(openIdx + 1, close).replace(/\s+/g, "");
316
+ // The message went into the subject too, so a reworded message lost the
317
+ // correlation that tells a REPLACED assertion from a REMOVED one. Dropping
318
+ // it here is also what lets a baseline written before this change still
319
+ // correlate: its fingerprint no longer matches, but its subject does.
320
+ const subject = firstArgument(fingerprint.slice(openIdx + 1, close)).replace(/\s+/g, "");
321
+ return subject.length > 0 ? subject : undefined;
260
322
  }
261
323
  // Node assert: the subject is the first top-level argument
262
324
  // (`assert.strictEqual(SUBJECT, expected, msg?)`, `assert.ok(SUBJECT, msg?)`).
@@ -265,20 +327,7 @@ export function subjectOf(fingerprint) {
265
327
  const openIdx = nodeAssert.index + nodeAssert[0].length - 1;
266
328
  const close = balancedCloseIndex(fingerprint, openIdx);
267
329
  const args = fingerprint.slice(openIdx + 1, close === -1 ? undefined : close);
268
- let depth = 0;
269
- let end = args.length;
270
- for (let i = 0; i < args.length; i++) {
271
- const c = args[i];
272
- if (c === "(" || c === "[" || c === "{")
273
- depth++;
274
- else if (c === ")" || c === "]" || c === "}")
275
- depth--;
276
- else if (depth === 0 && c === ",") {
277
- end = i;
278
- break;
279
- }
280
- }
281
- const subject = args.slice(0, end).replace(/\s+/g, "");
330
+ const subject = firstArgument(args).replace(/\s+/g, "");
282
331
  return subject.length > 0 ? subject : undefined;
283
332
  }
284
333
  const assertPrefix = /^assert\s+/.exec(fingerprint);
@@ -1,8 +1,3 @@
1
1
  import type { AssertionLanguage } from "./metrics.js";
2
2
  import { type LintFinding, type LintOptions } from "./lint-types.js";
3
- /** UI (Playwright) lints. Python (playwright-python) UI specs deliberately get
4
- * the site-scoped lints only: the structural rules' patterns and accept-lists
5
- * are JS/TS-specific (imports, waitForResponse shapes, expect matcher accept
6
- * forms), and porting them without a python golden corpus to validate against
7
- * would recreate the false-block class review round 2 removed. Pinned by test. */
8
3
  export declare function lintUiSpec(raw: string, language: AssertionLanguage, opts?: LintOptions): LintFinding[];
@@ -222,6 +222,36 @@ function lintUnassertedTrailingAction(stripped) {
222
222
  * are JS/TS-specific (imports, waitForResponse shapes, expect matcher accept
223
223
  * forms), and porting them without a python golden corpus to validate against
224
224
  * would recreate the false-block class review round 2 removed. Pinned by test. */
225
+ // SKYR-4305 — a removal guard (`toBeHidden()` / `not.toBeVisible()` on an element a
226
+ // PR removed) is only meaningful next to a positive assertion on the same page: alone
227
+ // it passes on a blank page, a 404, a typo'd testid, or a page the element never
228
+ // rendered on. `toBeHidden` is a PARTIAL_MATCHER (weight 2), so the strength gate does
229
+ // not catch a negative-only spec; this lint does.
230
+ const ABSENCE_ASSERT_RE = /\.\s*(?:toBeHidden|to_be_hidden)\s*\(|\.\s*not\s*\.\s*(?:toBeVisible|toBeAttached)\s*\(|\.\s*not_to_be_(?:visible|attached)\s*\(/g;
231
+ // Only Playwright LOCATOR/page matchers count as the positive half of the pair —
232
+ // they prove something rendered. Generic Jest matchers (toBe/toEqual/toHaveLength)
233
+ // are deliberately absent: `expect(resp.status()).toBe(200)` next to a lone
234
+ // toBeHidden() proves nothing about the page.
235
+ const POSITIVE_ASSERT_RE = /\.\s*(?:toHaveText|toContainText|toHaveValue|toHaveValues|toHaveAttribute|toHaveCount|toHaveURL|toHaveTitle|toHaveId|toHaveClass|toHaveCSS|toHaveJSProperty|toHaveAccessibleName|toHaveAccessibleDescription|toHaveRole|toHaveScreenshot|toBeVisible|toBeChecked|toBeEnabled|toBeDisabled|toBeEditable|toBeEmpty|toBeFocused|toBeInViewport|toContainClass|to_have_text|to_contain_text|to_have_value|to_have_values|to_have_attribute|to_have_count|to_have_url|to_have_title|to_have_id|to_have_class|to_have_css|to_have_js_property|to_have_accessible_name|to_have_accessible_description|to_have_role|to_have_screenshot|to_be_visible|to_be_checked|to_be_enabled|to_be_disabled|to_be_editable|to_be_empty|to_be_focused|to_be_in_viewport|to_contain_class)\s*\(/;
236
+ function lintNegativeOnlySpec(commentless) {
237
+ ABSENCE_ASSERT_RE.lastIndex = 0;
238
+ const first = ABSENCE_ASSERT_RE.exec(commentless);
239
+ if (!first)
240
+ return [];
241
+ // Strip the absence assertions themselves, then look for a Playwright matcher
242
+ // on a rendered element (generic Jest matchers do not count — see POSITIVE_ASSERT_RE).
243
+ const remainder = commentless.replace(ABSENCE_ASSERT_RE, "");
244
+ ABSENCE_ASSERT_RE.lastIndex = 0;
245
+ if (POSITIVE_ASSERT_RE.test(remainder))
246
+ return [];
247
+ return [{
248
+ rule: "negative-only-spec",
249
+ severity: "hard",
250
+ line: lineOfOffset(commentless, first.index),
251
+ message: "Every locator assertion in this spec asserts absence (toBeHidden / not.toBeVisible); nothing proves the page rendered, so it passes on a blank page, a 404 or a typo'd selector.",
252
+ remediation: "Add at least one positive assertion on a retained element of the same page (toHaveText / toHaveValue / toHaveCount / toHaveURL) next to the absence assertions.",
253
+ }];
254
+ }
225
255
  export function lintUiSpec(raw, language, opts) {
226
256
  if (language === "java")
227
257
  return [];
@@ -240,5 +270,7 @@ export function lintUiSpec(raw, language, opts) {
240
270
  findings.push(...lintUnassertedTrailingAction(stripped));
241
271
  }
242
272
  findings.push(...lintTautologies(commentless, opts));
273
+ if (!maintenance)
274
+ findings.push(...lintNegativeOnlySpec(commentless));
243
275
  return findings;
244
276
  }