doc-detective 4.38.0 → 4.41.4

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 (234) hide show
  1. package/README.md +4 -4
  2. package/dist/agents/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/agents/adapters/claude-code.js +1 -2
  4. package/dist/agents/adapters/claude-code.js.map +1 -1
  5. package/dist/agents/adapters/codex.d.ts.map +1 -1
  6. package/dist/agents/adapters/codex.js +3 -4
  7. package/dist/agents/adapters/codex.js.map +1 -1
  8. package/dist/agents/adapters/copilot-cli.d.ts.map +1 -1
  9. package/dist/agents/adapters/copilot-cli.js +1 -2
  10. package/dist/agents/adapters/copilot-cli.js.map +1 -1
  11. package/dist/agents/adapters/gemini-cli.d.ts.map +1 -1
  12. package/dist/agents/adapters/gemini-cli.js +1 -2
  13. package/dist/agents/adapters/gemini-cli.js.map +1 -1
  14. package/dist/agents/adapters/opencode.d.ts.map +1 -1
  15. package/dist/agents/adapters/opencode.js +1 -2
  16. package/dist/agents/adapters/opencode.js.map +1 -1
  17. package/dist/agents/adapters/qwen-code.d.ts.map +1 -1
  18. package/dist/agents/adapters/qwen-code.js +1 -2
  19. package/dist/agents/adapters/qwen-code.js.map +1 -1
  20. package/dist/agents/fetcher.d.ts.map +1 -1
  21. package/dist/agents/fetcher.js +1 -2
  22. package/dist/agents/fetcher.js.map +1 -1
  23. package/dist/common/src/ajv.d.ts +30 -0
  24. package/dist/common/src/ajv.d.ts.map +1 -0
  25. package/dist/common/src/ajv.js +131 -0
  26. package/dist/common/src/ajv.js.map +1 -0
  27. package/dist/common/src/detectTests.d.ts +2 -18
  28. package/dist/common/src/detectTests.d.ts.map +1 -1
  29. package/dist/common/src/detectTests.js +8 -77
  30. package/dist/common/src/detectTests.js.map +1 -1
  31. package/dist/common/src/schemas/referenced.d.ts +12 -0
  32. package/dist/common/src/schemas/referenced.d.ts.map +1 -0
  33. package/dist/common/src/schemas/referenced.js +10 -0
  34. package/dist/common/src/schemas/referenced.js.map +1 -0
  35. package/dist/common/src/schemas/schemas.json +2 -2
  36. package/dist/common/src/schemas/schemasReferenced.json +14871 -0
  37. package/dist/common/src/schemas/validators.cjs +509 -0
  38. package/dist/common/src/schemas/validatorsCheck.cjs +2 -0
  39. package/dist/common/src/textUtils.d.ts +25 -0
  40. package/dist/common/src/textUtils.d.ts.map +1 -0
  41. package/dist/common/src/textUtils.js +84 -0
  42. package/dist/common/src/textUtils.js.map +1 -0
  43. package/dist/common/src/types/generated/annotate_v3.d.ts.map +1 -1
  44. package/dist/common/src/types/generated/annotation_v3.d.ts.map +1 -1
  45. package/dist/common/src/types/generated/click_v3.d.ts +153 -0
  46. package/dist/common/src/types/generated/click_v3.d.ts.map +1 -1
  47. package/dist/common/src/types/generated/config_v3.d.ts +13753 -207
  48. package/dist/common/src/types/generated/config_v3.d.ts.map +1 -1
  49. package/dist/common/src/types/generated/dragAndDrop_v3.d.ts +72 -0
  50. package/dist/common/src/types/generated/dragAndDrop_v3.d.ts.map +1 -1
  51. package/dist/common/src/types/generated/find_v3.d.ts +632 -0
  52. package/dist/common/src/types/generated/find_v3.d.ts.map +1 -1
  53. package/dist/common/src/types/generated/goTo_v3.d.ts +31 -0
  54. package/dist/common/src/types/generated/goTo_v3.d.ts.map +1 -1
  55. package/dist/common/src/types/generated/httpRequest_v3.d.ts +181 -0
  56. package/dist/common/src/types/generated/httpRequest_v3.d.ts.map +1 -1
  57. package/dist/common/src/types/generated/loadCookie_v3.d.ts +30 -0
  58. package/dist/common/src/types/generated/loadCookie_v3.d.ts.map +1 -1
  59. package/dist/common/src/types/generated/report_v3.d.ts +28234 -321
  60. package/dist/common/src/types/generated/report_v3.d.ts.map +1 -1
  61. package/dist/common/src/types/generated/resolvedTests_v3.d.ts +42281 -822
  62. package/dist/common/src/types/generated/resolvedTests_v3.d.ts.map +1 -1
  63. package/dist/common/src/types/generated/saveCookie_v3.d.ts +35 -0
  64. package/dist/common/src/types/generated/saveCookie_v3.d.ts.map +1 -1
  65. package/dist/common/src/types/generated/screenshot_v3.d.ts +45 -0
  66. package/dist/common/src/types/generated/screenshot_v3.d.ts.map +1 -1
  67. package/dist/common/src/types/generated/spec_v3.d.ts +28188 -275
  68. package/dist/common/src/types/generated/spec_v3.d.ts.map +1 -1
  69. package/dist/common/src/types/generated/startSurface_v3.d.ts +63 -1
  70. package/dist/common/src/types/generated/startSurface_v3.d.ts.map +1 -1
  71. package/dist/common/src/types/generated/step_v3.d.ts +5186 -523
  72. package/dist/common/src/types/generated/step_v3.d.ts.map +1 -1
  73. package/dist/common/src/types/generated/test_v3.d.ts +13268 -3582
  74. package/dist/common/src/types/generated/test_v3.d.ts.map +1 -1
  75. package/dist/common/src/types/generated/type_v3.d.ts +62 -0
  76. package/dist/common/src/types/generated/type_v3.d.ts.map +1 -1
  77. package/dist/common/src/types/generated/waitUntil_v3.d.ts +62 -0
  78. package/dist/common/src/types/generated/waitUntil_v3.d.ts.map +1 -1
  79. package/dist/common/src/validate.d.ts +1 -1
  80. package/dist/common/src/validate.d.ts.map +1 -1
  81. package/dist/common/src/validate.js +29 -75
  82. package/dist/common/src/validate.js.map +1 -1
  83. package/dist/core/appium.d.ts +3 -2
  84. package/dist/core/appium.d.ts.map +1 -1
  85. package/dist/core/appium.js +63 -89
  86. package/dist/core/appium.js.map +1 -1
  87. package/dist/core/appiumReuse.d.ts +43 -0
  88. package/dist/core/appiumReuse.d.ts.map +1 -0
  89. package/dist/core/appiumReuse.js +226 -0
  90. package/dist/core/appiumReuse.js.map +1 -0
  91. package/dist/core/config.d.ts +10 -2
  92. package/dist/core/config.d.ts.map +1 -1
  93. package/dist/core/config.js +37 -7
  94. package/dist/core/config.js.map +1 -1
  95. package/dist/core/detectTests.d.ts.map +1 -1
  96. package/dist/core/detectTests.js +51 -17
  97. package/dist/core/detectTests.js.map +1 -1
  98. package/dist/core/files.d.ts.map +1 -1
  99. package/dist/core/files.js +3 -4
  100. package/dist/core/files.js.map +1 -1
  101. package/dist/core/index.d.ts +2 -2
  102. package/dist/core/index.d.ts.map +1 -1
  103. package/dist/core/index.js +132 -21
  104. package/dist/core/index.js.map +1 -1
  105. package/dist/core/integrations/heretto.d.ts +1 -1
  106. package/dist/core/integrations/heretto.d.ts.map +1 -1
  107. package/dist/core/integrations/heretto.js +7 -6
  108. package/dist/core/integrations/heretto.js.map +1 -1
  109. package/dist/core/lazyYaml.d.ts +2 -0
  110. package/dist/core/lazyYaml.d.ts.map +1 -0
  111. package/dist/core/lazyYaml.js +13 -0
  112. package/dist/core/lazyYaml.js.map +1 -0
  113. package/dist/core/resolveTests.js +1 -1
  114. package/dist/core/resolveTests.js.map +1 -1
  115. package/dist/core/telem.d.ts +1 -1
  116. package/dist/core/telem.d.ts.map +1 -1
  117. package/dist/core/telem.js +2 -11
  118. package/dist/core/telem.js.map +1 -1
  119. package/dist/core/telemLite.d.ts +10 -0
  120. package/dist/core/telemLite.d.ts.map +1 -0
  121. package/dist/core/telemLite.js +101 -0
  122. package/dist/core/telemLite.js.map +1 -0
  123. package/dist/core/tests/androidEmulator.d.ts +67 -4
  124. package/dist/core/tests/androidEmulator.d.ts.map +1 -1
  125. package/dist/core/tests/androidEmulator.js +366 -37
  126. package/dist/core/tests/androidEmulator.js.map +1 -1
  127. package/dist/core/tests/appSurface.d.ts +25 -6
  128. package/dist/core/tests/appSurface.d.ts.map +1 -1
  129. package/dist/core/tests/appSurface.js +88 -52
  130. package/dist/core/tests/appSurface.js.map +1 -1
  131. package/dist/core/tests/browserSessions.d.ts +2 -1
  132. package/dist/core/tests/browserSessions.d.ts.map +1 -1
  133. package/dist/core/tests/browserSessions.js +7 -1
  134. package/dist/core/tests/browserSessions.js.map +1 -1
  135. package/dist/core/tests/checkLink.d.ts +1 -0
  136. package/dist/core/tests/checkLink.d.ts.map +1 -1
  137. package/dist/core/tests/checkLink.js +14 -5
  138. package/dist/core/tests/checkLink.js.map +1 -1
  139. package/dist/core/tests/closeSurface.d.ts.map +1 -1
  140. package/dist/core/tests/closeSurface.js +4 -3
  141. package/dist/core/tests/closeSurface.js.map +1 -1
  142. package/dist/core/tests/findStrategies.d.ts.map +1 -1
  143. package/dist/core/tests/findStrategies.js +153 -116
  144. package/dist/core/tests/findStrategies.js.map +1 -1
  145. package/dist/core/tests/geckoDriver.d.ts +16 -0
  146. package/dist/core/tests/geckoDriver.d.ts.map +1 -0
  147. package/dist/core/tests/geckoDriver.js +127 -0
  148. package/dist/core/tests/geckoDriver.js.map +1 -0
  149. package/dist/core/tests/goTo.d.ts.map +1 -1
  150. package/dist/core/tests/goTo.js +12 -33
  151. package/dist/core/tests/goTo.js.map +1 -1
  152. package/dist/core/tests/httpRequest.d.ts.map +1 -1
  153. package/dist/core/tests/httpRequest.js +4 -3
  154. package/dist/core/tests/httpRequest.js.map +1 -1
  155. package/dist/core/tests/iosSimulator.d.ts +4 -1
  156. package/dist/core/tests/iosSimulator.d.ts.map +1 -1
  157. package/dist/core/tests/iosSimulator.js +49 -1
  158. package/dist/core/tests/iosSimulator.js.map +1 -1
  159. package/dist/core/tests/mobileBrowser.d.ts +2 -1
  160. package/dist/core/tests/mobileBrowser.d.ts.map +1 -1
  161. package/dist/core/tests/mobileBrowser.js +5 -2
  162. package/dist/core/tests/mobileBrowser.js.map +1 -1
  163. package/dist/core/tests/runCode.d.ts +9 -1
  164. package/dist/core/tests/runCode.d.ts.map +1 -1
  165. package/dist/core/tests/runCode.js +58 -2
  166. package/dist/core/tests/runCode.js.map +1 -1
  167. package/dist/core/tests/saveScreenshot.d.ts +18 -2
  168. package/dist/core/tests/saveScreenshot.d.ts.map +1 -1
  169. package/dist/core/tests/saveScreenshot.js +159 -4
  170. package/dist/core/tests/saveScreenshot.js.map +1 -1
  171. package/dist/core/tests/startRecording.d.ts.map +1 -1
  172. package/dist/core/tests/startRecording.js +6 -4
  173. package/dist/core/tests/startRecording.js.map +1 -1
  174. package/dist/core/tests/startSurface.d.ts.map +1 -1
  175. package/dist/core/tests/startSurface.js +8 -2
  176. package/dist/core/tests/startSurface.js.map +1 -1
  177. package/dist/core/tests.d.ts +21 -7
  178. package/dist/core/tests.d.ts.map +1 -1
  179. package/dist/core/tests.js +372 -121
  180. package/dist/core/tests.js.map +1 -1
  181. package/dist/core/utils.d.ts +68 -3
  182. package/dist/core/utils.d.ts.map +1 -1
  183. package/dist/core/utils.js +551 -18
  184. package/dist/core/utils.js.map +1 -1
  185. package/dist/core/verification.d.ts +17 -0
  186. package/dist/core/verification.d.ts.map +1 -0
  187. package/dist/core/verification.js +850 -0
  188. package/dist/core/verification.js.map +1 -0
  189. package/dist/debug/appium.js +2 -2
  190. package/dist/debug/appium.js.map +1 -1
  191. package/dist/debug/findings.js +2 -2
  192. package/dist/debug/findings.js.map +1 -1
  193. package/dist/debug/tools.d.ts +8 -0
  194. package/dist/debug/tools.d.ts.map +1 -1
  195. package/dist/debug/tools.js +60 -22
  196. package/dist/debug/tools.js.map +1 -1
  197. package/dist/hints/context.d.ts.map +1 -1
  198. package/dist/hints/context.js +2 -2
  199. package/dist/hints/context.js.map +1 -1
  200. package/dist/index.cjs +25777 -4
  201. package/dist/reporters/htmlReporter.js +1 -1
  202. package/dist/runtime/androidInstaller.d.ts +45 -0
  203. package/dist/runtime/androidInstaller.d.ts.map +1 -1
  204. package/dist/runtime/androidInstaller.js +88 -5
  205. package/dist/runtime/androidInstaller.js.map +1 -1
  206. package/dist/runtime/browsers.d.ts +87 -7
  207. package/dist/runtime/browsers.d.ts.map +1 -1
  208. package/dist/runtime/browsers.js +308 -41
  209. package/dist/runtime/browsers.js.map +1 -1
  210. package/dist/runtime/cacheDir.d.ts +1 -1
  211. package/dist/runtime/cacheDir.d.ts.map +1 -1
  212. package/dist/runtime/cacheDir.js +5 -2
  213. package/dist/runtime/cacheDir.js.map +1 -1
  214. package/dist/runtime/heavyDeps.d.ts.map +1 -1
  215. package/dist/runtime/heavyDeps.js.map +1 -1
  216. package/dist/runtime/installCommand.d.ts.map +1 -1
  217. package/dist/runtime/installCommand.js +23 -0
  218. package/dist/runtime/installCommand.js.map +1 -1
  219. package/dist/runtime/loader.d.ts +13 -0
  220. package/dist/runtime/loader.d.ts.map +1 -1
  221. package/dist/runtime/loader.js +50 -0
  222. package/dist/runtime/loader.js.map +1 -1
  223. package/dist/runtime/wdaProducts.d.ts +12 -1
  224. package/dist/runtime/wdaProducts.d.ts.map +1 -1
  225. package/dist/runtime/wdaProducts.js +27 -2
  226. package/dist/runtime/wdaProducts.js.map +1 -1
  227. package/dist/runtime/windowsBash.d.ts +6 -1
  228. package/dist/runtime/windowsBash.d.ts.map +1 -1
  229. package/dist/runtime/windowsBash.js +45 -4
  230. package/dist/runtime/windowsBash.js.map +1 -1
  231. package/dist/utils.d.ts.map +1 -1
  232. package/dist/utils.js +19 -5
  233. package/dist/utils.js.map +1 -1
  234. package/package.json +43 -41
@@ -4,12 +4,20 @@ import path from "node:path";
4
4
  import crypto from "node:crypto";
5
5
  import dns from "node:dns/promises";
6
6
  import net from "node:net";
7
- import axios from "axios";
8
7
  import { spawn } from "node:child_process";
9
8
  import { loadHeavyDep, resolveHeavyDepPath, ensureRuntimeInstalled, } from "../runtime/loader.js";
10
9
  import { resolveWindowsBash as defaultResolveWindowsBash } from "../runtime/windowsBash.js";
11
10
  import { assertConptyAllocatable, ensurePtyBackendOnDisk, } from "./ptyWatchdog.js";
12
- export { outputResults, loadEnvs, log, logLevelEnabled, timestamp, getOrInitRunTimestamp, getRunOutputDir, runArchivesArtifacts, replaceEnvs, spawnCommand, spawnBackgroundCommand, spawnPtyBackgroundCommand, resolveShellName, resolveShellExecutable, shellSpawnEnv, waitForReady, waitForPort, waitForHttp, waitForStdio, waitForOutputMatch, inContainer, cleanTemp, calculateFractionalDifference, serializeBrowserResult, matchesExpectedOutput, fetchFile, isRelativeUrl, appendQueryParams, isDeviceWebContext, computeSettleCeiling, redactUrlForOutput, assertUrlHostIsPublic, sanitizeFilesystemName, compileFilter, isRetryableSessionError, isSessionAlive, isPageBroken, isPageUnnavigated, isInitialBlankDocument, classifyContextRetry, isTransientProcessInitError, matchesFilter, selectSpecsForRun, shouldFailRun, findFreePort, runConcurrent, createResourceRegistry, runResourceAware, rollUpResults, rollUpAssertions, createAppiumPool, evaluateContextRequirements, };
11
+ import { createRequire } from "node:module";
12
+ import { applyVerification, declarePage, VERIFIED_TOOL } from "./verification.js";
13
+ // axios (~70 modules with its deps) loads on first use rather than at CLI
14
+ // startup (see test/startup-modules.test.js). Cached, so hot paths pay the
15
+ // dynamic import once.
16
+ let axiosModule;
17
+ export function loadAxios() {
18
+ return (axiosModule ??= import("axios").then((m) => m.default));
19
+ }
20
+ export { outputResults, loadEnvs, log, logLevelEnabled, timestamp, getOrInitRunTimestamp, getRunOutputDir, runArchivesArtifacts, replaceEnvs, spawnCommand, spawnBackgroundCommand, spawnPtyBackgroundCommand, resolveShellName, resolveShellExecutable, shellSpawnEnv, waitForReady, waitForPort, waitForHttp, waitForStdio, waitForOutputMatch, inContainer, cleanTemp, calculateFractionalDifference, serializeBrowserResult, matchesExpectedOutput, fetchFile, isRelativeUrl, appendQueryParams, isDeviceWebContext, computeSettleCeiling, redactUrlForOutput, assertUrlHostIsPublic, sanitizeFilesystemName, compileFilter, isRetryableSessionError, isSessionAlive, isPageBroken, isPageUnnavigated, isInitialBlankDocument, issueNavigation, probeStuckOnBlankDocument, guardBlankDocumentNavigation, classifyContextRetry, isTransientProcessInitError, matchesFilter, selectSpecsForRun, shouldFailRun, findFreePort, runConcurrent, createResourceRegistry, runResourceAware, rollUpResults, rollUpAssertions, createAppiumPool, evaluateContextRequirements, verifiedDate, shieldsBadge, resolveVerifiedId, applyVerifiedMarkers, applyBadgeAnchoredTests, };
13
21
  // A fixed set of Appium server ports shared by concurrent runners. `acquire()`
14
22
  // hands out a free port, waiting if every port is checked out; `release()`
15
23
  // returns one, handing it straight to the next waiter. Single-threaded JS
@@ -495,7 +503,7 @@ async function waitForHttp(url, { deadline }) {
495
503
  // Cap the per-request timeout to the time left so a single hung request
496
504
  // can't block past the overall readiness deadline (e.g. timeout: 600).
497
505
  const remaining = deadline - Date.now();
498
- const resp = await axios.get(url, {
506
+ const resp = await (await loadAxios()).get(url, {
499
507
  validateStatus: () => true,
500
508
  timeout: Math.max(1, Math.min(5000, remaining)),
501
509
  });
@@ -754,6 +762,77 @@ async function isPageUnnavigated(driver) {
754
762
  function isInitialBlankDocument(url) {
755
763
  return INITIAL_BLANK_DOCUMENT.test(String(url ?? "").trim());
756
764
  }
765
+ // Issue a navigation, then re-issue it once if the browser is still parked on
766
+ // its initial blank document. Chromium's `url()` can resolve with the session
767
+ // never having left `data:,`, so the navigation silently did not take while
768
+ // every later check passes trivially against the blank page (ADR 01084, ADR
769
+ // 01088). Returns whether the retry fired, which is what gates
770
+ // probeStuckOnBlankDocument below: a first attempt that landed on a real page
771
+ // proves nothing by being re-read, so the healthy path pays no extra round trip.
772
+ async function issueNavigation(driver, url) {
773
+ await driver.url(url);
774
+ if (!(await isPageUnnavigated(driver)))
775
+ return false;
776
+ await driver.url(url);
777
+ return true;
778
+ }
779
+ // The companion verdict. Reads the URL ONCE and returns it when the browser is
780
+ // still on the initial blank document, else null, so a caller decides and
781
+ // reports from a single observation. Probing twice can decide on one URL and
782
+ // print another if the page moves in between, which reads as the tool
783
+ // contradicting itself. An unreadable URL is not evidence of anything, so it
784
+ // reads as not stuck, matching isPageUnnavigated's own swallow.
785
+ async function probeStuckOnBlankDocument(driver, retried) {
786
+ if (!retried)
787
+ return null;
788
+ let observedUrl;
789
+ try {
790
+ observedUrl = String((await driver.getUrl()) ?? "").trim();
791
+ }
792
+ catch {
793
+ return null;
794
+ }
795
+ return isInitialBlankDocument(observedUrl) ? observedUrl : null;
796
+ }
797
+ // Wrap a WebdriverIO session's `url` command with the guard above, so a caller
798
+ // holding a raw runner gets the same protection a `goTo` step has. getRunner
799
+ // hands out such a runner and its documented usage is `await runner.url(...)`,
800
+ // which bypassed the guard entirely: a session parked on `data:,` returned an
801
+ // empty title and the failure surfaced with nothing pointing at the navigation.
802
+ // See adrs/01102-getrunner-verifies-it-left-the-blank-document.md.
803
+ //
804
+ // Uses WebdriverIO's own `overwriteCommand` rather than assigning to `url`,
805
+ // because the browser object is a proxy. A runner without it (a stub in a unit
806
+ // test) is left alone rather than failing.
807
+ function guardBlankDocumentNavigation(runner, config) {
808
+ if (typeof runner?.overwriteCommand !== "function")
809
+ return;
810
+ runner.overwriteCommand("url", async function (originalUrl, url, ...rest) {
811
+ // Nothing to verify: WebdriverIO owns rejecting a missing or non-string
812
+ // target, and a caller deliberately asking for the blank document itself
813
+ // would otherwise read as stuck forever.
814
+ if (typeof url !== "string" ||
815
+ url.length === 0 ||
816
+ isInitialBlankDocument(url)) {
817
+ return originalUrl.call(this, url, ...rest);
818
+ }
819
+ // Re-implements issueNavigation's two steps rather than calling it, for
820
+ // two reasons. It would recurse: `this.url` IS this wrapper, and
821
+ // issueNavigation navigates through the driver it is handed. And the
822
+ // warning has to sit BETWEEN the two navigations, so a reader of the log
823
+ // can see the re-issue was a response to the blank page.
824
+ const result = await originalUrl.call(this, url, ...rest);
825
+ if (!(await isPageUnnavigated(this)))
826
+ return result;
827
+ log(config, "warning", `Browser was still on its initial blank document after navigating to ${redactUrlForOutput(url)}. Re-issuing the navigation.`);
828
+ const retriedResult = await originalUrl.call(this, url, ...rest);
829
+ const stuckOn = await probeStuckOnBlankDocument(this, true);
830
+ if (stuckOn !== null) {
831
+ throw new Error(`The browser never left its initial blank document (${redactUrlForOutput(stuckOn)}): navigation to ${redactUrlForOutput(url)} didn't take, even after a retry. The session is alive; the page was never loaded.`);
832
+ }
833
+ return retriedResult;
834
+ });
835
+ }
757
836
  // The retry DECISION for a FAILed context, composed from the three probes above.
758
837
  // Returns the reason to retry on a fresh session, or null to let the FAIL stand.
759
838
  // Split out from runContext so the rules that the individual probes can't
@@ -770,16 +849,21 @@ function isInitialBlankDocument(url) {
770
849
  // comes from a Map's values(), whose iteration order is insertion order per
771
850
  // the language spec, not an implementation detail.
772
851
  //
773
- // See adrs/01082-retries-mid-session-context-retry.md and
774
- // adrs/01084-retry-unnavigated-context.md.
775
- async function classifyContextRetry(probeDrivers) {
852
+ // • ...and only when the context actually tried to navigate. A context that
853
+ // ran no goTo (process-surface steps only) is legitimately on `data:,`, so
854
+ // its FAIL is real; retrying it re-runs steps that aren't idempotent.
855
+ //
856
+ // See adrs/01082-retries-mid-session-context-retry.md,
857
+ // adrs/01084-retry-unnavigated-context.md and
858
+ // adrs/01119-no-unnavigated-retry-without-navigation.md.
859
+ async function classifyContextRetry(probeDrivers, { navigated = true } = {}) {
776
860
  for (const probeDriver of probeDrivers) {
777
861
  if (!(await isSessionAlive(probeDriver)))
778
862
  return "session-died";
779
863
  if (await isPageBroken(probeDriver))
780
864
  return "page-broken";
781
865
  }
782
- if (await isPageUnnavigated(probeDrivers[0]))
866
+ if (navigated && (await isPageUnnavigated(probeDrivers[0])))
783
867
  return "unnavigated";
784
868
  return null;
785
869
  }
@@ -953,11 +1037,10 @@ function appendQueryParams(url, params) {
953
1037
  return pathAndAuthority + (query ? "?" + query : "") + fragment;
954
1038
  }
955
1039
  // Delete transient scratch files from the doc-detective temp directory.
956
- // PRESERVED entries: the lazy-install cache subdirectories (`browsers/`,
957
- // `runtime/`) and `installed.json`. The default cacheDir is
958
- // `<os.tmpdir()>/doc-detective/`, which means cache assets and scratch
959
- // files share the same root directory. A blanket wipe here would delete
960
- // the very chromedriver/firefox binaries the next runTests call needs
1040
+ // PRESERVED entries: every lazy-install cache entry. The default cacheDir is
1041
+ // `~/.doc-detective/` (ADR 01105), but a cacheDir explicitly pointed at
1042
+ // `<os.tmpdir()>/doc-detective/` shares this root with scratch files, and a
1043
+ // blanket wipe would delete the very binaries the next runTests call needs
961
1044
  // (this regression broke sequential `runTests` calls in CI on every
962
1045
  // platform — see test/core-core.test.js's getRunner suite).
963
1046
  const PRESERVED_TEMP_ENTRIES = new Set([
@@ -967,6 +1050,10 @@ const PRESERVED_TEMP_ENTRIES = new Set([
967
1050
  // Tool downloads (git-bash — the MinGit backing runShell's Windows `bash`
968
1051
  // shell). Wiping it would force a ~40 MB re-download on every run.
969
1052
  "tools",
1053
+ // `install android` (SDK + its JRE) and `install ios` (WDA prebuilds).
1054
+ "android-sdk",
1055
+ "jre",
1056
+ "ios",
970
1057
  ]);
971
1058
  function cleanTemp() {
972
1059
  const tempDir = path.join(os.tmpdir(), "doc-detective");
@@ -1046,10 +1133,20 @@ function redactUrlForOutput(value) {
1046
1133
  const url = new URL(value);
1047
1134
  url.search = "";
1048
1135
  url.hash = "";
1136
+ // `toString()` re-serializes userinfo, so a basic-auth URL would publish
1137
+ // its password alongside the host. These strings land in step descriptions,
1138
+ // warnings, reports and CI logs, so clear both before serializing.
1139
+ url.password = "";
1140
+ url.username = "";
1049
1141
  return url.toString();
1050
1142
  }
1051
1143
  catch {
1052
- return value.split("?")[0].split("#")[0];
1144
+ const stripped = value.split("?")[0].split("#")[0];
1145
+ // Nothing parsed this, so match the authority textually: userinfo sits
1146
+ // between `//` and the first `/`. `[^/]*` cannot cross that boundary, so it
1147
+ // stops at the last `@` of the authority and leaves a later `@` in the path
1148
+ // alone.
1149
+ return stripped.replace(/^((?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/)[^/]*@/, "$1");
1053
1150
  }
1054
1151
  }
1055
1152
  // Private/loopback/link-local IP ranges that binary URL fetches refuse to
@@ -1177,7 +1274,7 @@ async function fetchFile(fileURL, opts = {}) {
1177
1274
  // change; expanding SSRF coverage there belongs in its own PR.
1178
1275
  await assertUrlHostIsPublic(fileURL);
1179
1276
  }
1180
- const response = await axios.get(fileURL, opts.binary ? FETCH_BINARY_DEFAULTS : undefined);
1277
+ const response = await (await loadAxios()).get(fileURL, opts.binary ? FETCH_BINARY_DEFAULTS : undefined);
1181
1278
  let data;
1182
1279
  if (opts.binary) {
1183
1280
  data = Buffer.from(response.data);
@@ -1280,6 +1377,8 @@ function logLevelEnabled(config, level) {
1280
1377
  * width clamped up by a hundred-plus pixels) is flagged.
1281
1378
  */
1282
1379
  export const VIEWPORT_TOLERANCE_PX = 16;
1380
+ /** How long realizeViewport waits for a resize to show up in the page. */
1381
+ const VIEWPORT_SETTLE_MS = 2000;
1283
1382
  /**
1284
1383
  * Compare a requested browser viewport against the viewport the page actually
1285
1384
  * rendered (window.innerWidth/innerHeight read back after a resize) and produce
@@ -1314,7 +1413,7 @@ export function viewportMismatchWarning(requested, actual, tolerance = 0) {
1314
1413
  }
1315
1414
  if (parts.length === 0)
1316
1415
  return null;
1317
- return `Requested viewport not fully realized — the browser or OS enforces a minimum window size: ${parts.join("; ")}. Screenshots and measurements reflect the rendered size, not the requested size.`;
1416
+ return `Requested viewport not fully realized — the browser or OS limits the window size: ${parts.join("; ")}. Screenshots and measurements reflect the rendered size, not the requested size.`;
1318
1417
  }
1319
1418
  /**
1320
1419
  * True when the browser FLOORED a requested viewport — the realized size came
@@ -1372,7 +1471,7 @@ async function readViewport(driver) {
1372
1471
  *
1373
1472
  * Only meaningful when at least one dimension was requested; callers guard that.
1374
1473
  */
1375
- export async function realizeViewport(driver, requested, config = {}, label) {
1474
+ export async function realizeViewport(driver, requested, config = {}, label, level = "warning") {
1376
1475
  const current = await readViewport(driver);
1377
1476
  const target = resolveViewportTarget(requested, current);
1378
1477
  const windowSize = await driver.getWindowSize();
@@ -1386,13 +1485,88 @@ export async function realizeViewport(driver, requested, config = {}, label) {
1386
1485
  ? target.height - current.height
1387
1486
  : 0;
1388
1487
  await driver.setWindowSize(Math.round(windowSize.width + deltaWidth), Math.round(windowSize.height + deltaHeight));
1389
- const actual = await readViewport(driver);
1488
+ // Some engines report the old viewport for a moment after a resize (headless
1489
+ // Chrome on Windows CI). Poll until the target lands or the size stops
1490
+ // changing; a stale read would otherwise skew the next resize's delta and
1491
+ // raise a false floor warning.
1492
+ let actual = await readViewport(driver);
1493
+ let previous;
1494
+ const deadline = Date.now() + VIEWPORT_SETTLE_MS;
1495
+ while (viewportMismatchWarning(target, actual, VIEWPORT_TOLERANCE_PX) &&
1496
+ !(previous &&
1497
+ previous.width === actual.width &&
1498
+ previous.height === actual.height &&
1499
+ (actual.width !== current.width || actual.height !== current.height)) &&
1500
+ Date.now() < deadline) {
1501
+ await new Promise((resolve) => setTimeout(resolve, 50));
1502
+ previous = actual;
1503
+ actual = await readViewport(driver);
1504
+ }
1390
1505
  const warning = viewportMismatchWarning(requested, actual, VIEWPORT_TOLERANCE_PX);
1391
1506
  if (warning) {
1392
- await log(config, "warning", label ? `${label}: ${warning}` : warning);
1507
+ await log(config, level, label ? `${label}: ${warning}` : warning);
1393
1508
  }
1394
1509
  return actual;
1395
1510
  }
1511
+ /**
1512
+ * Viewport for a desktop browser session nobody sized. Without it the size is
1513
+ * whatever the engine and host pick (headless Chrome renders 758×426 on
1514
+ * Windows, headless Firefox 1366×683), which drifts between runner images.
1515
+ * A viewport rather than a window: the window-to-viewport offset is the part
1516
+ * that drifts. See ADR 01144.
1517
+ */
1518
+ export const DEFAULT_VIEWPORT = { width: 1200, height: 800 };
1519
+ // Only positive dimensions count: the schema doesn't floor these, and a
1520
+ // 0/negative/NaN value must not reach setWindowSize.
1521
+ function positiveDimensions(dims) {
1522
+ const width = Number(dims?.width);
1523
+ const height = Number(dims?.height);
1524
+ if (!(width > 0) && !(height > 0))
1525
+ return undefined;
1526
+ return {
1527
+ ...(width > 0 ? { width } : {}),
1528
+ ...(height > 0 ? { height } : {}),
1529
+ };
1530
+ }
1531
+ /**
1532
+ * Size a freshly opened desktop browser session: an authored viewport wins,
1533
+ * then an authored window, then DEFAULT_VIEWPORT. Records the choice as
1534
+ * `driver.state.sizeTarget` and returns it, plus the viewport that rendered
1535
+ * when one was realized.
1536
+ */
1537
+ export async function applyBrowserSize(driver, authored, config = {}, label) {
1538
+ const viewport = positiveDimensions(authored?.viewport);
1539
+ const windowDims = positiveDimensions(authored?.window);
1540
+ let target;
1541
+ let realized;
1542
+ if (viewport) {
1543
+ target = { kind: "viewport", ...viewport };
1544
+ realized = await realizeViewport(driver, viewport, config, label);
1545
+ }
1546
+ else if (windowDims) {
1547
+ target = { kind: "window", ...windowDims };
1548
+ const current = await driver.getWindowSize();
1549
+ await driver.setWindowSize(windowDims.width ?? current.width, windowDims.height ?? current.height);
1550
+ }
1551
+ else {
1552
+ target = { kind: "default", ...DEFAULT_VIEWPORT };
1553
+ // Nobody asked for this size, so a host too small to fit it (a headed
1554
+ // desktop on a small display) isn't worth a warning, and a session that
1555
+ // can't be resized at all still runs at the size it opened with.
1556
+ try {
1557
+ realized = await realizeViewport(driver, DEFAULT_VIEWPORT, config, label, "debug");
1558
+ }
1559
+ catch (error) {
1560
+ await log(config, "debug", `Couldn't apply the default ${DEFAULT_VIEWPORT.width}×${DEFAULT_VIEWPORT.height} viewport: ${error?.message ?? error}`);
1561
+ }
1562
+ }
1563
+ driver.state = driver.state ?? {};
1564
+ driver.state.sizeTarget = target;
1565
+ // The target belongs to the window that was sized, not to popups the page
1566
+ // opens later in the same session.
1567
+ driver.state.sizeTargetHandle = await driver.getWindowHandle?.();
1568
+ return { target, realized };
1569
+ }
1396
1570
  async function log(config, level, message) {
1397
1571
  if (message === undefined) {
1398
1572
  // 2-arg form: log(message, level)
@@ -1558,6 +1732,365 @@ function replaceEnvs(stringOrObject) {
1558
1732
  }
1559
1733
  return stringOrObject;
1560
1734
  }
1735
+ // ===========================================================================
1736
+ // Verification metadata write-back. After a run, every page that lists a
1737
+ // `verified-by` entry for Doc Detective gets that entry's result written back
1738
+ // when its check passed. src/core/verification.ts holds the rules, and
1739
+ // adrs/01140-last-verified-on-marker-writeback.md records them.
1740
+ // ===========================================================================
1741
+ // Map a file extension to the doc format whose comment syntax we use. `.mdx`
1742
+ // shares the markdown grammar (and uses the JSX-comment variant).
1743
+ const VERIFIED_FORMAT_BY_EXT = {
1744
+ md: "markdown",
1745
+ markdown: "markdown",
1746
+ mdx: "markdown",
1747
+ adoc: "asciidoc",
1748
+ asciidoc: "asciidoc",
1749
+ asc: "asciidoc",
1750
+ html: "html",
1751
+ htm: "html",
1752
+ dita: "dita",
1753
+ ditamap: "dita",
1754
+ // `.xml` is intentionally omitted: it's a generic extension (POMs, SVGs, feeds,
1755
+ // Spring configs), and treating every .xml as DITA would put unrelated files on
1756
+ // the write-back scan path. DITA authored as `.xml` is uncommon; use `.dita`.
1757
+ };
1758
+ // Body of an existing DD-managed shields.io image, per format, so a badge
1759
+ // writer can find and replace its own image line on re-runs. Anchored to the
1760
+ // `Last_verified-` label shieldsBadge writes, so an author's other shields.io
1761
+ // badge on that line is never taken for ours.
1762
+ const VERIFIED_IMG_BODY = {
1763
+ markdown: "!\\[[^\\]]*\\]\\([^)]*img\\.shields\\.io/badge/Last_verified-[^)]*\\)",
1764
+ html: "<img[^>]*img\\.shields\\.io/badge/Last_verified-[^>]*>",
1765
+ asciidoc: "image:\\S*img\\.shields\\.io/badge/Last_verified-\\S*\\[[^\\]]*\\]",
1766
+ dita: "<image[^>]*img\\.shields\\.io/badge/Last_verified-[\\s\\S]*?</image>",
1767
+ };
1768
+ // Filesystem-/badge-friendly calendar date, `YYYY-MM-DD` in UTC (deterministic
1769
+ // across runners/timezones), e.g. `2026-06-26`.
1770
+ function verifiedDate(d = new Date()) {
1771
+ const y = d.getUTCFullYear();
1772
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
1773
+ const day = String(d.getUTCDate()).padStart(2, "0");
1774
+ return `${y}-${m}-${day}`;
1775
+ }
1776
+ // Escape one shields.io URL path segment: `_`→`__`, `-`→`--`, ` `→`_`. The
1777
+ // dash-doubling is the easy-to-get-wrong bit (a literal `-` is otherwise the
1778
+ // label/message/color separator), so `2026-06-26` → `2026--06--26`.
1779
+ function shieldsEscape(segment) {
1780
+ return segment.replace(/_/g, "__").replace(/-/g, "--").replace(/ /g, "_");
1781
+ }
1782
+ // Build a shields.io static badge, wrapped in the format's native image syntax.
1783
+ // DD only writes the URL; shields.io renders it client-side (no network call).
1784
+ function shieldsBadge(format, date, { label = "Last verified", color = "brightgreen" } = {}) {
1785
+ const url = `https://img.shields.io/badge/${shieldsEscape(label)}-${shieldsEscape(date)}-${shieldsEscape(color)}`;
1786
+ const alt = `${label} ${date}`;
1787
+ switch (format) {
1788
+ case "asciidoc":
1789
+ return `image:${url}[${alt}]`;
1790
+ case "html":
1791
+ return `<img src="${url}" alt="${alt}">`;
1792
+ case "dita":
1793
+ return `<image href="${url}"><alt>${alt}</alt></image>`;
1794
+ default:
1795
+ return `![${alt}](${url})`;
1796
+ }
1797
+ }
1798
+ // Replace a source document in one step: write a sibling temp file, then rename
1799
+ // it over the real target, so an interrupted write never leaves the document
1800
+ // half-written. The target is resolved first, so a symlink stays a symlink,
1801
+ // and its permission bits carry over. On any failure the temp file goes and
1802
+ // the document is untouched.
1803
+ function writeFileAtomic(file, content) {
1804
+ const target = fs.realpathSync(file);
1805
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomBytes(6).toString("hex")}.tmp`);
1806
+ // Created exclusively first, so an EEXIST throws before we own the file and
1807
+ // cleanup can never remove someone else's.
1808
+ const fd = fs.openSync(tmp, "wx", 0o600); // private until it takes the target's mode
1809
+ let keepTmp = false;
1810
+ try {
1811
+ try {
1812
+ fs.writeFileSync(fd, content, "utf8");
1813
+ }
1814
+ finally {
1815
+ fs.closeSync(fd);
1816
+ }
1817
+ fs.chmodSync(tmp, fs.statSync(target).mode & 0o7777);
1818
+ try {
1819
+ fs.renameSync(tmp, target);
1820
+ }
1821
+ catch (error) {
1822
+ // Windows refuses to replace a file another process holds open (an
1823
+ // editor, a preview). Fall back to an in-place copy, which is no worse
1824
+ // than a plain write, rather than skip the document.
1825
+ if (!["EPERM", "EBUSY", "EACCES"].includes(error?.code))
1826
+ throw error;
1827
+ try {
1828
+ fs.copyFileSync(tmp, target);
1829
+ }
1830
+ catch (copyError) {
1831
+ // The copy truncates the document first, so a failure partway can
1832
+ // leave it incomplete. Keep the temp file, the one complete copy.
1833
+ keepTmp = true;
1834
+ throw new Error(`${copyError?.message ?? copyError} (the complete content is kept in ${tmp})`);
1835
+ }
1836
+ fs.rmSync(tmp, { force: true });
1837
+ }
1838
+ }
1839
+ catch (error) {
1840
+ if (!keepTmp)
1841
+ fs.rmSync(tmp, { force: true });
1842
+ throw error;
1843
+ }
1844
+ }
1845
+ // Resolve a check against a run report: specs with a matching specId, else
1846
+ // tests with a matching testId. A routed revisit (goToTest) reports a unit
1847
+ // more than once, so it resolves to PASS only if every match passed, and to
1848
+ // the first other result otherwise. Null when nothing matches.
1849
+ function resolveVerifiedId(results, id) {
1850
+ if (!results || !Array.isArray(results.specs))
1851
+ return null;
1852
+ const specs = results.specs.filter((spec) => spec?.specId === id);
1853
+ const matches = specs.length
1854
+ ? specs
1855
+ : results.specs.flatMap((spec) => (spec?.tests || []).filter((test) => test?.testId === id));
1856
+ if (matches.length === 0)
1857
+ return null;
1858
+ const failed = matches.find((unit) => unit.result !== "PASS");
1859
+ return failed ? (failed.result ?? null) : "PASS";
1860
+ }
1861
+ // Post-run write-back: for every candidate source file, fill Doc Detective's
1862
+ // `verified-by` entries whose check PASSED, in front matter and in inline
1863
+ // `meta` statements. Unknown checks warn and are skipped; other results write
1864
+ // nothing. Per-file failures warn and continue. Never throws.
1865
+ //
1866
+ // The candidate set is the union of the report's content paths and the full
1867
+ // detected input set (`files`), so a prose-only page that names a check but
1868
+ // contributes no spec is still updated.
1869
+ //
1870
+ // With `init` (`verification.write: true`), a Markdown or MDX page whose spec
1871
+ // PASSED is first given a `verified-by` entry naming that spec, if it has no
1872
+ // Doc Detective entry yet (declarePage), so the fill completes it.
1873
+ function applyVerifiedMarkers({ config, results, files: detectedFiles = [], init = false, }) {
1874
+ if (!results || !Array.isArray(results.specs))
1875
+ return;
1876
+ // contentPath -> the passing spec a page declares under `init`.
1877
+ const passingSpecs = new Map();
1878
+ if (init) {
1879
+ for (const spec of results.specs) {
1880
+ const ext = (String(spec?.contentPath ?? "").split(".").pop() || "").toLowerCase();
1881
+ if (VERIFIED_FORMAT_BY_EXT[ext] !== "markdown" || typeof spec.specId !== "string")
1882
+ continue;
1883
+ if (resolveVerifiedId(results, spec.specId) === "PASS")
1884
+ passingSpecs.set(spec.contentPath, spec.specId);
1885
+ }
1886
+ }
1887
+ const files = new Set();
1888
+ for (const file of detectedFiles) {
1889
+ if (file)
1890
+ files.add(file);
1891
+ }
1892
+ for (const spec of results.specs) {
1893
+ if (spec?.contentPath)
1894
+ files.add(spec.contentPath);
1895
+ for (const test of spec?.tests || []) {
1896
+ if (test?.contentPath)
1897
+ files.add(test.contentPath);
1898
+ }
1899
+ }
1900
+ const today = verifiedDate();
1901
+ const version = createRequire(import.meta.url)("../../package.json").version;
1902
+ for (const file of files) {
1903
+ try {
1904
+ if (!fs.existsSync(file))
1905
+ continue;
1906
+ const ext = (file.split(".").pop() || "").toLowerCase();
1907
+ const format = VERIFIED_FORMAT_BY_EXT[ext];
1908
+ if (!format)
1909
+ continue;
1910
+ const original = fs.readFileSync(file, "utf8");
1911
+ const specId = passingSpecs.get(file);
1912
+ const declared = specId === undefined ? original : declarePage(original, specId);
1913
+ // Cheap fast-path: every entry Doc Detective fills names it, so a file
1914
+ // without the literal "doc-detective" has nothing to update.
1915
+ if (!declared.includes(VERIFIED_TOOL))
1916
+ continue;
1917
+ const content = applyVerification(declared, format, {
1918
+ today,
1919
+ version,
1920
+ resolve: (check) => resolveVerifiedId(results, check),
1921
+ onUnknown: (check) => log(config, "warning", `verified-by check '${check}' in ${file} matches no test or spec; skipping.`),
1922
+ onSkipped: (line, reason) => log(config, "warning", `Skipping the meta statement at ${file}:${line}: ${reason}.`),
1923
+ });
1924
+ if (content !== original)
1925
+ writeFileAtomic(file, content);
1926
+ }
1927
+ catch (error) {
1928
+ log(config, "warning", `Failed to update verified-by entries in ${file}: ${error?.message ?? error}`);
1929
+ }
1930
+ }
1931
+ }
1932
+ // A `test` statement with `testId` added to its options, in its own comment
1933
+ // form, or undefined when its options aren't absent or one JSON object, the
1934
+ // only shapes it can edit without re-serializing the author's statement.
1935
+ function withTestId(statement, id) {
1936
+ const m = /^([\s\S]*?\btest\b)([\s\S]*?)(\s*(?:-->|\*\/\}|\)|\?>))$/.exec(statement);
1937
+ if (!m)
1938
+ return undefined;
1939
+ const [, head, options, close] = m;
1940
+ const field = `"testId": ${JSON.stringify(id)}`;
1941
+ if (options.trim() === "")
1942
+ return `${head} {${field}}${close}`;
1943
+ try {
1944
+ const parsed = JSON.parse(options);
1945
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || "testId" in parsed)
1946
+ return undefined;
1947
+ }
1948
+ catch {
1949
+ return undefined;
1950
+ }
1951
+ const open = options.indexOf("{");
1952
+ const rest = options.slice(open + 1).trimStart();
1953
+ return `${head}${options.slice(0, open + 1)}${field}${rest.startsWith("}") ? "" : ", "}${rest}${close}`;
1954
+ }
1955
+ // Whether `statement` sets `id` as its testId (`"testId": "id"`, `testId: id`,
1956
+ // or `testId="id"`), not merely mentions it, or a longer id that starts with it.
1957
+ function setsTestId(statement, id) {
1958
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1959
+ return new RegExp(`testId["']?\\s*[:=]\\s*["']?${escaped}(?=["'\\s,}>)]|$)`).test(statement);
1960
+ }
1961
+ // A `meta` statement declaring `checks` for Doc Detective, in the format's
1962
+ // comment form, or undefined when the payload can't be written in that form.
1963
+ function declaringStatement(format, ext, checks) {
1964
+ const payload = JSON.stringify({ "verified-by": checks.map((check) => ({ tool: "doc-detective", check })) });
1965
+ if (format === "dita")
1966
+ return `<?manni meta ${payload}?>`;
1967
+ if (format === "asciidoc")
1968
+ return payload.includes(")") ? undefined : `// (meta ${payload})`;
1969
+ return ext === "mdx" ? `{/* meta ${payload} */}` : `<!-- meta ${payload} -->`;
1970
+ }
1971
+ // Badge-anchored test verification: `badge: true` on an inline `test` statement, resolved by report-carried (contentPath, location.line) — no authored testId. See ADR 01141.
1972
+ // With `init` (`verification.write: true`), a passing inline test whose testId
1973
+ // is authored (written in its own test statement; a generated one never is) gets a `meta`
1974
+ // statement declaring it above its statement and badge, unless one already
1975
+ // does. The verification write-back then fills and pins it. See ADR 01140.
1976
+ // A statement is independent of the badge: a test without `badge: true`
1977
+ // still gets one.
1978
+ function applyBadgeAnchoredTests({ config, results, init = false }) {
1979
+ if (!results || !Array.isArray(results.specs))
1980
+ return;
1981
+ const byFile = new Map();
1982
+ const merge = (prev, next) => (prev === undefined || prev === "PASS" ? next : prev);
1983
+ for (const spec of results.specs) {
1984
+ for (const test of spec?.tests || []) {
1985
+ if (test?.badge !== true && !init)
1986
+ continue;
1987
+ if (!test?.contentPath)
1988
+ continue;
1989
+ if (!test?.location?.line) {
1990
+ if (test.badge === true) {
1991
+ log(config, "warning", `Test '${test.testId ?? "(unnamed)"}' in ${test.contentPath} has \`badge: true\` but no location — badges only apply to tests detected from an inline test statement, not JSON/YAML spec tests; skipping.`);
1992
+ }
1993
+ continue;
1994
+ }
1995
+ const byLine = byFile.get(test.contentPath) || new Map();
1996
+ const work = byLine.get(test.location.line) || { checks: new Map() };
1997
+ if (test.badge === true)
1998
+ work.badge = merge(work.badge, test.result);
1999
+ if (init && typeof test.testId === "string") {
2000
+ const { startIndex: start = 0, endIndex: end = 0 } = test.location;
2001
+ const result = merge(work.checks.get(test.testId)?.result, test.result);
2002
+ work.checks.set(test.testId, { result, start, end });
2003
+ }
2004
+ byLine.set(test.location.line, work);
2005
+ byFile.set(test.contentPath, byLine);
2006
+ }
2007
+ }
2008
+ const today = verifiedDate();
2009
+ for (const [file, byLine] of byFile) {
2010
+ try {
2011
+ if (!fs.existsSync(file))
2012
+ continue;
2013
+ const ext = (file.split(".").pop() || "").toLowerCase();
2014
+ const format = VERIFIED_FORMAT_BY_EXT[ext];
2015
+ if (!format)
2016
+ continue;
2017
+ const original = fs.readFileSync(file, "utf8");
2018
+ const eol = original.includes("\r\n") ? "\r\n" : "\n";
2019
+ const lines = original.split(/\r?\n/);
2020
+ const body = VERIFIED_IMG_BODY[format] || VERIFIED_IMG_BODY.markdown;
2021
+ const existingImgRe = new RegExp(`^([ \\t]*)(?:${body})[ \\t]*$`);
2022
+ // A check some statement in the file already declares. Reading `original`,
2023
+ // not `lines`, is deliberate: a goToTest revisit reruns the same test and
2024
+ // reports its location, so one id reaches one line. Two authored tests
2025
+ // sharing an id on different lines each get their own statement.
2026
+ const declared = (id) => new RegExp(`"check"\\s*:\\s*${JSON.stringify(id).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(original);
2027
+ let changed = false;
2028
+ // Bottom-to-top so an insertion doesn't shift indices still to process.
2029
+ for (const [line, work] of [...byLine].sort((a, b) => b[0] - a[0])) {
2030
+ let li = line - 1; // 0-indexed into `lines`: the test statement
2031
+ if (li < 0 || li >= lines.length)
2032
+ continue; // stale location vs. current file
2033
+ const indent = (lines[li].match(/^[ \t]*/) || [""])[0];
2034
+ // Freeze a passing test's generated id: write it into the test's own
2035
+ // statement, where it counts as authored from then on. A generated id
2036
+ // changes whenever its test does, so a statement naming it would break.
2037
+ // Right to left, so two statements on one line keep their positions. A
2038
+ // statement can span lines; adding the id never adds one, so every
2039
+ // line index this pass still uses stays valid.
2040
+ const frozen = new Set();
2041
+ for (const [id, c] of [...work.checks].sort((a, b) => b[1].start - a[1].start)) {
2042
+ const text = original.slice(c.start, c.end).replace(/\r\n/g, "\n");
2043
+ if (c.result !== "PASS" || setsTestId(text, id))
2044
+ continue;
2045
+ const span = text.split("\n").length;
2046
+ const block = lines.slice(li, li + span).join("\n");
2047
+ const at = block.indexOf(text);
2048
+ const next = at === -1 ? undefined : withTestId(text, id);
2049
+ if (next === undefined) {
2050
+ log(config, "warning", `Can't freeze the generated testId '${id}' in ${file}: its test statement's options aren't absent or one JSON object. Add a testId to the statement by hand.`);
2051
+ continue;
2052
+ }
2053
+ lines.splice(li, span, ...(block.slice(0, at) + next + block.slice(at + text.length)).split("\n"));
2054
+ frozen.add(id);
2055
+ changed = true;
2056
+ }
2057
+ // Non-pass: leave the existing badge to age.
2058
+ if (work.badge === "PASS") {
2059
+ const img = shieldsBadge(format, today);
2060
+ const existing = li > 0 ? lines[li - 1].match(existingImgRe) : null;
2061
+ if (existing) {
2062
+ if (lines[li - 1] !== `${existing[1]}${img}`) {
2063
+ lines[li - 1] = `${existing[1]}${img}`;
2064
+ changed = true;
2065
+ }
2066
+ }
2067
+ else {
2068
+ lines.splice(li, 0, `${indent}${img}`);
2069
+ li++;
2070
+ changed = true;
2071
+ }
2072
+ }
2073
+ const checks = [...work.checks]
2074
+ .filter(([id, c]) => c.result === "PASS" && (frozen.has(id) || setsTestId(original.slice(c.start, c.end), id)) && !declared(id))
2075
+ .map(([id]) => id);
2076
+ const statement = checks.length > 0 ? declaringStatement(format, ext, checks) : undefined;
2077
+ if (checks.length > 0 && !statement) {
2078
+ log(config, "warning", `Can't declare ${checks.map((c) => `'${c}'`).join(", ")} in ${file}: an AsciiDoc \`// ( )\` statement ends at the first ")", so no statement can name it. Rename the testId without ")".`);
2079
+ }
2080
+ if (statement) {
2081
+ const top = li > 0 && existingImgRe.test(lines[li - 1]) ? li - 1 : li;
2082
+ lines.splice(top, 0, `${format === "asciidoc" ? "" : indent}${statement}`);
2083
+ changed = true;
2084
+ }
2085
+ }
2086
+ if (changed)
2087
+ writeFileAtomic(file, lines.join(eol));
2088
+ }
2089
+ catch (error) {
2090
+ log(config, "warning", `Failed to update badge-anchored tests in ${file}: ${error?.message ?? error}`);
2091
+ }
2092
+ }
2093
+ }
1561
2094
  // Filesystem-safe instant token, matching the `doc-detective debug` dump's
1562
2095
  // file naming (`debug-<timestamp>`): an ISO-8601 string with the `:` and `.`
1563
2096
  // (illegal/awkward in filenames, notably on Windows) replaced by `-`, e.g.