@opengsd/gsd-core 1.11.0 → 1.13.0

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 (498) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +12 -0
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-debug-session-manager.md +1 -1
  6. package/agents/gsd-debugger.md +1 -1
  7. package/agents/gsd-dom-verifier.md +169 -0
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-executor.md +78 -42
  10. package/agents/gsd-framework-selector.md +1 -3
  11. package/agents/gsd-intel-updater.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +0 -1
  13. package/agents/gsd-pattern-mapper.md +11 -0
  14. package/agents/gsd-phase-researcher.md +3 -1
  15. package/agents/gsd-plan-checker.md +91 -112
  16. package/agents/gsd-planner.md +20 -4
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +2 -2
  19. package/agents/gsd-roadmapper.md +15 -11
  20. package/agents/gsd-ui-checker.md +82 -7
  21. package/agents/gsd-ui-researcher.md +70 -3
  22. package/agents/gsd-verifier.md +24 -2
  23. package/bin/install.js +847 -200
  24. package/commands/gsd/discuss-phase.md +1 -1
  25. package/commands/gsd/execute-phase.md +1 -1
  26. package/commands/gsd/import.md +1 -1
  27. package/commands/gsd/ns-workflow.md +2 -1
  28. package/commands/gsd/phase.md +1 -1
  29. package/commands/gsd/quick-batch.md +105 -0
  30. package/commands/gsd/quick.md +8 -4
  31. package/commands/gsd/surface.md +18 -8
  32. package/gsd-core/bin/gsd-tools.cjs +761 -100
  33. package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
  34. package/gsd-core/bin/lib/adr-parser.cjs +13 -7
  35. package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
  36. package/gsd-core/bin/lib/api-coverage.cjs +30 -9
  37. package/gsd-core/bin/lib/artifacts.cjs +2 -0
  38. package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
  39. package/gsd-core/bin/lib/audit.cjs +163 -41
  40. package/gsd-core/bin/lib/broken-windows.cjs +306 -28
  41. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  42. package/gsd-core/bin/lib/capability-lock.cjs +10 -4
  43. package/gsd-core/bin/lib/capability-registry.cjs +785 -144
  44. package/gsd-core/bin/lib/capability-state.cjs +25 -4
  45. package/gsd-core/bin/lib/capability-validator.cjs +321 -18
  46. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  47. package/gsd-core/bin/lib/check-command-router.cjs +229 -6
  48. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  49. package/gsd-core/bin/lib/cli-exit.cjs +496 -10
  50. package/gsd-core/bin/lib/clusters.cjs +1 -0
  51. package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
  52. package/gsd-core/bin/lib/codex-agent-toml.cjs +410 -4
  53. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  54. package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
  55. package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
  56. package/gsd-core/bin/lib/commands.cjs +877 -54
  57. package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
  58. package/gsd-core/bin/lib/config-loader.cjs +121 -29
  59. package/gsd-core/bin/lib/config.cjs +92 -2
  60. package/gsd-core/bin/lib/configuration.cjs +129 -37
  61. package/gsd-core/bin/lib/core-utils.cjs +118 -14
  62. package/gsd-core/bin/lib/decisions.cjs +213 -1
  63. package/gsd-core/bin/lib/edge-probe.cjs +23 -2
  64. package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
  65. package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
  66. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  67. package/gsd-core/bin/lib/frontmatter.cjs +975 -326
  68. package/gsd-core/bin/lib/gap-checker.cjs +41 -8
  69. package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
  70. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
  71. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  72. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
  73. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  74. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
  75. package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
  76. package/gsd-core/bin/lib/host-integration.cjs +96 -11
  77. package/gsd-core/bin/lib/init-command-router.cjs +132 -21
  78. package/gsd-core/bin/lib/init.cjs +252 -56
  79. package/gsd-core/bin/lib/install-engine.cjs +252 -15
  80. package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
  81. package/gsd-core/bin/lib/install-profiles.cjs +100 -18
  82. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  83. package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
  84. package/gsd-core/bin/lib/installer-migrations.cjs +10 -7
  85. package/gsd-core/bin/lib/intel.cjs +101 -26
  86. package/gsd-core/bin/lib/io.cjs +195 -15
  87. package/gsd-core/bin/lib/learnings.cjs +85 -14
  88. package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
  89. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  90. package/gsd-core/bin/lib/markdown-table.cjs +175 -4
  91. package/gsd-core/bin/lib/milestone.cjs +112 -7
  92. package/gsd-core/bin/lib/model-catalog.cjs +177 -19
  93. package/gsd-core/bin/lib/model-resolver.cjs +10 -28
  94. package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
  95. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  96. package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
  97. package/gsd-core/bin/lib/phase-id.cjs +321 -13
  98. package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
  99. package/gsd-core/bin/lib/phase-locator.cjs +138 -17
  100. package/gsd-core/bin/lib/phase.cjs +1175 -115
  101. package/gsd-core/bin/lib/plan-document.cjs +273 -0
  102. package/gsd-core/bin/lib/plan-scan.cjs +13 -2
  103. package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
  104. package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
  105. package/gsd-core/bin/lib/planning-snapshot.cjs +165 -34
  106. package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
  107. package/gsd-core/bin/lib/probe-core.cjs +4 -1
  108. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
  109. package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
  110. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  111. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  112. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  113. package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
  114. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +71 -45
  115. package/gsd-core/bin/lib/review-lane-descriptor.cjs +62 -14
  116. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  117. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  118. package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
  119. package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
  120. package/gsd-core/bin/lib/roadmap.cjs +248 -64
  121. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
  122. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  123. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
  124. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
  125. package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
  126. package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
  127. package/gsd-core/bin/lib/shell-command-projection.cjs +75 -8
  128. package/gsd-core/bin/lib/smart-entry.cjs +19 -31
  129. package/gsd-core/bin/lib/spec-section.cjs +12 -7
  130. package/gsd-core/bin/lib/state-command-router.cjs +47 -18
  131. package/gsd-core/bin/lib/state-contract.cjs +359 -0
  132. package/gsd-core/bin/lib/state-document.cjs +216 -5
  133. package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
  134. package/gsd-core/bin/lib/state-transition.cjs +850 -145
  135. package/gsd-core/bin/lib/state.cjs +1629 -287
  136. package/gsd-core/bin/lib/surface.cjs +33 -10
  137. package/gsd-core/bin/lib/task-command-router.cjs +111 -1
  138. package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
  139. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  140. package/gsd-core/bin/lib/teams-status.cjs +4 -1
  141. package/gsd-core/bin/lib/uat-predicate.cjs +58 -20
  142. package/gsd-core/bin/lib/uat.cjs +2542 -387
  143. package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
  144. package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
  145. package/gsd-core/bin/lib/unusable-input.cjs +13 -0
  146. package/gsd-core/bin/lib/update-context.cjs +6 -2
  147. package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
  148. package/gsd-core/bin/lib/validate.cjs +230 -12
  149. package/gsd-core/bin/lib/vendor/README.md +43 -5
  150. package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
  151. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  152. package/gsd-core/bin/lib/verification.cjs +287 -13
  153. package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
  154. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  155. package/gsd-core/bin/lib/verify.cjs +441 -56
  156. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  157. package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
  158. package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
  159. package/gsd-core/bin/lib/worktree-safety.cjs +185 -21
  160. package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
  161. package/gsd-core/bin/shared/config-schema.manifest.json +13 -0
  162. package/gsd-core/bin/shared/exit-codes.json +8 -0
  163. package/gsd-core/bin/shared/exit-codes.sh +20 -0
  164. package/gsd-core/bin/shared/model-catalog.json +8 -1
  165. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  166. package/gsd-core/references/agent-contracts.md +6 -5
  167. package/gsd-core/references/api-coverage.md +24 -2
  168. package/gsd-core/references/autonomous-smart-discuss.md +3 -3
  169. package/gsd-core/references/checkpoints.md +37 -19
  170. package/gsd-core/references/decimal-phase-calculation.md +5 -5
  171. package/gsd-core/references/edge-probe.md +17 -5
  172. package/gsd-core/references/execute-mvp-tdd.md +18 -18
  173. package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
  174. package/gsd-core/references/execute-phase-response-language.md +6 -0
  175. package/gsd-core/references/execute-phase-wave-guard.md +11 -9
  176. package/gsd-core/references/executor-examples.md +42 -0
  177. package/gsd-core/references/failing-direction.md +78 -0
  178. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  179. package/gsd-core/references/gate-prompts.md +1 -1
  180. package/gsd-core/references/git-integration.md +5 -5
  181. package/gsd-core/references/git-planning-commit.md +3 -3
  182. package/gsd-core/references/gsd-run-resolver.md +1 -1
  183. package/gsd-core/references/loop-hook-dispatch.md +22 -0
  184. package/gsd-core/references/model-profiles.md +1 -1
  185. package/gsd-core/references/mvp-concepts.md +2 -2
  186. package/gsd-core/references/nyquist-compliance.md +74 -0
  187. package/gsd-core/references/offer-next.md +3 -5
  188. package/gsd-core/references/phase-argument-parsing.md +3 -3
  189. package/gsd-core/references/plan-checker-examples.md +41 -0
  190. package/gsd-core/references/planner-antipatterns.md +25 -0
  191. package/gsd-core/references/planner-chunked.md +5 -1
  192. package/gsd-core/references/planner-coupling.md +42 -0
  193. package/gsd-core/references/planner-failing-direction.md +53 -0
  194. package/gsd-core/references/planner-human-verify-mode.md +15 -1
  195. package/gsd-core/references/planner-quick-batch.md +71 -0
  196. package/gsd-core/references/planner-reviews.md +47 -0
  197. package/gsd-core/references/planner-revision.md +76 -3
  198. package/gsd-core/references/planner-verify-command-grounding.md +17 -0
  199. package/gsd-core/references/planning-config.md +39 -9
  200. package/gsd-core/references/response-language-directive.md +9 -0
  201. package/gsd-core/references/reviewer-instances.md +31 -0
  202. package/gsd-core/references/revision-loop.md +118 -11
  203. package/gsd-core/references/runtime-aware-dispatch.md +1 -1
  204. package/gsd-core/references/tdd.md +15 -12
  205. package/gsd-core/references/ui-brand.md +65 -21
  206. package/gsd-core/references/ui-consideration-probe.md +1 -1
  207. package/gsd-core/references/universal-anti-patterns.md +2 -2
  208. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  209. package/gsd-core/references/verify-command-path-resolvability.md +42 -0
  210. package/gsd-core/references/verify-mvp-mode.md +1 -1
  211. package/gsd-core/references/workstream-flag.md +11 -11
  212. package/gsd-core/templates/README.md +1 -1
  213. package/gsd-core/templates/SECURITY.md +3 -3
  214. package/gsd-core/templates/UI-SPEC.md +25 -3
  215. package/gsd-core/templates/VALIDATION.md +3 -3
  216. package/gsd-core/templates/phase-prompt.md +7 -0
  217. package/gsd-core/templates/state.md +7 -0
  218. package/gsd-core/templates/verification-report.md +5 -0
  219. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  220. package/gsd-core/workflows/add-backlog.md +3 -1
  221. package/gsd-core/workflows/add-phase.md +5 -3
  222. package/gsd-core/workflows/add-tests.md +4 -9
  223. package/gsd-core/workflows/add-todo.md +2 -2
  224. package/gsd-core/workflows/ai-integration-phase.md +5 -10
  225. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  226. package/gsd-core/workflows/audit-fix.md +14 -3
  227. package/gsd-core/workflows/audit-milestone.md +11 -9
  228. package/gsd-core/workflows/audit-uat.md +19 -2
  229. package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
  230. package/gsd-core/workflows/autonomous.md +12 -26
  231. package/gsd-core/workflows/check-todos.md +2 -2
  232. package/gsd-core/workflows/cleanup.md +3 -3
  233. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
  234. package/gsd-core/workflows/code-review-fix.md +3 -1
  235. package/gsd-core/workflows/code-review.md +192 -69
  236. package/gsd-core/workflows/complete-milestone.md +28 -14
  237. package/gsd-core/workflows/debug.md +6 -4
  238. package/gsd-core/workflows/diagnose-issues.md +17 -7
  239. package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
  240. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  241. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  242. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  243. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  244. package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
  245. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  246. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  247. package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
  248. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  249. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  250. package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
  251. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
  252. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  253. package/gsd-core/workflows/discuss-phase.md +2 -2
  254. package/gsd-core/workflows/do.md +46 -19
  255. package/gsd-core/workflows/docs-update.md +6 -5
  256. package/gsd-core/workflows/edit-phase.md +3 -1
  257. package/gsd-core/workflows/eval-review.md +5 -10
  258. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
  259. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -11
  260. package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
  261. package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
  262. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
  263. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +29 -5
  264. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
  265. package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
  266. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +4 -2
  267. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  268. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
  269. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  270. package/gsd-core/workflows/execute-phase.md +68 -66
  271. package/gsd-core/workflows/execute-plan.md +25 -20
  272. package/gsd-core/workflows/explore.md +3 -1
  273. package/gsd-core/workflows/extract-learnings.md +3 -1
  274. package/gsd-core/workflows/fast.md +8 -2
  275. package/gsd-core/workflows/forensics.md +3 -1
  276. package/gsd-core/workflows/graduation.md +6 -6
  277. package/gsd-core/workflows/health.md +4 -7
  278. package/gsd-core/workflows/help/modes/brief.md +2 -0
  279. package/gsd-core/workflows/help/modes/default.md +2 -0
  280. package/gsd-core/workflows/help/modes/full.md +12 -0
  281. package/gsd-core/workflows/help/modes/topic.md +2 -0
  282. package/gsd-core/workflows/help.md +2 -0
  283. package/gsd-core/workflows/import.md +17 -14
  284. package/gsd-core/workflows/inbox.md +5 -6
  285. package/gsd-core/workflows/ingest-docs.md +45 -12
  286. package/gsd-core/workflows/insert-phase.md +7 -5
  287. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  288. package/gsd-core/workflows/list-seeds.md +7 -3
  289. package/gsd-core/workflows/list-workspaces.md +3 -1
  290. package/gsd-core/workflows/manager.md +15 -26
  291. package/gsd-core/workflows/map-codebase.md +3 -1
  292. package/gsd-core/workflows/milestone-summary.md +3 -1
  293. package/gsd-core/workflows/mvp-phase.md +3 -3
  294. package/gsd-core/workflows/new-milestone.md +10 -22
  295. package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
  296. package/gsd-core/workflows/new-project.md +17 -29
  297. package/gsd-core/workflows/new-workspace.md +2 -2
  298. package/gsd-core/workflows/next.md +4 -2
  299. package/gsd-core/workflows/node-repair.md +2 -0
  300. package/gsd-core/workflows/note.md +2 -0
  301. package/gsd-core/workflows/onboard.md +1 -1
  302. package/gsd-core/workflows/pause-work.md +20 -5
  303. package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
  304. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  305. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
  306. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
  307. package/gsd-core/workflows/plan-phase.md +251 -54
  308. package/gsd-core/workflows/plan-review-convergence.md +148 -19
  309. package/gsd-core/workflows/plant-seed.md +3 -3
  310. package/gsd-core/workflows/pr-branch.md +195 -51
  311. package/gsd-core/workflows/profile-user.md +17 -15
  312. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  313. package/gsd-core/workflows/progress.md +52 -15
  314. package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
  315. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
  316. package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
  317. package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
  318. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
  319. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  320. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  321. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  322. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  323. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  324. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  325. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  326. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  327. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  328. package/gsd-core/workflows/quick-batch.md +203 -0
  329. package/gsd-core/workflows/quick.md +33 -32
  330. package/gsd-core/workflows/reapply-patches.md +2 -0
  331. package/gsd-core/workflows/remove-phase.md +6 -4
  332. package/gsd-core/workflows/remove-workspace.md +3 -3
  333. package/gsd-core/workflows/resume-project.md +14 -14
  334. package/gsd-core/workflows/review.md +404 -21
  335. package/gsd-core/workflows/scan.md +3 -1
  336. package/gsd-core/workflows/section-manifest.json +12 -0
  337. package/gsd-core/workflows/secure-phase.md +3 -3
  338. package/gsd-core/workflows/session-report.md +2 -0
  339. package/gsd-core/workflows/settings-advanced.md +9 -9
  340. package/gsd-core/workflows/settings-integrations.md +66 -32
  341. package/gsd-core/workflows/settings.md +4 -6
  342. package/gsd-core/workflows/ship.md +22 -16
  343. package/gsd-core/workflows/sketch-wrap-up.md +13 -17
  344. package/gsd-core/workflows/sketch.md +13 -19
  345. package/gsd-core/workflows/smart-entry.md +4 -6
  346. package/gsd-core/workflows/spec-phase.md +31 -4
  347. package/gsd-core/workflows/spike-wrap-up.md +9 -11
  348. package/gsd-core/workflows/spike.md +21 -32
  349. package/gsd-core/workflows/stats.md +4 -2
  350. package/gsd-core/workflows/sync-skills.md +13 -5
  351. package/gsd-core/workflows/thread.md +13 -7
  352. package/gsd-core/workflows/transition.md +7 -5
  353. package/gsd-core/workflows/ui-phase.md +36 -21
  354. package/gsd-core/workflows/ui-review.md +7 -11
  355. package/gsd-core/workflows/ultraplan-phase.md +7 -13
  356. package/gsd-core/workflows/undo.md +9 -17
  357. package/gsd-core/workflows/update.md +47 -48
  358. package/gsd-core/workflows/validate-phase.md +3 -3
  359. package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
  360. package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
  361. package/gsd-core/workflows/verify-work.md +106 -21
  362. package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
  363. package/hooks/dist/gsd-check-update-worker.js +19 -2
  364. package/hooks/dist/gsd-config-reload.js +18 -12
  365. package/hooks/dist/gsd-context-monitor.js +302 -22
  366. package/hooks/dist/gsd-cursor-post-tool.js +3 -1
  367. package/hooks/dist/gsd-cursor-pre-tool.js +3 -1
  368. package/hooks/dist/gsd-cursor-session-start.js +2 -1
  369. package/hooks/dist/gsd-cursor-stop.js +2 -1
  370. package/hooks/dist/gsd-cursor-subagent-start.js +28 -23
  371. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
  372. package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
  373. package/hooks/dist/gsd-graphify-update.sh +22 -18
  374. package/hooks/dist/gsd-node-runner.sh +77 -0
  375. package/hooks/dist/gsd-phase-boundary.sh +1 -0
  376. package/hooks/dist/gsd-prompt-guard.js +46 -12
  377. package/hooks/dist/gsd-read-guard.js +18 -7
  378. package/hooks/dist/gsd-read-injection-scanner.js +22 -13
  379. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  380. package/hooks/dist/gsd-session-state.sh +1 -0
  381. package/hooks/dist/gsd-statusline.js +222 -29
  382. package/hooks/dist/gsd-validate-commit.sh +523 -12
  383. package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
  384. package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
  385. package/hooks/dist/gsd-workflow-guard.js +36 -17
  386. package/hooks/dist/gsd-worktree-path-guard.js +36 -21
  387. package/hooks/dist/gsd-write-guard.js +35 -25
  388. package/hooks/dist/lib/cli-exit.js +560 -0
  389. package/hooks/dist/lib/exit-code-registry.js +98 -0
  390. package/hooks/dist/lib/git-cmd.js +210 -1
  391. package/hooks/dist/lib/git-probe.js +84 -0
  392. package/hooks/dist/lib/hook-exit.js +81 -0
  393. package/hooks/dist/lib/injection-patterns.js +36 -6
  394. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  395. package/hooks/gsd-agent-isolation-guard.js +77 -38
  396. package/hooks/gsd-check-update-worker.js +19 -2
  397. package/hooks/gsd-config-reload.js +18 -12
  398. package/hooks/gsd-context-monitor.js +302 -22
  399. package/hooks/gsd-cursor-post-tool.js +3 -1
  400. package/hooks/gsd-cursor-pre-tool.js +3 -1
  401. package/hooks/gsd-cursor-session-start.js +2 -1
  402. package/hooks/gsd-cursor-stop.js +2 -1
  403. package/hooks/gsd-cursor-subagent-start.js +28 -23
  404. package/hooks/gsd-cursor-subagent-stop.js +3 -1
  405. package/hooks/gsd-ensure-canonical-path.js +2 -1
  406. package/hooks/gsd-graphify-update.sh +22 -18
  407. package/hooks/gsd-node-runner.sh +77 -0
  408. package/hooks/gsd-phase-boundary.sh +1 -0
  409. package/hooks/gsd-prompt-guard.js +46 -12
  410. package/hooks/gsd-read-guard.js +18 -7
  411. package/hooks/gsd-read-injection-scanner.js +22 -13
  412. package/hooks/gsd-secret-read-guard.js +1079 -0
  413. package/hooks/gsd-session-state.sh +1 -0
  414. package/hooks/gsd-statusline.js +222 -29
  415. package/hooks/gsd-validate-commit.sh +523 -12
  416. package/hooks/gsd-windsurf-pre-command.js +16 -11
  417. package/hooks/gsd-windsurf-pre-write.js +22 -13
  418. package/hooks/gsd-workflow-guard.js +36 -17
  419. package/hooks/gsd-worktree-path-guard.js +36 -21
  420. package/hooks/gsd-write-guard.js +35 -25
  421. package/hooks/hooks.json +6 -0
  422. package/hooks/lib/cli-exit.js +560 -0
  423. package/hooks/lib/exit-code-registry.js +98 -0
  424. package/hooks/lib/git-cmd.js +210 -1
  425. package/hooks/lib/git-probe.js +84 -0
  426. package/hooks/lib/hook-exit.js +81 -0
  427. package/hooks/lib/injection-patterns.js +36 -6
  428. package/hooks/managed-hooks-registry.cjs +4 -0
  429. package/package.json +14 -9
  430. package/scripts/base64-scan.sh +74 -12
  431. package/scripts/build-hooks.js +12 -0
  432. package/scripts/check-glossary-refs.cjs +77 -15
  433. package/scripts/check-mutation-score-ratchet.cjs +156 -0
  434. package/scripts/ci-check-job-near-cap.cjs +49 -0
  435. package/scripts/ci-pr-mergeability.cjs +262 -0
  436. package/scripts/ci-test-scope.cjs +52 -12
  437. package/scripts/ci-timeout-report.cjs +230 -0
  438. package/scripts/docs-guard-registry.cjs +406 -0
  439. package/scripts/gen-capability-registry.cjs +8 -6
  440. package/scripts/gen-exit-code-docs.cjs +318 -0
  441. package/scripts/gen-exit-code-registry.cjs +891 -0
  442. package/scripts/gen-features.cjs +836 -0
  443. package/scripts/gen-hooks-cli-exit.cjs +239 -0
  444. package/scripts/gen-install-tree-fixtures.cjs +2 -2
  445. package/scripts/gen-loop-host-contract.cjs +189 -4
  446. package/scripts/gen-scripts-cli-exit.cjs +185 -0
  447. package/scripts/gen-state-md-docs.cjs +727 -0
  448. package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
  449. package/scripts/lib/ci-job-timing.cjs +72 -0
  450. package/scripts/lib/cli-exit.cjs +546 -44
  451. package/scripts/lib/drift-scan.cjs +32 -2
  452. package/scripts/lib/exit-code-registry.cjs +98 -0
  453. package/scripts/lib/ndjson-reporter.cjs +119 -0
  454. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  455. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  456. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  457. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  458. package/scripts/lint-docs-guard-registration.cjs +495 -0
  459. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +198 -0
  460. package/scripts/lint-eslint-glob-coverage.allowlist.json +4 -0
  461. package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
  462. package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
  463. package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
  464. package/scripts/lint-phase-enumeration-drift.cjs +45 -14
  465. package/scripts/lint-phase-id-drift.cjs +133 -8
  466. package/scripts/lint-planning-prompt-drift.cjs +38 -1
  467. package/scripts/lint-portable-grep.cjs +176 -0
  468. package/scripts/lint-removed-but-needed.cjs +184 -16
  469. package/scripts/lint-response-language-coverage.cjs +524 -0
  470. package/scripts/lint-seam-enforcement.cjs +182 -0
  471. package/scripts/lint-slug-derivation-drift.cjs +921 -0
  472. package/scripts/lint-source-test-name-collision.cjs +241 -0
  473. package/scripts/lint-state-write-path-drift.cjs +337 -432
  474. package/scripts/lint-test-file-count.allowlist.json +124 -4
  475. package/scripts/lint-test-file-count.cjs +25 -3
  476. package/scripts/lint-unreachable-guard-drift.cjs +51 -64
  477. package/scripts/lint-vendored-deps.cjs +208 -35
  478. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  479. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  480. package/scripts/mutation-matrix.cjs +599 -50
  481. package/scripts/npm-audit-baseline.cjs +376 -0
  482. package/scripts/prompt-injection-scan.sh +83 -14
  483. package/scripts/require-issue-link-policy.cjs +16 -1
  484. package/scripts/secret-scan.sh +75 -13
  485. package/scripts/select-docs-guards.cjs +56 -0
  486. package/scripts/sync-runtime-launcher.cjs +22 -3
  487. package/skills/gsd-discuss-phase/SKILL.md +1 -1
  488. package/skills/gsd-execute-phase/SKILL.md +1 -1
  489. package/skills/gsd-import/SKILL.md +1 -1
  490. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  491. package/skills/gsd-phase/SKILL.md +1 -1
  492. package/skills/gsd-quick/SKILL.md +8 -4
  493. package/skills/gsd-quick-batch/SKILL.md +105 -0
  494. package/skills/gsd-surface/SKILL.md +18 -8
  495. package/vscode/package.json +1 -1
  496. package/bin/lib/ui-safety-gate.cjs +0 -109
  497. package/scripts/lint-emitted-drift-ack.cjs +0 -344
  498. package/scripts/state-write-path-drift-baseline.json +0 -19
@@ -19,30 +19,63 @@ const io = require("./io.cjs");
19
19
  const { output, error } = io;
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
21
  const markdownSectionizer = require("./markdown-sectionizer.cjs");
22
- const { collectSection, tokenizeHeadings } = markdownSectionizer;
22
+ const { collectSection, tokenizeHeadings, stripFencedCode, scanFencedBlocks } = markdownSectionizer;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const markdownTable = require("./markdown-table.cjs");
25
25
  const { splitTableRow, isDelimiterRow } = markdownTable;
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const coreUtils = require("./core-utils.cjs");
28
- const { toPosixPath } = coreUtils;
28
+ const { toPosixPath, normalizeLineEndings } = coreUtils;
29
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
30
30
  const planningWorkspace = require("./planning-workspace.cjs");
31
31
  const { planningDir } = planningWorkspace;
32
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
33
  const frontmatter = require("./frontmatter.cjs");
34
- const { extractFrontmatter } = frontmatter;
34
+ const { extractFrontmatter, frontmatterListEntries, flattenObjectListItem } = frontmatter;
35
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
36
  const phaseIdMod = require("./phase-id.cjs");
37
37
  const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
38
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
39
  const phaseLocator = require("./phase-locator.cjs");
40
- const { getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocator;
40
+ const { listMilestonePhaseDirs, getAllArchivedPhaseDirs } = phaseLocator;
41
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
42
+ const auditMod = require("./audit.cjs");
43
+ const { isAuditItemAcknowledged, deriveUatGapSnapshotValue } = auditMod;
41
44
  const security_cjs_1 = require("./security.cjs");
42
45
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
43
46
  const configLoader = require("./config-loader.cjs");
44
47
  const { loadConfig } = configLoader;
45
48
  // ─── cmdAuditUat ─────────────────────────────────────────────────────────────
49
+ /**
50
+ * Select the UAT documents belonging to ONE phase directory.
51
+ *
52
+ * Extracted (#2790) so `cmdAuditUat` and the read-only `planning.inspect` query
53
+ * cannot drift on which files count as this phase's UAT. `scopeToPhase` has no
54
+ * unfiltered fallback on purpose: a phase whose own UAT file is genuinely absent
55
+ * scopes to empty and contributes nothing, rather than picking up a stray
56
+ * cross-phase file (#3511).
57
+ */
58
+ function selectPhaseUatFiles(files, phaseDirName) {
59
+ return scopeToPhase(files.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseDirName);
60
+ }
61
+ /**
62
+ * The ONE read boundary for every document `cmdAuditUat` scans off disk
63
+ * (#3707-CR follow-up MAJOR). Wraps `fs.readFileSync` +
64
+ * `normalizeLineEndings` in a single seam so a lone-CR-separated
65
+ * `*-UAT.md`, `*-VERIFICATION.md`, or `deferred-items.md` is normalized BY
66
+ * CONSTRUCTION before it reaches ANY downstream parser — current
67
+ * (`parseUatItemsWithStats`, `parseVerificationItems`, `parseDeferredItems`)
68
+ * or future. Fixing this per-parser was the original (#3707-CR) MEDIUM fix's
69
+ * mistake: two of the four ingresses in this function were normalized by
70
+ * editing their own parsers directly, and the other two (VERIFICATION,
71
+ * deferred-items.md) were missed precisely because nothing forced a new call
72
+ * site to remember the step. Routing every read through this function
73
+ * removes that failure mode: a parser added later needs no line-ending logic
74
+ * of its own, because the text it receives is already normalized.
75
+ */
76
+ function readNormalizedDocument(filePath) {
77
+ return normalizeLineEndings(node_fs_1.default.readFileSync(filePath, 'utf-8'));
78
+ }
46
79
  function cmdAuditUat(cwd, raw) {
47
80
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
48
81
  const hasActivePhases = node_fs_1.default.existsSync(phasesDir);
@@ -55,14 +88,18 @@ function cmdAuditUat(cwd, raw) {
55
88
  // mattering when a milestone closes: a deferred human-UAT scenario or a
56
89
  // `skipped` live-stack test is exactly what gets archived still-open.
57
90
  //
58
- // Reuses the canonical `getArchivedPhaseDirs` seam (phase-locator.cts), which
91
+ // Reuses the canonical `getAllArchivedPhaseDirs` seam (phase-locator.cts), which
59
92
  // `findPhaseInternal` already uses for this same fallback, so the archive
60
93
  // layout convention stays owned by one module.
61
- const archivedDirs = getArchivedPhaseDirs(cwd);
94
+ // #3804: the guard AND the scan use the cross-workstream enumeration —
95
+ // a project whose only phases live in workstream milestone trees is a
96
+ // fully-populated audit, not a broken install.
97
+ const archivedDirs = getAllArchivedPhaseDirs(cwd);
62
98
  if (!hasActivePhases && archivedDirs.length === 0) {
63
99
  error('No phases directory found in planning directory');
64
100
  }
65
101
  const results = [];
102
+ let acknowledgedFiles = 0;
66
103
  // Active dirs are milestone-filtered; archived dirs deliberately are NOT.
67
104
  // listMilestonePhaseDirs derives the CURRENT milestone's phase directories
68
105
  // (window + sentinel filtered) from ROADMAP.md, and archived phases belong
@@ -94,30 +131,89 @@ function cmdAuditUat(cwd, raw) {
94
131
  // under this phase's audit-uat entry. A phase whose own UAT file is
95
132
  // genuinely absent scopes to empty and contributes nothing — correct, and
96
133
  // the reason scopeToPhase has no unfiltered fallback.
97
- for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), dir)) {
134
+ for (const file of selectPhaseUatFiles(files, dir)) {
98
135
  const uatFilePath = node_path_1.default.join(phaseDir, file);
99
- const content = node_fs_1.default.readFileSync(uatFilePath, 'utf-8');
100
- const items = parseUatItems(content);
101
- if (items.length > 0) {
102
- results.push({
136
+ const content = readNormalizedDocument(uatFilePath);
137
+ const { items, headingsSeen } = parseUatItemsWithStats(content);
138
+ const uatFm = extractFrontmatter(content, uatFilePath);
139
+ const status = (uatFm.status || 'unknown').toLowerCase();
140
+ // #3805: honour the audit_acknowledged marker with the SAME snapshot
141
+ // key audit.cts's scanUatGaps uses ('gap_snapshot', derived value
142
+ // composed by the shared derivation) — one acknowledgement means the
143
+ // same thing to both commands.
144
+ if (isAuditItemAcknowledged(uatFm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) {
145
+ acknowledgedFiles++;
146
+ continue;
147
+ }
148
+ // `parse_gap` means the file contained `### N.` test blocks that
149
+ // yielded no items — NOT merely "zero items and not complete" (#3707
150
+ // MAJOR: that broader signal false-positived on an all-pass file and on
151
+ // a Gaps-only file with everything resolved). A file whose blocks all
152
+ // passed, or that has no test blocks at all, never sets `headingsSeen`,
153
+ // so it never sets the flag regardless of status.
154
+ //
155
+ // `status` deliberately does NOT gate this (#3078 security review). A
156
+ // terminal `status: complete` is an ASSERTION BY THE AUTHOR that the
157
+ // work is finished — and an assertion is exactly the thing that must
158
+ // not be allowed to switch off the detector that would contradict it.
159
+ // The earlier `status !== 'complete'` guard did precisely that: a file
160
+ // could declare itself complete and thereby suppress the report of the
161
+ // rows this tool could not read, which is a self-declared kill switch
162
+ // over the very detector this issue built. The distinction that
163
+ // actually matters is not "is it complete" but "is there anything the
164
+ // tool failed to parse":
165
+ // - complete + `headingsSeen === 0` — nothing unread, so nothing to
166
+ // contradict the claim. Still omitted entirely, exactly as before;
167
+ // that is the whole point of a terminal status and must not
168
+ // regress. (Same for a file whose blocks all parsed and passed.)
169
+ // - complete + `headingsSeen > 0` — the author's claim of
170
+ // completeness CANNOT BE VERIFIED against rows the parser could not
171
+ // read, so the file is surfaced with `parse_gap` and the
172
+ // `unparsed_blocks` count. The audit reports what it could not see
173
+ // rather than trusting the frontmatter over the file body.
174
+ //
175
+ // This check is deliberately UNCONDITIONAL on `items.length` (#3707
176
+ // follow-up BLOCKER): a MIXED file — some parseable rows plus some
177
+ // unparseable blocks — must report BOTH the real items AND the parse
178
+ // gap, quantified via `unparsed_blocks`. The previous `else if` only
179
+ // ever flagged a file with ZERO items, silently discarding
180
+ // `headingsSeen` (and every unparseable row it counted) the instant any
181
+ // single item existed anywhere in the file, including via the Gaps
182
+ // union.
183
+ if (items.length > 0 || headingsSeen > 0) {
184
+ const entry = {
103
185
  phase: phaseNum,
104
186
  phase_dir: dir,
105
187
  file,
106
188
  file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, file))),
107
189
  type: 'uat',
108
- status: (extractFrontmatter(content, uatFilePath).status || 'unknown'),
190
+ status,
109
191
  archived_milestone: milestone,
110
192
  items,
111
- });
193
+ };
194
+ if (headingsSeen > 0) {
195
+ entry.parse_gap = true;
196
+ entry.unparsed_blocks = headingsSeen;
197
+ }
198
+ results.push(entry);
112
199
  }
113
200
  }
114
201
  // Process VERIFICATION files — scoped to THIS phase's own token (#3511)
115
202
  // for the same reason as the UAT loop above.
116
203
  for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) {
117
204
  const verificationFilePath = node_path_1.default.join(phaseDir, file);
118
- const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
119
- const status = extractFrontmatter(content, verificationFilePath).status || 'unknown';
205
+ const content = readNormalizedDocument(verificationFilePath);
206
+ const verFm = extractFrontmatter(content, verificationFilePath);
207
+ const status = (verFm.status || 'unknown').toLowerCase();
208
+ // #3805: same marker, same 'status' snapshot key as scanVerificationGaps,
209
+ // and the same ORDERING — the open-status gate runs FIRST (a marker on
210
+ // a file that would never surface is not a suppressed item), then the
211
+ // acknowledgement suppresses what the gate surfaced.
120
212
  if (status === 'human_needed' || status === 'gaps_found') {
213
+ if (isAuditItemAcknowledged(verFm, { snapshotKey: 'status', currentValue: status })) {
214
+ acknowledgedFiles++;
215
+ continue;
216
+ }
121
217
  const items = parseVerificationItems(content, status, verificationFilePath);
122
218
  if (items.length > 0) {
123
219
  results.push({
@@ -142,7 +238,7 @@ function cmdAuditUat(cwd, raw) {
142
238
  // required.
143
239
  const deferredFile = 'deferred-items.md';
144
240
  if (files.includes(deferredFile)) {
145
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, deferredFile), 'utf-8');
241
+ const content = readNormalizedDocument(node_path_1.default.join(phaseDir, deferredFile));
146
242
  const items = parseDeferredItems(content);
147
243
  if (items.length > 0) {
148
244
  results.push({
@@ -162,10 +258,52 @@ function cmdAuditUat(cwd, raw) {
162
258
  const summary = {
163
259
  total_files: results.length,
164
260
  total_items: results.reduce((sum, r) => sum + r.items.length, 0),
261
+ // #3707 blocker 2: a distinct counter so a file whose test blocks
262
+ // yielded no items (structurally unparseable, not "all clear") stays
263
+ // visible even though it contributes zero to total_items. Consumers
264
+ // (audit-uat.md, progress.md) must gate their all-clear / debt checks on
265
+ // BOTH total_items === 0 AND parse_gap_files === 0.
266
+ //
267
+ // Counts EVERY entry with `parse_gap: true`, archived or not — same as
268
+ // `total_items`, which has no archived split. An outstanding item does
269
+ // not stop mattering because its phase was archived on milestone close
270
+ // (#2766): a deferred human-UAT scenario or a `skipped` live-stack test
271
+ // is exactly what gets archived still-open, so a parse gap on that same
272
+ // file is still an unread outstanding row, not closed history. Splitting
273
+ // this counter by `archived_milestone` (tried in this branch, reverted)
274
+ // demoted an in-progress phase filed under an archived dir out of the
275
+ // gate, and buried an archived outstanding row's parse failure relative
276
+ // to the identical row when it happened to parse — the exact bug class
277
+ // this issue exists to fix.
278
+ parse_gap_files: results.filter((r) => r.parse_gap).length,
165
279
  by_category: {},
166
280
  by_phase: {},
281
+ // #3783: additive segmentation so a consumer reads one field instead of
282
+ // re-deriving the `archived_milestone` filter itself. Deliberately does
283
+ // NOT touch total_items/parse_gap_files — see the parse_gap_files
284
+ // comment above for why splitting THAT counter by archive status was
285
+ // tried and reverted; this is a purely additive pair of new keys.
286
+ current_milestone: { files: 0, items: 0 },
287
+ archived: { files: 0, items: 0, by_milestone: {} },
167
288
  };
168
289
  for (const r of results) {
290
+ const resultItemCount = r.items.length;
291
+ if (r.archived_milestone) {
292
+ summary.archived.files++;
293
+ summary.archived.items += resultItemCount;
294
+ summary.archived.by_milestone[r.archived_milestone] =
295
+ (summary.archived.by_milestone[r.archived_milestone] || 0) + resultItemCount;
296
+ }
297
+ else {
298
+ summary.current_milestone.files++;
299
+ summary.current_milestone.items += resultItemCount;
300
+ }
301
+ // Deliberate (#3707 follow-up MINOR): this seeds a `by_phase` key at 0
302
+ // even for a parse-gap-only phase whose `items` is empty — do NOT "tidy"
303
+ // this away as dead code. The 0-valued key is itself the cue that this
304
+ // phase was scanned and produced no COUNTABLE items, distinguishing it
305
+ // from a phase absent from `by_phase` entirely (never scanned / no UAT
306
+ // file at all). A phase with a real outstanding item overwrites it below.
169
307
  if (!summary.by_phase[r.phase])
170
308
  summary.by_phase[r.phase] = 0;
171
309
  for (const item of r.items) {
@@ -174,7 +312,9 @@ function cmdAuditUat(cwd, raw) {
174
312
  summary.by_category[cat] = (summary.by_category[cat] || 0) + 1;
175
313
  }
176
314
  }
177
- output({ results, summary }, raw, undefined);
315
+ // #3805: acknowledged files surface as a COUNT (audit-open's honesty
316
+ // model: the marker fired, the items are suppressed, both facts visible).
317
+ output({ results, summary, acknowledged_files: acknowledgedFiles }, raw, undefined);
178
318
  }
179
319
  // ─── cmdRenderCheckpoint ──────────────────────────────────────────────────────
180
320
  function cmdRenderCheckpoint(cwd, options = {}, raw) {
@@ -203,6 +343,12 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
203
343
  }
204
344
  // ─── parseCurrentTest ─────────────────────────────────────────────────────────
205
345
  function parseCurrentTest(content) {
346
+ // #3707-CR: this is the render-checkpoint path's own independent ingress
347
+ // into `tokenizeHeadings` (via the `parseFirstPendingTest` fallback below),
348
+ // separate from `parseUatItemsWithStats`'s. Normalize here too, ONCE, so a
349
+ // lone-CR document cannot hide its first pending row from this path either
350
+ // — see `normalizeLineEndings` for why.
351
+ content = normalizeLineEndings(content);
206
352
  // Use the seam to locate the ## Current Test section (ADR-1372 T5).
207
353
  // HTML-comment stripping within the section body is UAT-specific, so we keep
208
354
  // the comment removal caller-side after extracting the body.
@@ -263,24 +409,51 @@ function parseFirstPendingTest(content) {
263
409
  // tokenizeHeadings operates on the section body as a standalone document,
264
410
  // filtering to level-3 headings matching the UAT-specific "N. Name" pattern.
265
411
  // The UAT-specific item parsing (number extraction, result parsing) stays caller-side.
266
- const subHeadings = tokenizeHeadings(sectionBody).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
412
+ //
413
+ // #3078 blocker (same exposure as `parseUatItemsWithStats`): only a COLUMN-0
414
+ // heading is a test row — see `isColumnZeroHeading`. A `### N.` line indented
415
+ // <= 3 spaces INSIDE an `expected: |` value is value text, and must not
416
+ // register as a phantom heading and steal the real row's `result:` token.
417
+ //
418
+ // #3078 follow-up: tokenize a copy with the DELIMITER LINES of every
419
+ // wholly-INDENTED fenced block blanked out first (bodies untouched — column
420
+ // 0 is structure, indentation is content) — see
421
+ // `blankIndentedFenceDelimiters`. Without this, an
422
+ // indented ` ``` ` opener inside an `expected: |` value still reads as a
423
+ // real fence to `tokenizeHeadings` (CommonMark tolerates 1-3 leading
424
+ // spaces), which then hides every heading up to the next matching closer —
425
+ // including a later, genuinely column-0 `### N.` row.
426
+ //
427
+ // #3078 round-5 MAJOR: the row predicate is `isTestRowHeadingText`, the ONE
428
+ // shared helper `parseUatItemsWithStats` uses. It previously read
429
+ // `/^\d+\.\s+/` here while the audit path read `/^\d+\.(?!\d)/`, so
430
+ // `### 3.Foo` WAS a row on one path and was NOT on the other — two parse
431
+ // paths in one module disagreeing about the same grammar.
432
+ const subHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(sectionBody)).filter((h) => h.level === 3 && isTestRowHeadingText(h.text) && isColumnZeroHeading(sectionBody, h));
267
433
  for (let i = 0; i < subHeadings.length; i += 1) {
268
434
  const current = subHeadings[i];
269
435
  const next = subHeadings[i + 1];
270
- // Slice the block for this sub-test from the section body text
436
+ // Slice the block for this sub-test from the RAW section body text
271
437
  const block = next
272
438
  ? sectionBody.slice(current.offset, next.offset)
273
439
  : sectionBody.slice(current.offset);
274
440
  if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
275
441
  continue;
276
442
  }
277
- // Extract the UAT-specific number and name from the heading text
278
- const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
443
+ // Extract the UAT-specific number and name from the heading text via the
444
+ // SAME `parseTestRowHeadingText` seam the audit path uses (#3078 round-5
445
+ // MAJOR) — a name-mandatory `/^(\d+)\.\s+(.+)$/` here would have `continue`d
446
+ // past exactly the `### 3.` / `### 3.Foo` shapes the shared predicate just
447
+ // admitted, reintroducing the divergence one line below the fix.
448
+ const headingParts = parseTestRowHeadingText(current.text);
279
449
  if (!headingParts)
280
450
  continue;
281
- const testNumber = parseInt(headingParts[1], 10);
282
- const testName = headingParts[2].trim();
283
- const expected = parseExpectedFromTestBlock(block);
451
+ const testNumber = headingParts.number;
452
+ const testName = headingParts.name;
453
+ // #3078 blocker: clip the block at its first fence opener before handing
454
+ // it to `parseExpectedFromTestBlock`, so a raw read cannot reach into
455
+ // fence-hidden content — including a LATER row's own `expected:` line.
456
+ const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
284
457
  if (!expected) {
285
458
  error(`Pending UAT test ${testNumber} is missing an expected field`);
286
459
  }
@@ -293,20 +466,122 @@ function parseFirstPendingTest(content) {
293
466
  }
294
467
  return null;
295
468
  }
296
- function parseExpectedFromTestBlock(block) {
297
- const expectedBlockMatch = block.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m)
298
- || block.match(/^expected:\s*\|\n([\s\S]+)/m);
299
- if (expectedBlockMatch) {
300
- return expectedBlockMatch[1]
469
+ /**
470
+ * CRLF (#3078, found while hardening the scalar reader): the opener pattern
471
+ * demanded a BARE `\n` immediately after the `|`, so on a CRLF document
472
+ * `expected: |\r\n` never matched the block-scalar arm at all — control fell
473
+ * through to the INLINE arm, which happily captured the pipe character itself
474
+ * and published `expected: "|"`, discarding the entire multi-line value with no
475
+ * trace. `\r?` on the opener plus a per-line `\r` strip on the body fixes it.
476
+ * `(?:[1-9][+-]?|[+-][1-9]?)?` additionally admits the `|-` / `|+` chomping
477
+ * indicators AND the explicit indentation indicator (`|2`, `|2-`, `|-2`, ...,
478
+ * in either order per the YAML header grammar), keeping this reader in step
479
+ * with the column-0 heading rule (an indented heading inside a scalar body is
480
+ * otherwise a `expected: |-` or `expected: |2` value would be structurally
481
+ * masked but then read as the literal string `"|-"` / `"|2"` by the same
482
+ * fall-through.
483
+ *
484
+ * `[|>]` (#3078 follow-up): the `>` FOLDED-scalar family (`>`, `>-`, `>+`,
485
+ * `>2`, `>2+`, ...) hit the exact same fall-through as the CRLF/`|-`/`|+`
486
+ * bugs above — the opener only ever matched `|`, so `expected: >` fell to the
487
+ * inline arm and published the literal `">"` as the value, discarding the
488
+ * whole scalar. The opener character is now captured (group 1) so the caller
489
+ * can apply YAML's fold semantics for `>` while leaving `|` untouched.
490
+ *
491
+ * TRAILING COMMENT (#3078 round-6 MINOR 1): YAML permits a comment after a
492
+ * block-scalar header — `expected: | # sample`, `reason: >- # note` are both
493
+ * legal and open a scalar exactly as the bare forms do. The grammar was
494
+ * `$`-anchored immediately after the indicator, so those headers matched
495
+ * NEITHER `extractScalarField`'s opener (the value silently fell through to
496
+ * the inline arm and published the literal `"|"`) NOR
497
+ * `ANY_KEY_SCALAR_HEADER_LINE_RE` (so `countUnattributedIndentedRows` treated
498
+ * the scalar's own indented body heading as an unattributed lost row — a FALSE
499
+ * parse gap). `(?:#[^\r\n]*)?` closes both at the single shared source.
500
+ */
501
+ const SCALAR_HEADER_BODY = String.raw `[ \t]*([|>])(?:[1-9][+-]?|[+-][1-9]?)?[ \t]*(?:#[^\r\n]*)?`;
502
+ /**
503
+ * Build the block-scalar HEADER grammar for an arbitrary `key:` — the ONE
504
+ * source shared by `expected:`, `reason:` and `blocked_by:` (#3078 MINOR 2:
505
+ * `reason:`/`blocked_by:` previously had no block-scalar grammar of their own
506
+ * at all, and silently published the literal `"|"` / `">"` for a `|`/`>`
507
+ * value, discarding it). A key is always a hardcoded literal at each call
508
+ * site in this module (never untrusted input), so no escaping is needed.
509
+ */
510
+ function scalarHeaderFor(key) {
511
+ return String.raw `${key}:${SCALAR_HEADER_BODY}`;
512
+ }
513
+ /**
514
+ * ANY key's block-scalar HEADER line (#3078 MINOR 1), matched against ONE
515
+ * already-CR-stripped source line instead of against a multi-line block.
516
+ * Derived from the SAME `SCALAR_HEADER_BODY` source `scalarHeaderFor` uses
517
+ * so the opener grammars (`|`, `|-`, `|+`, `|2`, `|-2`, `>`, `>-`, `>+`, `>2+`,
518
+ * ...) cannot drift between them — the generative-divergence class this repo
519
+ * pins elsewhere.
520
+ *
521
+ * `countUnattributedIndentedRows` walks back from an indented `### N.`-shaped
522
+ * line to the nearest preceding column-0 line and asks whether THAT line
523
+ * opened a block scalar that still owns the indented line as its body.
524
+ * Testing only an `expected:`-ONLY grammar there meant an indented
525
+ * heading-shaped line inside ANY OTHER block scalar — `reported: |`
526
+ * (templates/UAT.md), `reason: |`, a verbatim user response containing
527
+ * ` ### 9. Section Nine` — was miscounted as a lost row even though nothing
528
+ * is missing. YAML's indentation rule (any column-0 line terminates a scalar)
529
+ * does not care WHICH key opened the scalar, only that a `[|>]`-family opener
530
+ * did, so the walk-back only needs to recognise the opener grammar, not the
531
+ * specific key.
532
+ */
533
+ const ANY_KEY_SCALAR_HEADER_LINE_RE = new RegExp(String.raw `^[A-Za-z_][\w-]*:${SCALAR_HEADER_BODY}$`);
534
+ /**
535
+ * Apply YAML FOLDED-scalar (`>`) line-joining to an already-dedented,
536
+ * CRLF-stripped block-scalar body: lines within a paragraph (no blank line
537
+ * between them) join with a single space; a blank line between paragraphs
538
+ * becomes a literal `\n` in the result. `|` (LITERAL) bodies are returned
539
+ * unchanged — folding is `>`-only.
540
+ */
541
+ function foldScalarBody(body) {
542
+ const lines = body.split('\n');
543
+ const paragraphs = [];
544
+ let current = [];
545
+ for (const line of lines) {
546
+ if (line === '') {
547
+ paragraphs.push(current.join(' '));
548
+ current = [];
549
+ }
550
+ else {
551
+ current.push(line);
552
+ }
553
+ }
554
+ paragraphs.push(current.join(' '));
555
+ return paragraphs.join('\n');
556
+ }
557
+ /**
558
+ * Extract a YAML-lite `key:` field's value from `block` — block-scalar
559
+ * (`|`/`>` family, dedented and, for `>`, YAML-folded) OR plain inline.
560
+ * Generalized from the `expected:`-only reader (#3078 MINOR 2) so `reason:`
561
+ * and `blocked_by:` — which previously had NO block-scalar grammar at all and
562
+ * silently published the literal `"|"` / `">"` for a multi-line value,
563
+ * discarding it — go through the exact same opener grammar and fold
564
+ * semantics instead of a third, hand-rolled dialect.
565
+ */
566
+ function extractScalarField(block, key) {
567
+ const opener = String.raw `^${scalarHeaderFor(key)}\r?\n`;
568
+ const blockMatch = block.match(new RegExp(`${opener}([\\s\\S]*?)(?=^\\w[\\w-]*:\\s)`, 'm'))
569
+ || block.match(new RegExp(`${opener}([\\s\\S]+)`, 'm'));
570
+ if (blockMatch) {
571
+ const openerChar = blockMatch[1];
572
+ const dedented = blockMatch[2]
301
573
  .split('\n')
302
- .map((line) => line.replace(/^ {2}/, ''))
574
+ .map((line) => line.replace(/\r$/, '').replace(/^ {2}/, ''))
303
575
  .join('\n')
304
576
  .trim();
577
+ return openerChar === '>' ? foldScalarBody(dedented) : dedented;
305
578
  }
306
- const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m);
307
- return expectedInlineMatch ? expectedInlineMatch[1].trim() : null;
579
+ const inlineMatch = block.match(new RegExp(String.raw `^${key}:\s*(.+)\s*$`, 'm'));
580
+ return inlineMatch ? inlineMatch[1].trim() : null;
581
+ }
582
+ function parseExpectedFromTestBlock(block) {
583
+ return extractScalarField(block, 'expected');
308
584
  }
309
- const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical
310
585
  const CHECKPOINT_FRAMES = {
311
586
  english: {
312
587
  banner: 'CHECKPOINT: Verification Required',
@@ -409,52 +684,6 @@ function resolveCheckpointFrame(responseLanguage) {
409
684
  const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().normalize('NFC').toLowerCase()];
410
685
  return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
411
686
  }
412
- // Approximate terminal-cell width. East Asian Width W/F code points occupy two
413
- // cells, while Unicode combining marks occupy no additional cell beyond their
414
- // base character. Counting only W/F ranges is insufficient for scripts such as
415
- // Devanagari: Hindi vowel signs and viramas are combining marks, and treating
416
- // each as a full cell visibly shifts the checkpoint box's right border.
417
- function isWideCodePoint(codePoint) {
418
- return ((codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
419
- codePoint === 0x2329 || codePoint === 0x232a ||
420
- (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals .. CJK Symbols and Punctuation
421
- (codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana .. CJK Compatibility
422
- (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A
423
- (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
424
- (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi Syllables
425
- (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
426
- (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
427
- (codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms
428
- (codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms
429
- (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
430
- (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary
431
- );
432
- }
433
- // Non-spacing/enclosing marks and format controls occupy zero terminal cells.
434
- // Spacing combining marks (General_Category=Mc), such as Devanagari vowel
435
- // signs, still advance the cursor and must contribute one column.
436
- const ZERO_WIDTH_MARK_RE = /\p{gc=Mn}|\p{gc=Me}|\p{gc=Cf}/u;
437
- // Iterates by Unicode code point (not UTF-16 code unit) so astral characters
438
- // are measured once, not as two surrogate units.
439
- function displayWidth(text) {
440
- let width = 0;
441
- for (const ch of text) {
442
- if (ZERO_WIDTH_MARK_RE.test(ch))
443
- continue;
444
- width += isWideCodePoint(ch.codePointAt(0)) ? 2 : 1;
445
- }
446
- return width;
447
- }
448
- // Pads `text` into a `║ text… ║` line matching CHECKPOINT_BOX_WIDTH. Content
449
- // that overflows the box (a longer translated string) is left unpadded rather
450
- // than truncated — a slightly ragged border beats losing text.
451
- function checkpointBoxLine(text) {
452
- const innerWidth = CHECKPOINT_BOX_WIDTH - 2;
453
- const content = ` ${text}`;
454
- const padLength = innerWidth - displayWidth(content);
455
- const padded = padLength > 0 ? content + ' '.repeat(padLength) : content;
456
- return `║${padded}║`;
457
- }
458
687
  const RTL_ISOLATE = '\u2067';
459
688
  const POP_DIRECTIONAL_ISOLATE = '\u2069';
460
689
  function isolateCheckpointFrameText(text, frame) {
@@ -467,51 +696,777 @@ function buildCheckpoint(currentTest, responseLanguage) {
467
696
  const banner = isolateCheckpointFrameText(frame.banner, frame);
468
697
  const instruction = isolateCheckpointFrameText(frame.instruction, frame);
469
698
  return [
470
- '╔══════════════════════════════════════════════════════════════╗',
471
- checkpointBoxLine(banner),
472
- '╚══════════════════════════════════════════════════════════════╝',
699
+ `### ${banner}`,
473
700
  '',
474
701
  `**Test ${currentTest.number}: ${currentTest.name}**`,
475
702
  '',
476
703
  currentTest.expected,
477
704
  '',
478
- '──────────────────────────────────────────────────────────────',
479
- instruction,
480
- '──────────────────────────────────────────────────────────────',
705
+ '---',
706
+ '',
707
+ `**${instruction}**`,
481
708
  ].join('\n');
482
709
  }
483
710
  // ─── parseUatItems ────────────────────────────────────────────────────────────
484
- function parseUatItems(content) {
711
+ /**
712
+ * Result tokens treated as PASSING (#3707 defect 1). Deliberately MINIMAL —
713
+ * that minimality is the point. Every token NOT in this set surfaces as an
714
+ * outstanding item, mirroring the fail-safe direction `parseGapsItems`
715
+ * already documents for this exact false-negative class (#2286): a project
716
+ * that invents a novel pass-word gets a visible, correctable false positive
717
+ * (an extra row an agent can dismiss) rather than today's invisible drop (a
718
+ * genuinely outstanding row silently vanishing with no trace). This was the
719
+ * issue's one open design question and was decided deliberately, here, in
720
+ * favor of the fail-safe direction over a larger "known synonyms" allowlist.
721
+ */
722
+ const UAT_PASS_RESULTS = new Set(['pass', 'passed']);
723
+ /**
724
+ * A fenced-code OPENER line at COLUMN 0 (``` or ~~~).
725
+ *
726
+ * Deliberately NOT the CommonMark `{0,3}`-space form (#3078 simplification):
727
+ * inside this module a fence only ever means "document structure the tokenizer
728
+ * hid from us", and every structural fence in a UAT file starts at column 0. An
729
+ * INDENTED fence run is, by construction, part of an `expected: |` block-scalar
730
+ * value — the ordinary way a UAT row reproduces a code sample verbatim — and
731
+ * must stay invisible to the clipper, or the very field it exists to protect
732
+ * gets truncated at its own sample. Column 0 is the whole rule for every
733
+ * fence-aware scan THIS MODULE writes directly against raw block text (this
734
+ * one, `dropTopLevelFencedRegions`'s `delimRe`). It does NOT extend to
735
+ * `tokenizeHeadings`, which is a third-party CommonMark scanner with its own
736
+ * {0,3}-space fence tolerance baked in — see `blankIndentedFenceDelimiters`
737
+ * for how an indented delimiter is kept from reaching that scanner at all.
738
+ */
739
+ const FENCE_OPENER_RE = /^(?:`{3,}|~{3,})/;
740
+ /**
741
+ * The CommonMark-tolerant (0-3 leading spaces) twin of `FENCE_OPENER_RE`,
742
+ * used ONLY by the inner delimiter-shape sweep in
743
+ * `blankIndentedFenceDelimiters` (#3078 round-7 MAJOR). That sweep runs
744
+ * strictly BETWEEN a neutralised block's own (already-blanked) delimiters,
745
+ * looking for a line `tokenizeHeadings` would itself read as a fence opener
746
+ * once those delimiters are gone — and `tokenizeHeadings` tolerates up to
747
+ * three leading spaces on an opener, so a column-0-anchored test here misses
748
+ * an INDENTED delimiter-shaped line and lets the mutation manufacture exactly
749
+ * the structure `scanFencedBlocks` never saw. `FENCE_OPENER_RE` itself stays
750
+ * column-0-anchored: every OTHER call site depends on that anchoring.
751
+ */
752
+ const INDENT_TOLERANT_DELIM_RE = /^ {0,3}(?:`{3,}|~{3,})/;
753
+ /**
754
+ * A raw source line whose shape is a UAT `### N.` test heading — the line-level
755
+ * twin of the `h.level === 3 && /^\d+\.(?!\d)/` token filter in
756
+ * `parseUatItemsWithStats`, and anchored at COLUMN 0 to match that filter's
757
+ * `isColumnZeroHeading` guard exactly. Used ONLY to count headings that
758
+ * `tokenizeHeadings` suppressed (a fence-straddled row), never to parse one:
759
+ * the two counts must be derived by the SAME rule or the shortfall they
760
+ * bracket over- or under-reports.
761
+ */
762
+ const TEST_HEADING_LINE_RE = /^#{3}(?!#)[ \t]+\d+\.(?!\d)/;
763
+ /**
764
+ * THE test-row grammar, in ONE place (#3078 round-5 MAJOR).
765
+ *
766
+ * `parseFirstPendingTest` (the render-checkpoint path) and
767
+ * `parseUatItemsWithStats` (the audit path) each filtered level-3 headings with
768
+ * their own literal — `/^\d+\.\s+/` vs `/^\d+\.(?!\d)/` — so the two paths in
769
+ * this one module DISAGREED about what a test row is: `### 3.Foo` (name squished
770
+ * against the dot) and `### 3.` (no name at all) were rows to the audit and were
771
+ * silently NOT rows to the checkpoint. That is the generative-divergence class
772
+ * this repo requires closed with a shared definition rather than two literals
773
+ * kept in sync by hand.
774
+ *
775
+ * The AUDIT rule wins, deliberately: `^\d+\.(?!\d)` admits `### 3.` and
776
+ * `### 3.Foo` (a heading missing or squishing its name still contributes to
777
+ * `headingsSeen`/items instead of vanishing from BOTH — the same silent-drop
778
+ * symptom the parse-gap flag exists to catch) while the `(?!\d)` lookahead keeps
779
+ * a DOTTED-SECTION heading like `### 1.2.3 Overview` out, since that is a
780
+ * document outline number, not test row 1. `TEST_HEADING_LINE_RE` /
781
+ * `INDENTED_TEST_HEADING_LINE_RE` are the raw-source-line twins of this same
782
+ * rule and carry the identical `\d+\.(?!\d)` core.
783
+ */
784
+ const TEST_ROW_HEADING_TEXT_RE = /^\d+\.(?!\d)/;
785
+ /** True when a level-3 heading's TEXT is a UAT test row. See `TEST_ROW_HEADING_TEXT_RE`. */
786
+ function isTestRowHeadingText(text) {
787
+ return TEST_ROW_HEADING_TEXT_RE.test(text);
788
+ }
789
+ /**
790
+ * Split a test-row heading's text into its number and display name — the
791
+ * extraction twin of `isTestRowHeadingText`, shared by both parse paths for the
792
+ * same anti-divergence reason. Returns `null` for text the predicate rejects.
793
+ *
794
+ * A bare `### 3.` (no trailing name) falls back to the heading's own trimmed
795
+ * text (`3.`) rather than yielding an empty name.
796
+ */
797
+ function parseTestRowHeadingText(text) {
798
+ if (!isTestRowHeadingText(text))
799
+ return null;
800
+ const parts = text.match(/^(\d+)\.\s*(.*)$/);
801
+ if (!parts)
802
+ return null;
803
+ return { number: parseInt(parts[1], 10), name: parts[2].trim() || text.trim() };
804
+ }
805
+ /**
806
+ * The INDENTED (1-3 leading spaces, CommonMark-legal) twin of
807
+ * `TEST_HEADING_LINE_RE` — used by the SHORTFALL SCAN ONLY, never by the parse
808
+ * gate.
809
+ *
810
+ * #3078 round-4 MAJOR 2: `isColumnZeroHeading` refusing to PARSE an indented
811
+ * `### N.` row is deliberate and stays (no `*UAT*.md` in the tree indents one).
812
+ * But the COUNTING side inherited that anchor through
813
+ * `TEST_HEADING_LINE_RE`, so a heading the parse gate rejected could never
814
+ * reach `headingsSeen` either: ` ### 1. Indented Row` with `result: pending`
815
+ * — which origin/next's unanchored `###\s*(\d+)\.` did surface — yielded no
816
+ * item, no gap, no count and no trace at all. Refusing to parse is defensible;
817
+ * vanishing silently is the exact defect class this issue exists to close, so
818
+ * the row now surfaces as a PARSE GAP instead.
819
+ */
820
+ const INDENTED_TEST_HEADING_LINE_RE = /^[ \t]+#{3}(?!#)[ \t]+\d+\.(?!\d)/;
821
+ /**
822
+ * True when `heading` starts at COLUMN 0 of its source line in `content`.
823
+ *
824
+ * The UAT test-row contract (#3078): a `### N.` row heading is structure ONLY
825
+ * at column 0. `tokenizeHeadings` implements CommonMark, which tolerates up to
826
+ * 3 leading spaces on an ATX heading — and that single over-permissive rule is
827
+ * what let a `### 3. Fake Row` line sitting INSIDE an `expected: |` value
828
+ * register as a phantom heading, open a block, and STEAL the real row's
829
+ * `result:` line, dropping a genuinely outstanding row from `items`. A scalar
830
+ * body is indented BY CONSTRUCTION (that is what makes it a body), so requiring
831
+ * column 0 makes every such line inert without the parser needing any notion of
832
+ * YAML block scalars at all. The shipped `templates/UAT.md` writes every `### N.`
833
+ * heading at column 0, and no UAT document in the tree indents one.
834
+ *
835
+ * `HeadingToken.offset` is the offset of the heading LINE's first character, so
836
+ * a column-0 heading is exactly one whose first character is the `#` itself.
837
+ */
838
+ function isColumnZeroHeading(content, heading) {
839
+ return content.charCodeAt(heading.offset) === 0x23 /* '#' */;
840
+ }
841
+ /**
842
+ * An INDENTED (1-3 leading spaces, never 0) fenced-code delimiter line.
843
+ * Column 0 is intentionally EXCLUDED — a column-0 fence is real document
844
+ * structure and `tokenizeHeadings` handling it is correct; only the
845
+ * CommonMark-legal 1-3-space tolerance is the problem this targets.
846
+ */
847
+ const INDENTED_FENCE_DELIM_RE = /^ {1,3}(?:`{3,}|~{3,})/;
848
+ /**
849
+ * Return `content` with the two DELIMITER LINES of every wholly-INDENTED
850
+ * fenced block overwritten by spaces, byte-length- and line-count-preserving,
851
+ * so every downstream offset and line index still lines up against the
852
+ * original document. The block's BODY is left verbatim — see "COLUMN 0 IS
853
+ * STRUCTURE, INDENTATION IS CONTENT" below for why that is the point, not an
854
+ * oversight.
855
+ *
856
+ * Why (#3078 follow-up, escalated design call, answered as option (b)):
857
+ * dropping `maskBlockScalarBodies` in favor of the column-0 heading filter
858
+ * (`isColumnZeroHeading`) fixed the phantom-heading theft, but it silently
859
+ * dropped a SECOND thing masking used to do — hide an INDENTED fence
860
+ * delimiter from `tokenizeHeadings` itself. `tokenizeHeadings` is a
861
+ * CommonMark scanner with its own {0,3}-space fence tolerance; a 1-3-space
862
+ * ` ``` ` inside an `expected: |` scalar body still opens a fence AS FAR AS
863
+ * THAT SCANNER IS CONCERNED, and every heading between it and its matching
864
+ * (or absent) closer — including a LATER, genuinely column-0 `### N.` row —
865
+ * is hidden from the token stream entirely, not merely mis-filtered. The
866
+ * column-0 heading filter cannot recover a heading the tokenizer never
867
+ * returned in the first place.
868
+ *
869
+ * This is deliberately the SAME "column 0 is structure, anything else is
870
+ * value text" rule already applied to headings (`isColumnZeroHeading`) and to
871
+ * this module's own raw-text fence scans (`FENCE_OPENER_RE`,
872
+ * `dropTopLevelFencedRegions`'s `delimRe`) — extended to the one place that
873
+ * rule cannot be expressed as a post-hoc filter, because the tokenizer
874
+ * consumes the fence delimiter before this module ever sees a token for it.
875
+ * It carries no YAML knowledge whatsoever (no notion of `expected:`, `|`,
876
+ * indentation width, or scalar bodies) — it blanks an indented delimiter LINE
877
+ * unconditionally, wherever it appears, the same context-free way the other
878
+ * column-0 rules do.
879
+ *
880
+ * PAIRED, NOT UNCONDITIONAL (#3078 round-4 MAJOR 1). Blanking every indented
881
+ * delimiter LINE on sight perturbs fence PAIRING in BOTH directions, because
882
+ * CommonMark lets a COLUMN-0 fence be closed by a delimiter indented up to
883
+ * three spaces:
884
+ * - a column-0 opener closed by an INDENTED closer had its closer blanked,
885
+ * so the fence never closed for `tokenizeHeadings` and every later row —
886
+ * including a genuinely column-0 `### N.` with an outstanding `result:` —
887
+ * was swallowed;
888
+ * - the mirror, an INDENTED opener closed by a COLUMN-0 closer, had its
889
+ * opener blanked, PROMOTING that closer into an opener and swallowing
890
+ * everything after it instead.
891
+ * Both documents are legal CommonMark that renders correctly, so neither may
892
+ * lose content. The decision is therefore made per FENCED BLOCK, not per line:
893
+ * a block is neutralised only when it is indented at BOTH ends (or is an
894
+ * indented opener that never closes at all) — i.e. when nothing about it is
895
+ * column-0 document structure. That is exactly the intended case, an indented
896
+ * fence pair living wholly inside an `expected: |` block-scalar value, which
897
+ * is why the helper exists; any block with a column-0 delimiter at either end
898
+ * is left completely alone so its pairing reaches the tokenizer unchanged.
899
+ *
900
+ * COLUMN 0 IS STRUCTURE, INDENTATION IS CONTENT — and that rule is applied in
901
+ * ONE direction only, to the DELIMITERS. Only the two delimiter lines of a
902
+ * neutralised block are blanked; its body is left exactly as written. A
903
+ * column-0 `### N.` sitting between two indented delimiters therefore becomes
904
+ * a real heading, and a `result:` line after it belongs to that heading. That
905
+ * is CORRECT under this rule, not theft: by the very rule that selected the
906
+ * block for neutralisation, an indented delimiter is not a fence at all, so
907
+ * there is no fence for the column-0 line to be "inside" of. The document is
908
+ * malformed; reading it this way is the consistent reading, and it is PINNED
909
+ * by test (see "#3078 round 5: column 0 is structure" in tests/uat.test.cjs).
910
+ * Blanking the whole block open-to-close was tried and REVERTED: it destroys
911
+ * content legitimately living between the delimiters, and — for the
912
+ * unterminated-opener case, where the "body" runs to EOF — silently deletes
913
+ * the entire remainder of the document, dropping every later row.
914
+ *
915
+ * NO SECOND FENCE DIALECT: the blocks come from `scanFencedBlocks`
916
+ * (markdown-sectionizer.cts), the SAME exported CommonMark state machine
917
+ * `stripFencedCode` — and therefore `tokenizeHeadings` — runs. Backtick AND
918
+ * tilde runs, run length >= 3, the <= 3-space indent tolerance, a closer of
919
+ * the same char with run length >= the opener and no trailing text, info
920
+ * strings (including the "a backtick fence's info string may not contain a
921
+ * backtick" rule), and the unterminated-at-EOF case are all classified by that
922
+ * engine, not re-derived here. This module contributes only the column-0
923
+ * question — which delimiter lines are structure — via
924
+ * `INDENTED_FENCE_DELIM_RE`.
925
+ *
926
+ * LINE-BASED by construction (`content.split('\n')` / `.join('\n')`), never
927
+ * character-array splicing — the exact bug class (`Array.from(content)`
928
+ * code-point indexing against UTF-16 offsets) that made the original
929
+ * `maskBlockScalarBodies` corrupt astral-character documents. A line's own
930
+ * `.length` and `' '.repeat(line.length)` are measured in the same (UTF-16)
931
+ * units as the string itself, so this cannot misalign regardless of
932
+ * code-point framing, and CRLF survives untouched: `split('\n')` leaves any
933
+ * `\r` attached to the end of its line, and blanking that line replaces the
934
+ * `\r` with a space exactly like every other character on it — `join('\n')`
935
+ * then reproduces the original line count and total length exactly.
936
+ */
937
+ function blankIndentedFenceDelimiters(content) {
938
+ const lines = content.split('\n');
939
+ const isIndentedDelimiter = (idx) => idx >= 0 && idx < lines.length && INDENTED_FENCE_DELIM_RE.test(lines[idx].replace(/\r$/, ''));
940
+ const blank = new Set();
941
+ for (const block of scanFencedBlocks(lines)) {
942
+ // A column-0 OPENER is real document structure: leave the whole block
943
+ // alone, closer included, so an indented closer still closes it.
944
+ if (!isIndentedDelimiter(block.openLineIdx))
945
+ continue;
946
+ // An indented opener paired with a COLUMN-0 closer is likewise real
947
+ // structure at its far end — blanking the opener would promote that closer
948
+ // into an opener and hide everything after it.
949
+ if (block.closeLineIdx !== -1 && !isIndentedDelimiter(block.closeLineIdx))
950
+ continue;
951
+ // DELIMITERS ONLY — never the body. THE RULE: column 0 is structure,
952
+ // indentation is content. An indented delimiter therefore neutralises
953
+ // ITSELF, but it never hides column-0 structure sitting between
954
+ // delimiters: a column-0 `### N.` there IS a heading, and a `result:`
955
+ // after it IS that heading's. Widening this to the whole block was tried
956
+ // (#3078 round 5) and reverted — it deletes content that legitimately
957
+ // lives between the delimiters, and on an UNTERMINATED indented opener it
958
+ // blanks to EOF, taking every later row with it. Pinned by test; do not
959
+ // "fix" it back.
960
+ blank.add(block.openLineIdx);
961
+ if (block.closeLineIdx !== -1)
962
+ blank.add(block.closeLineIdx);
963
+ // #3078 round-6 MAJOR: the two fence engines must not disagree about the
964
+ // text handed downstream. `scanFencedBlocks` classified the ORIGINAL
965
+ // lines, but `tokenizeHeadings` re-runs its own CommonMark state machine
966
+ // over this MUTATED copy. A COLUMN-0 delimiter-shaped line that was mere
967
+ // fence CONTENT in the original — e.g. a ```-run inside an indented
968
+ // ````-pair — is PROMOTED to a real opener the instant its enclosing
969
+ // delimiters are blanked, hiding every later heading to EOF. Blank those
970
+ // too, so the mutation cannot manufacture structure that the classifying
971
+ // engine never saw.
972
+ //
973
+ // DELIMITER-SHAPED LINES ONLY. A column-0 `### N.` heading between
974
+ // neutralised delimiters stays a heading (the pinned "column 0 is
975
+ // structure" behaviour), and the field lines of a row living between two
976
+ // rows' scalars survive untouched — both are pinned by test. This adds
977
+ // exactly one shape to the blank set: a line that would itself be read as
978
+ // a fence delimiter.
979
+ const inner = block.closeLineIdx === -1 ? lines.length : block.closeLineIdx;
980
+ for (let i = block.openLineIdx + 1; i < inner; i += 1) {
981
+ if (INDENT_TOLERANT_DELIM_RE.test(lines[i].replace(/\r$/, '')))
982
+ blank.add(i);
983
+ }
984
+ }
985
+ if (blank.size === 0)
986
+ return content;
987
+ return lines.map((line, i) => (blank.has(i) ? ' '.repeat(line.length) : line)).join('\n');
988
+ }
989
+ /**
990
+ * Truncate `block` at its first TOP-LEVEL fenced-code opener (#3078 blocker).
991
+ *
992
+ * `parseExpectedFromTestBlock` must read the RAW block (an `expected: |` scalar
993
+ * may legitimately reproduce fenced-looking text verbatim, so a fence-STRIPPED
994
+ * copy would corrupt the field). But a raw block slice can run straight into
995
+ * content that `tokenizeHeadings` correctly hid inside a fence — including a
996
+ * LATER test row's own `expected:` line, which the earlier row then published
997
+ * as its own. Clipping at the fence opener bounds the raw read to the part of
998
+ * the block the tokenizer also considered visible.
999
+ *
1000
+ * Column-0 fences only (`FENCE_OPENER_RE`): a fenced sample nested inside a
1001
+ * legitimate `expected: |` value is indented by construction, so it is invisible
1002
+ * here and cannot clip the very field this exists to preserve.
1003
+ */
1004
+ function clipBlockAtFirstFence(block) {
1005
+ const rawLines = block.split('\n');
1006
+ let firstFenceLine = -1;
1007
+ for (let i = 0; i < rawLines.length; i += 1) {
1008
+ if (FENCE_OPENER_RE.test(rawLines[i])) {
1009
+ firstFenceLine = i;
1010
+ break;
1011
+ }
1012
+ }
1013
+ if (firstFenceLine === -1)
1014
+ return block;
1015
+ const beforeFence = rawLines.slice(0, firstFenceLine).join('\n');
1016
+ if (parseExpectedFromTestBlock(beforeFence))
1017
+ return beforeFence;
1018
+ // #3078 follow-up MINOR 2: an `expected:` field appearing AFTER a fence has
1019
+ // CLOSED is not a theft risk — only content strictly INSIDE the fence must
1020
+ // stay hidden. The plain "clip at first opener" result above silently
1021
+ // discards a late `expected:` even when it sits outside every fence.
1022
+ // Reconstruct the block with every top-level FENCED REGION dropped, keeping
1023
+ // RAW text everywhere else. This exposes a late `expected:` living after a
1024
+ // fence closes, while an `expected:` living strictly inside the fence is
1025
+ // dropped along with it and stays unreachable — the "inside a fence" vs.
1026
+ // "after a closed fence" split falls straight out of whether the
1027
+ // fence-tracking state machine below is OPEN or CLOSED at that line, not out
1028
+ // of position relative to the FIRST fence opener alone.
1029
+ const visible = dropTopLevelFencedRegions(rawLines);
1030
+ if (parseExpectedFromTestBlock(visible))
1031
+ return visible;
1032
+ return beforeFence;
1033
+ }
1034
+ /**
1035
+ * Reconstruct `rawLines` with every TOP-LEVEL fenced region removed. Mirrors
1036
+ * `stripFencedCode`'s own delimiter algorithm — a fence run of the SAME
1037
+ * character and at least the SAME length, with no trailing content, is what
1038
+ * closes an open fence — so "inside a fence" here means the same thing it means
1039
+ * to the rest of this module's fence handling. An UNTERMINATED fence (open at
1040
+ * EOF) drops everything from its opener to the end, same as `stripFencedCode`.
1041
+ *
1042
+ * Delimiters are recognised at COLUMN 0 only, for the reason given on
1043
+ * `FENCE_OPENER_RE`: an INDENTED fence run belongs to an `expected: |` value,
1044
+ * not to document structure, and must not open a region here.
1045
+ */
1046
+ function dropTopLevelFencedRegions(rawLines) {
1047
+ const kept = [];
1048
+ let openFence = null;
1049
+ const delimRe = /^(`{3,}|~{3,})(.*)$/;
1050
+ for (let i = 0; i < rawLines.length; i += 1) {
1051
+ const line = rawLines[i].replace(/\r$/, '');
1052
+ const m = delimRe.exec(line);
1053
+ if (m) {
1054
+ const char = m[1][0];
1055
+ const len = m[1].length;
1056
+ const trailing = m[2];
1057
+ if (openFence === null) {
1058
+ if (char === '`' && trailing.includes('`')) {
1059
+ // Not a valid fence opener (CommonMark: backtick info string must
1060
+ // not contain a backtick) — ordinary content.
1061
+ kept.push(rawLines[i]);
1062
+ continue;
1063
+ }
1064
+ openFence = { char, len };
1065
+ }
1066
+ else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
1067
+ openFence = null;
1068
+ }
1069
+ continue; // all delimiter lines are dropped, opener or closer
1070
+ }
1071
+ if (openFence === null)
1072
+ kept.push(rawLines[i]);
1073
+ // Lines inside an open fence are silently dropped.
1074
+ }
1075
+ return kept.join('\n');
1076
+ }
1077
+ /**
1078
+ * Count the INDENTED (1-3 space) `### N.` heading-shaped lines in `surface`
1079
+ * that are NOT the value text of a preceding `expected:` block scalar.
1080
+ *
1081
+ * Why the exclusion (#3078 round-4 MAJOR 2): the parse gate refuses BOTH
1082
+ * shapes for the same reason (column 0 is structure), but only one of them is
1083
+ * a lost ROW. A `### 3. Fake Row` line sitting inside an `expected: |` value is
1084
+ * the row's own published `expected:` string — already surfaced, verbatim, on
1085
+ * the item — so counting it would flag a parse gap against a document with
1086
+ * nothing missing (the pinned scalar-body behaviour). A ` ### 1. Indented
1087
+ * Row` that no scalar owns is a row the parser declined to read, and must be
1088
+ * visible as an unparsed block instead of silently clean.
1089
+ *
1090
+ * Attribution is structural and cheap: walk BACK from the indented heading to
1091
+ * the first non-blank line at column 0 (a block-scalar body is indented by
1092
+ * construction, and blank lines are legal inside one). The heading is scalar
1093
+ * VALUE exactly when that line is ANY `key:` scalar header — not `expected:`
1094
+ * only (#3078 MINOR 1: testing the `expected:`-only grammar false-positived
1095
+ * on an indented heading-shaped line inside a DIFFERENT block scalar, e.g. a
1096
+ * template-sanctioned `reported: |` holding verbatim user prose, or a
1097
+ * `reason: |` body) — per `ANY_KEY_SCALAR_HEADER_LINE_RE`, derived from the
1098
+ * SAME `[|>]`-family opener grammar the reader itself uses. No second opener
1099
+ * dialect, and no attempt to model YAML indentation levels.
1100
+ */
1101
+ function countUnattributedIndentedRows(surface) {
1102
+ const lines = surface.split('\n');
1103
+ // LINEAR, not quadratic (#3078 round-6 MINOR 2). The walk-back above was
1104
+ // re-scanned per indented row, so a document of N rows and N lines cost
1105
+ // O(N^2) — measured 4x per 2x on real input (1000 rows 20ms → 16000 rows
1106
+ // 3.6s). The walk only ever asks ONE question of the prefix — "which is the
1107
+ // nearest preceding non-blank COLUMN-0 line?" — and that is a running value,
1108
+ // so a single forward pass computes it for every line at once. The
1109
+ // ATTRIBUTION RULE IS UNCHANGED: a blank line and an indented line are both
1110
+ // transparent (a block-scalar body is indented by construction and may
1111
+ // contain blank lines), and the first line that is neither terminates the
1112
+ // scalar; the heading is value text exactly when THAT line is any key's
1113
+ // block-scalar header.
1114
+ const stripped = lines.map((line) => line.replace(/\r$/, ''));
1115
+ const nearestColumnZero = new Array(lines.length);
1116
+ let last = -1;
1117
+ for (let i = 0; i < stripped.length; i += 1) {
1118
+ nearestColumnZero[i] = last;
1119
+ const line = stripped[i];
1120
+ if (line.trim() !== '' && !/^[ \t]/.test(line))
1121
+ last = i;
1122
+ }
1123
+ let count = 0;
1124
+ for (let i = 0; i < lines.length; i += 1) {
1125
+ if (!INDENTED_TEST_HEADING_LINE_RE.test(lines[i]))
1126
+ continue;
1127
+ const owner = nearestColumnZero[i];
1128
+ const ownedByScalar = owner !== -1 && ANY_KEY_SCALAR_HEADER_LINE_RE.test(stripped[owner]);
1129
+ if (!ownedByScalar)
1130
+ count += 1;
1131
+ }
1132
+ return count;
1133
+ }
1134
+ /**
1135
+ * `headingsSeen` is the TOTAL parse-gap tally (every heading-shaped thing this
1136
+ * parser could not turn into an item). `shortfallBlocks` is the SUBSET of it
1137
+ * contributed by the fence-suppression shortfall scan below — the one gap class
1138
+ * this module documents as carrying an ACCEPTED OVER-REPORT (a closed-fence
1139
+ * documentation sample written with literal digits is indistinguishable from a
1140
+ * genuinely fence-straddled row; see the long comment at the scan itself).
1141
+ * Reported separately so a consumer that must decide whether to WITHHOLD a
1142
+ * derived number — as opposed to merely REPORT the gap — can tell "a row I
1143
+ * definitely could not read" from "a row I possibly mis-counted".
1144
+ *
1145
+ * #3707-CR: `src/planning-inspect.cts`'s `buildUatRows` does NOT destructure
1146
+ * this field (verified — it and `cmdAuditUat` both consume only `items` and
1147
+ * `headingsSeen`), correcting an earlier stated instruction that it did.
1148
+ * `shortfallBlocks` currently has NO production consumer outside this
1149
+ * function's own computation. It is retained on the return value anyway,
1150
+ * deliberately, as part of this function's published stats contract — tests
1151
+ * assert on the full `{ items, headingsSeen, shortfallBlocks }` shape, and
1152
+ * dropping a returned field is a wider, unrelated change than a line-ending
1153
+ * fix warrants. A future consumer that needs to distinguish an
1154
+ * accepted-over-report shortfall from the rest of `headingsSeen` (the
1155
+ * original design intent above) can still do so.
1156
+ */
1157
+ function parseUatItemsWithStats(content) {
1158
+ content = normalizeLineEndings(content);
485
1159
  const items = [];
486
- // Match test blocks: ### N. Name\nexpected: ...\nresult: ...\n
487
- // Accept both bare (result: pending) and bracketed (result: [pending]) formats (#2273)
488
- const testPattern = /###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*\[?(\w+)\]?(?:\n(?:reported|reason|blocked_by):\s*[^\n]*)?/g;
489
- let match;
490
- while ((match = testPattern.exec(content)) !== null) {
491
- const [, num, name, expected, result] = match;
492
- if (result === 'pending' || result === 'skipped' || result === 'blocked') {
493
- // Extract optional fields — limit to current test block (up to next ### or EOF)
494
- const afterMatch = content.slice(match.index);
495
- const nextHeading = afterMatch.indexOf('\n###', 1);
496
- const blockText = nextHeading > 0 ? afterMatch.slice(0, nextHeading) : afterMatch;
497
- const reasonMatch = blockText.match(/reason:\s*(.+)/);
498
- const blockedByMatch = blockText.match(/blocked_by:\s*(.+)/);
499
- const item = {
500
- test: parseInt(num, 10),
501
- name: name.trim(),
502
- expected: expected.trim(),
503
- result,
504
- category: categorizeItem(result, reasonMatch?.[1], blockedByMatch?.[1]),
505
- };
506
- if (reasonMatch)
507
- item.reason = reasonMatch[1].trim();
508
- if (blockedByMatch)
509
- item.blocked_by = blockedByMatch[1].trim();
510
- items.push(item);
1160
+ let headingsSeen = 0;
1161
+ let shortfallBlocks = 0;
1162
+ // Locate every `### N. Name` test heading across the WHOLE document (not
1163
+ // adjacency-matched against `result:`, #3707 defect 2) and slice each one's
1164
+ // own block from its heading to the next heading OF ANY LEVEL (or EOF) —
1165
+ // a trailing `## Gaps` section or an interleaved `### Notes` heading must
1166
+ // not be absorbed into the preceding test's block, else its unanchored
1167
+ // `reason:`/`blocked_by:` scans below bleed a Gaps entry's fields onto the
1168
+ // last test row.
1169
+ // #3078 blocker: only a COLUMN-0 heading is document structure here (see
1170
+ // `isColumnZeroHeading`). The filter is applied to the WHOLE token stream,
1171
+ // not just to the `### N.` rows, because an indented heading must not act as
1172
+ // a block BOUNDARY either — a `### 3. Fake Row` line inside an `expected: |`
1173
+ // value would otherwise truncate its own row's block just before the real
1174
+ // `result:` line and drop a genuinely outstanding row from `items`.
1175
+ //
1176
+ // #3078 follow-up: tokenize a copy with every indented fence delimiter
1177
+ // blanked out (`blankIndentedFenceDelimiters`) BEFORE the column-0 filter
1178
+ // ever runs. Otherwise an indented ` ``` ` opener inside an `expected: |`
1179
+ // value still opens a real fence as far as `tokenizeHeadings` (a
1180
+ // CommonMark scanner, {0,3}-space fence tolerance) is concerned, hiding
1181
+ // every heading up to its closer from the token stream entirely — a LATER,
1182
+ // genuinely column-0 `### N.` row is never returned as a token at all, so
1183
+ // no post-hoc filter over the token stream could recover it.
1184
+ const allHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(content)).filter((h) => isColumnZeroHeading(content, h));
1185
+ // #3707 follow-up MINOR: `^\d+\.` alone — a trailing name is OPTIONAL
1186
+ // (`### 3.` and `### 3.Foo`, without the space the old `\s+`-anchored
1187
+ // pattern required, both count) so a heading missing or squishing its name
1188
+ // still contributes to `headingsSeen`/items rather than being silently
1189
+ // excluded from BOTH — the same vanishing-row symptom the parse-gap flag
1190
+ // exists to catch, reachable here at the heading-filter layer instead.
1191
+ // #3078 round-5 MAJOR: that rule now lives in `isTestRowHeadingText` and is
1192
+ // shared verbatim with `parseFirstPendingTest`, which used to disagree.
1193
+ // Carry each match's own index into `allHeadings` from the filter pass
1194
+ // itself (security review finding 3) rather than re-deriving it via
1195
+ // `allHeadings.indexOf(current)` inside the loop below — the latter is an
1196
+ // O(n) scan per heading, making the whole loop O(n^2) in document size.
1197
+ const subHeadings = [];
1198
+ allHeadings.forEach((h, index) => {
1199
+ if (h.level === 3 && isTestRowHeadingText(h.text))
1200
+ subHeadings.push({ heading: h, index });
1201
+ });
1202
+ // #3078 blocker: `tokenizeHeadings` is fence-aware, so a BALANCED fence pair
1203
+ // that opens after one test row and closes after a later one makes every
1204
+ // `### N.` heading between them invisible — the rows are not merely
1205
+ // unparseable, they are absent from the token stream, so the loop below can
1206
+ // never count them and the file reports as CLEAN with an outstanding
1207
+ // `result: blocked` inside it. (origin/next's old whole-file regex did
1208
+ // surface those rows, making the silent drop a regression.) Comparing the
1209
+ // count of heading-SHAPED source lines against the headings the tokenizer
1210
+ // actually returned recovers the shortfall; each suppressed row counts
1211
+ // toward `headingsSeen`, so the file is flagged as a parse gap rather than
1212
+ // silently clean. The line scan is anchored at COLUMN 0 (`TEST_HEADING_LINE_RE`)
1213
+ // by the same rule the token filter uses, so a `### N.`-shaped line living
1214
+ // inside an `expected: |` value — which is value text, not a suppressed row —
1215
+ // cannot inflate the tally.
1216
+ //
1217
+ // #3078 round-7 HIGH — SYMMETRY IS THE INVARIANT. BOTH SIDES OF THIS
1218
+ // COMPARISON ARE WHOLE-DOCUMENT. DO NOT SCOPE EITHER ONE. Read this whole
1219
+ // comment before "optimising" the `## Notes` noise back out; three separate
1220
+ // HIGH-severity silent false-cleans have been produced by three separate
1221
+ // attempts to be clever about scope here, and every one of them was a
1222
+ // regression against origin/next's plain whole-file regex.
1223
+ //
1224
+ // History of the failures, so they are not re-derived:
1225
+ // - round-6 HIGH: the raw line scan was SECTION-SCOPED to the `## Tests`
1226
+ // body while `subHeadings` stayed whole-document, so a legal
1227
+ // `### 9. Old / result: pass` row in a preceding `## Prior` section
1228
+ // decremented the shortfall by one and SILENTLY DISABLED the
1229
+ // fence-straddle detector.
1230
+ // - round-7 HIGH: "equalising" that by ALSO scoping the token side to the
1231
+ // section's offset span made the two counters agree with each other but
1232
+ // left the PARSE side whole-document — so a `### N.` row living OUTSIDE
1233
+ // the first `## Tests` section was parsed and surfaced normally when
1234
+ // visible, yet vanished with NO item AND NO parse_gap the moment a fence
1235
+ // straddled it: neither side of the comparison covered it. Reproduced
1236
+ // three ways — a straddle inside a `## Regression Tests` section, a
1237
+ // straddle inside a SECOND `## Tests` section (`collectSection` takes the
1238
+ // FIRST match only), and, as control, the identical straddle in a file
1239
+ // with no `## Tests` heading at all, which alone reported correctly.
1240
+ //
1241
+ // THE RULE: the parse side reads rows wherever they live in the document, so
1242
+ // the counting side must too. Scan shaped `### N.` lines over the ENTIRE
1243
+ // document and compare against ALL tokenized row headings. Any narrowing of
1244
+ // one side that is not matched by the other manufactures a blind spot, and a
1245
+ // blind spot here is a SILENT FALSE CLEAN — a file with an outstanding
1246
+ // `result: blocked` in it that never even enters `results`.
1247
+ //
1248
+ // ACCEPTED CONSEQUENCE, DELIBERATELY TRADED (this replaces the #3078
1249
+ // follow-up MINOR 1 scoping): a `### N.`-shaped line inside a properly
1250
+ // CLOSED fence in a `## Notes` section — a documentation sample of the row
1251
+ // format. NOTE the shape needs LITERAL DIGITS — the scan requires `\d+`, so the
1252
+ // conventional placeholder `### N. Name` does NOT trigger it; only a sample written
1253
+ // with real numbers (`### 1. Example Row`) does. On FREQUENCY, claim only what is
1254
+ // measurable here: the SHAPE is uncommon (it takes a literal-digit row inside a
1255
+ // CLOSED fence), and that is a claim about the shape, NOT a measurement across real
1256
+ // projects. The in-tree sample size for it is ZERO PHASE FILES — the only `*UAT*.md`
1257
+ // anywhere in this repo is the shipped template (which `selectPhaseUatFiles` never
1258
+ // scans, and which itself scores headingsSeen=11, six of them literal-digit example
1259
+ // rows), so "no phase UAT file in-tree triggers it" is vacuously true and proves
1260
+ // nothing about rarity in the field. Do not restate it as evidence. If you test the
1261
+ // placeholder form, see no over-report, and conclude this pin is stale: it is not.
1262
+ // The ordinary way to explain the syntax inside a UAT file — is
1263
+ // counted as a suppressed row and raises a parse gap on a file with nothing
1264
+ // actually missing. That is an OVER-report: noisy, but VISIBLE and FAIL-SAFE
1265
+ // (an agent reads the file and dismisses it). Fence-closedness cannot
1266
+ // distinguish it from a genuinely hidden row, because the fence-straddle
1267
+ // case this scan exists to catch is ALSO a properly closed fence — so the
1268
+ // only lever left is scope, and scope is exactly what produced the two
1269
+ // silent false-cleans above. This entire issue exists to eliminate false
1270
+ // cleans, so the trade goes this way ON PURPOSE: an extra noisy row beats an
1271
+ // invisible missing one. The behaviour is pinned by test; do not "fix" it.
1272
+ let shapedHeadingLines = 0;
1273
+ for (const line of content.split('\n')) {
1274
+ if (TEST_HEADING_LINE_RE.test(line))
1275
+ shapedHeadingLines += 1;
1276
+ }
1277
+ if (shapedHeadingLines > subHeadings.length) {
1278
+ shortfallBlocks = shapedHeadingLines - subHeadings.length;
1279
+ headingsSeen += shortfallBlocks;
1280
+ }
1281
+ // #3078 round-4 MAJOR 2: an INDENTED `### N.` row is refused by the parse
1282
+ // gate (`isColumnZeroHeading`) — correct — but must not therefore vanish
1283
+ // without a trace. See `countUnattributedIndentedRows` for why an indented
1284
+ // heading that is the VALUE of a preceding `expected:` block scalar is
1285
+ // excluded from this tally (it is value text, not a row), keeping the
1286
+ // scalar-body pins intact while a genuinely indented ROW surfaces as a gap.
1287
+ //
1288
+ // WHOLE-DOCUMENT, for the same reason as the shortfall scan above: this
1289
+ // counter has no token-side twin to disagree with, but scoping it to a
1290
+ // `## Tests` body would silently drop an indented row living anywhere else
1291
+ // in the file — the identical vanishing-row class. Its own false-positive
1292
+ // guard is STRUCTURAL (scalar attribution via
1293
+ // `ANY_KEY_SCALAR_HEADER_LINE_RE`) — with ONE positional caveat: the walk stops at the
1294
+ // nearest COLUMN-0 line, so a block scalar nested inside a `## Gaps` bullet (a
1295
+ // `- truth:` entry carrying an indented `note: |`) is transparent to it and a
1296
+ // heading-shaped line inside that value is counted. That is another instance of the
1297
+ // accepted over-report above, not a separate defect, not positional, so it needs no scope.
1298
+ headingsSeen += countUnattributedIndentedRows(content);
1299
+ // #3078: an UNTERMINATED fence swallows the entire remainder of the
1300
+ // document — every later test row AND a trailing `## Gaps` section — so the
1301
+ // file yields nothing at all and never even enters `results`: a whole-file
1302
+ // false clean. Mirrors the per-file malformed-markdown guard
1303
+ // `evaluateUatPassed` already applies via `analyzeMarkdown`
1304
+ // (src/uat-predicate.cts:278), which likewise gates on
1305
+ // `stripFencedCode(raw).unterminatedFence`. Deliberately measured on the RAW
1306
+ // document: a fence opened inside an `expected:` scalar is still an
1307
+ // unterminated fence for every downstream markdown consumer, and the masked
1308
+ // copy would hide it.
1309
+ if (stripFencedCode(content).unterminatedFence) {
1310
+ headingsSeen += 1;
1311
+ }
1312
+ for (let i = 0; i < subHeadings.length; i += 1) {
1313
+ const { heading: current, index: currentIdx } = subHeadings[i];
1314
+ const next = allHeadings[currentIdx + 1];
1315
+ const block = next ? content.slice(current.offset, next.offset) : content.slice(current.offset);
1316
+ // Fence-stripped copy for the `result:`/`reason:`/`blocked_by:` field
1317
+ // scans below (#3707 follow-up MAJOR/regression): `block` is raw slice
1318
+ // text, and a fenced code sample inside a test block (a legitimate way to
1319
+ // document expected output) can contain a line that LOOKS like a field
1320
+ // declaration (e.g. an example ` ```\nresult: pending\n``` `). Scanning
1321
+ // raw text reads that sample's `result:` as the test's real outcome —
1322
+ // origin/next returned null here, so an unstripped scan is a regression,
1323
+ // not a pre-existing behavior to preserve. `parseExpectedFromTestBlock`
1324
+ // below still receives the RAW `block`, not this stripped copy: an
1325
+ // `expected: |` block-scalar value may legitimately reproduce
1326
+ // fenced-looking text verbatim, and stripping it would corrupt that field.
1327
+ // #3707 round-3 MINOR: an UNTERMINATED fence (EOF inside a fence, or —
1328
+ // here, scoped per test block — the closing delimiter living in a LATER
1329
+ // block, so from this block's own slice the fence never closes) makes
1330
+ // `stripFencedCode` drop everything from the opener to the end of the
1331
+ // block, including a real `result:`/`reason:`/`blocked_by:` line that
1332
+ // follows it. Falling back to the RAW (unstripped) block in that case
1333
+ // means a legitimate fenced-code false-positive (a `result:`-shaped line
1334
+ // INSIDE a properly-closed sample) is still guarded against in the common
1335
+ // case, while a malformed/unterminated fence no longer silently swallows
1336
+ // a real field line into a false parse_gap.
1337
+ const stripResult = stripFencedCode(block);
1338
+ const fenceStrippedBlock = stripResult.unterminatedFence ? block : stripResult.text;
1339
+ // A block with no `result:` line at all is not a test row (e.g. still
1340
+ // being drafted) — no item, no false positive. It IS, however, a heading
1341
+ // that failed to yield an item for a reason other than a PASS token, so
1342
+ // it counts toward `headingsSeen` (used to detect a genuine parse gap).
1343
+ // Deliberately NOT end-anchored (regression fix, #3707 blocker 1): a
1344
+ // trailing comment/clause after the token (`result: pending (blocked on
1345
+ // staging)`, `result: [skipped] # no device`, `result: blocked -
1346
+ // waiting`) must still match and surface the row instead of being
1347
+ // silently dropped. The trailing text itself is matched-and-ignored
1348
+ // (#3707 follow-up MINOR): it is NOT synthesized into `reason` — a real
1349
+ // `reason:` line is the only source for that field (see below) — because
1350
+ // doing so previously changed `categorizeItem`'s classification for
1351
+ // shapes origin/next categorized differently (an unpinned behavior
1352
+ // change, not something the blocker required).
1353
+ // #3078-CR defect A fix, split-then-match scan: the previous `.match()`
1354
+ // against `/^result:.../im` ran a MULTILINE regex anchor directly over
1355
+ // unsplit block text. ECMA-262's LineTerminator set for `^`/`$` under
1356
+ // `/m` includes U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, but
1357
+ // `content.split('\n')` and this module's own heading tokenizer do NOT
1358
+ // treat either as a boundary. A `result:`-shaped line inside an
1359
+ // `expected: |` scalar body, sitting immediately after one of these
1360
+ // separators instead of an ordinary character, was therefore read as a
1361
+ // genuine line start by the regex engine even though it is not
1362
+ // `\n`-delimited from anything — it is exactly as much "one line" to
1363
+ // every other consumer as the ordinary-character control case.
1364
+ // Splitting on `\n` FIRST and testing each already-split line against a
1365
+ // single-line (`/im`-anchor-free) pattern fixes this: a line is never
1366
+ // split by U+2028/U+2029 (`String.prototype.split` matches only its
1367
+ // literal separator argument, never the wider ECMA-262 LineTerminator
1368
+ // set), so a `result:`-shaped line reachable only via one of those
1369
+ // separators can never register as its own split line — the split view
1370
+ // and the regex view are back in agreement, by construction, exactly the
1371
+ // way `splitLines` module is documented to be immune to the sibling `\r`
1372
+ // bug.
1373
+ //
1374
+ // FIRST MATCH WINS (byte-identical to origin/next otherwise): a block
1375
+ // with more than one column-0 `result:` line resolves to the FIRST one
1376
+ // encountered, same as the pre-existing `.match()` behaviour without
1377
+ // `/g` — this is deliberately NOT an ambiguity/parse-gap case (that
1378
+ // variant was tried and reverted: its boundary-truncation heuristic
1379
+ // mistook an indented `### N.` living inside a legitimate block scalar
1380
+ // for a heading boundary, corrupting every scalar/indent guard in this
1381
+ // module — see tests/uat.test.cjs's #3078 scalar guard family).
1382
+ // Trailing text is matched with `[^]*` rather than `.*` (final review
1383
+ // MINOR 1): `.` never matches U+2028/U+2029, so a column-0 `result:`
1384
+ // line whose trailing text contains one of those separators would
1385
+ // otherwise never reach `$`, and the whole line would fail to match —
1386
+ // an unpinned regression against origin/next, which parses it.
1387
+ const RESULT_LINE_RE = /^result:\s*\[?(\w+)\]?[^]*$/i;
1388
+ const resultLineMatch = fenceStrippedBlock
1389
+ .split('\n')
1390
+ .map((line) => line.match(RESULT_LINE_RE))
1391
+ .find((m) => m !== null);
1392
+ if (!resultLineMatch) {
1393
+ headingsSeen += 1;
1394
+ continue;
511
1395
  }
1396
+ // Security review finding 2: store the token lower-cased so the published
1397
+ // `result` field agrees with `category` (which categorizeItem already
1398
+ // lower-cases internally, below). No consumer needs the original casing —
1399
+ // `uat-predicate.cts` runs its own independent parser and already
1400
+ // lower-cases too — so the raw-cased form is kept nowhere.
1401
+ const result = resultLineMatch[1].toLowerCase();
1402
+ // #3707 defect 1: invert the old DROP-list filter to a PASS set — see
1403
+ // UAT_PASS_RESULTS's doc comment for why this direction was chosen.
1404
+ // A recognised PASS token is the ONLY reason a heading is excluded from
1405
+ // `headingsSeen` without producing an item — every other non-yielding
1406
+ // case (missing `result:` line, above) is a genuine parse gap.
1407
+ // `result` is already lower-cased at its extraction above, which is the
1408
+ // single point of normalization for this value — re-lowercasing here was
1409
+ // dead work and implied a second, independent normalization that does not
1410
+ // exist (#3078 round-5 MINOR).
1411
+ if (UAT_PASS_RESULTS.has(result))
1412
+ continue;
1413
+ // #3707 follow-up MINOR: the heading filter above now admits `### 3.`
1414
+ // (no name at all) and `### 3.Foo` (no space before the name), so this
1415
+ // extraction is loosened in lockstep — a bare number with no trailing
1416
+ // name falls back to the heading's own trimmed text (`3.`). #3078 round-5
1417
+ // MAJOR: shared with `parseFirstPendingTest` via `parseTestRowHeadingText`.
1418
+ const headingParts = parseTestRowHeadingText(current.text);
1419
+ const testNumber = headingParts.number;
1420
+ const testName = headingParts.name;
1421
+ // Reuse the existing block-scalar/inline `expected:` grammar rather than
1422
+ // re-deriving a second one (#3707 defect 2). #3078 blocker: the block is
1423
+ // CLIPPED at its first top-level fence opener first — still raw text (a
1424
+ // legitimate `expected: |` scalar must be read verbatim, fences and all),
1425
+ // but bounded to what the tokenizer also treated as visible, so this row
1426
+ // cannot reach past a fence into a LATER row's `expected:` line and
1427
+ // publish it as its own. See `clipBlockAtFirstFence`.
1428
+ const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
1429
+ // #3078 MINOR 2: `reason:`/`blocked_by:` previously had no block-scalar
1430
+ // grammar at all (only a plain `/key:\s*(.+)/` single-line match), so a
1431
+ // `reason: |`/`reason: >`/`blocked_by: |` value silently published as the
1432
+ // literal string `"|"` / `">"`, discarding the real multi-line value the
1433
+ // author wrote — and `categorizeItem` below reads exactly this field, so a
1434
+ // discarded `reason` could silently change an item's category. Routed
1435
+ // through the SAME `extractScalarField` machinery `expected:` already
1436
+ // uses rather than adding a third hand-rolled opener dialect.
1437
+ const reason = extractScalarField(fenceStrippedBlock, 'reason') ?? undefined;
1438
+ const blockedBy = extractScalarField(fenceStrippedBlock, 'blocked_by') ?? undefined;
1439
+ const item = {
1440
+ test: testNumber,
1441
+ name: testName,
1442
+ result,
1443
+ category: categorizeItem(result, reason, blockedBy),
1444
+ };
1445
+ if (expected)
1446
+ item.expected = expected;
1447
+ if (reason)
1448
+ item.reason = reason;
1449
+ if (blockedBy)
1450
+ item.blocked_by = blockedBy;
1451
+ items.push(item);
512
1452
  }
513
1453
  items.push(...parseGapsItems(content));
514
- return items;
1454
+ return { items, headingsSeen, shortfallBlocks };
1455
+ }
1456
+ /**
1457
+ * ITEMS-ONLY convenience form over `parseUatItemsWithStats` — the same parse,
1458
+ * with the `headingsSeen` parse-gap counter dropped, for a caller that only
1459
+ * wants the rows.
1460
+ *
1461
+ * Deliberately RETAINED with no in-tree caller (#3078 round-5 MINOR): both
1462
+ * `cmdAuditUat` and `src/planning-inspect.cts` need the stats form, so this is
1463
+ * currently used only from outside. It is a public export of a shipped module,
1464
+ * and removing an exported symbol is a CONTRACT change, out of scope for a bug
1465
+ * fix — so it stays, as the documented thin wrapper it has always been, with a
1466
+ * direct test of its own rather than as untested dead weight.
1467
+ */
1468
+ function parseUatItems(content) {
1469
+ return parseUatItemsWithStats(content).items;
515
1470
  }
516
1471
  // ─── parseGapsItems ───────────────────────────────────────────────────────────
517
1472
  /**
@@ -769,7 +1724,7 @@ function parseGapsTableItems(sectionBody) {
769
1724
  * surfaced.
770
1725
  *
771
1726
  * #3457: when the section body contains headings, entries are delimited by
772
- * LEAF headings (see `splitDeferredHeadingEntries`) rather than by bullets —
1727
+ * LEAF headings (see `splitDeferredHeadingEntriesDetailed`) rather than by bullets —
773
1728
  * the executor convention writes one deferred item as a heading followed by
774
1729
  * sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as
775
1730
  * one item PER BULLET. A body with no headings keeps the original
@@ -795,18 +1750,27 @@ function parseDeferredItemsWithStatus(content) {
795
1750
  // before field extraction, not just line 0 (which `extractGapEntryFields`
796
1751
  // does for the headless/Gaps shape, where a later `- ` line is a nested
797
1752
  // sub-list, not a field).
798
- const headingEntries = splitDeferredHeadingEntries(sectionBody);
1753
+ const headingEntries = splitDeferredHeadingEntriesDetailed(sectionBody);
1754
+ // The opener flags are HANDED DOWN rather than pre-applied (#3702 round 3,
1755
+ // m7/m8). Marker-stripping the lines here and passing the result meant the
1756
+ // reader's fence scan ran over text the splitter never saw, and the namer
1757
+ // stripped a marker off the heading TEXT. Both consumers now take the raw
1758
+ // lines plus the splitter's own per-line verdict — a rejected ordinal
1759
+ // ("3. status: resolved" as prose) still keeps its `3. ` and yields no field,
1760
+ // because that verdict is what carries the rejection.
799
1761
  const entries = headingEntries !== null
800
- ? headingEntries.map((entryLines) => ({
801
- lines: entryLines,
802
- fields: extractGapEntryFields(entryLines.map(stripLeadingBulletMarker)),
1762
+ ? headingEntries.map((entry) => ({
1763
+ lines: entry.lines,
1764
+ opener: entry.opener,
1765
+ fields: extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener),
803
1766
  }))
804
- : splitGapsEntries(sectionBody).map((entryLines) => ({
1767
+ : splitGapsEntries(sectionBody, DEFERRED_BULLET_MARKERS).map((entryLines) => ({
805
1768
  lines: entryLines,
806
- fields: extractGapEntryFields(entryLines),
1769
+ opener: undefined,
1770
+ fields: extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS),
807
1771
  }));
808
- for (const { lines: entryLines, fields } of entries) {
809
- const text = rawGapEntryText(entryLines);
1772
+ for (const { lines: entryLines, opener, fields } of entries) {
1773
+ const text = rawGapEntryText(entryLines, DEFERRED_BULLET_MARKERS, opener);
810
1774
  if (!text)
811
1775
  continue;
812
1776
  items.push({ name: text, status: fields.status || '' });
@@ -834,6 +1798,47 @@ function parseDeferredItems(content) {
834
1798
  category: 'deferred',
835
1799
  }));
836
1800
  }
1801
+ /**
1802
+ * The line ending for an entry that ends the FILE, where the entry is a single
1803
+ * line and therefore carries no terminator of its own to copy. No entry-local
1804
+ * evidence exists here — the separator before the entry terminates the
1805
+ * PREVIOUS line, not this one — so this asks the weaker question that CAN be
1806
+ * answered: does anything before the entry, within the scope the caller passes,
1807
+ * contradict CRLF? Uniform CRLF across that scope is the one case where
1808
+ * appending a `\r\n` cannot make the file more irregular. It fails CLOSED:
1809
+ * any bare `\n` in scope, or no scope at all, yields LF.
1810
+ *
1811
+ * Adopted from #3773 (`crlfAtEof`), whose four counterexamples fixed the scope
1812
+ * and are ported alongside it. Every simpler choice is refuted by a named test:
1813
+ * the separator immediately PRECEDING the entry propagates an isolated CRLF
1814
+ * into an LF-dominant list, because it terminates the previous line rather than
1815
+ * this one — that is the algorithm this PR shipped through round 3 and it is
1816
+ * withdrawn here. The whole DOCUMENT rejects CRLF over an unrelated bare `\n`
1817
+ * elsewhere, inside a fenced block say. The deferred-items SECTION body is
1818
+ * right when a heading delimits one, and becomes the whole document when it
1819
+ * does not.
1820
+ *
1821
+ * Scope, therefore: the section body when `## Deferred Items` delimits one (its
1822
+ * own preamble belongs to that section), else the entry-list region, where only
1823
+ * the entries can be trusted.
1824
+ *
1825
+ * WITH ONE CORRECTION to #3773, which is its B4. The entry-list region goes
1826
+ * EMPTY exactly when the list is undelimited AND holds a single entry, since
1827
+ * the region runs from the first entry's start to the insertion point and those
1828
+ * coincide. `crlfAtEof('')` is `false`, so a bare `\n` was inserted into a CRLF
1829
+ * document — `'preamble\r\n\r\n- alpha'` gained one — which is the very defect
1830
+ * the fallback exists to close, and it breaks the fix's own uniform-CRLF
1831
+ * invariant. When the preferred region is empty the caller widens to everything
1832
+ * preceding the insertion point rather than asserting LF from no evidence. That
1833
+ * can only ever loosen a scope that was carrying zero information, and the
1834
+ * predicate stays fail-closed over the wider one, so a contradicting bare `\n`
1835
+ * still yields LF. An entry at offset 0 of an undelimited document has no
1836
+ * evidence under either scope and stays LF, rather than inventing an ending
1837
+ * from nothing.
1838
+ */
1839
+ function crlfAtEof(before) {
1840
+ return before.length > 0 && !/(^|[^\r])\n/.test(before);
1841
+ }
837
1842
  /**
838
1843
  * CLI-writer half of the #3458 follow-up deferred_items suppression seam.
839
1844
  * Sets the ONE deferred entry whose rendered text (`rawGapEntryText`, the
@@ -848,14 +1853,27 @@ function parseDeferredItems(content) {
848
1853
  * `status:` away from `acknowledged` (or delete the field) and it resurfaces
849
1854
  * with no separate cleanup step, exactly like every other category's marker.
850
1855
  *
851
- * Deliberately refuses (`unsupported_heading_shape`) rather than guess when
852
- * the section uses the heading-delimited (#3457) entry shape: reliably
853
- * mapping a `splitDeferredHeadingEntries` entry back to its EXACT source line
854
- * span is not safely derivable without re-deriving that function's
855
- * leaf/container walk against a document that may also mix in headless
856
- * (`splitGapsEntries`-derived) entries between headings — attempting it risks
857
- * writing into the WRONG entry. The bullet-only (headless) shape below is the
858
- * primary, documented SCOPE BOUNDARY convention and is handled precisely.
1856
+ * #3781: the heading-delimited (#3457) entry shape is SUPPORTED. The
1857
+ * reader's own walk, `splitDeferredHeadingEntriesDetailed`, records each
1858
+ * entry's (start, end) character span in the SAME pass that groups its
1859
+ * lines — the technique `splitGapsEntriesWithSpans` already uses for the
1860
+ * headless shape — so there is no second walk for the writer to drift from
1861
+ * (#3702 round 5: upstream's fix shipped a hyphen-only sibling walk, and this
1862
+ * PR's widened grammar would have left it reading a different set of
1863
+ * entries than the reader; folding the spans into the one walk is what
1864
+ * keeps the writer and the reader on one grammar). The heading half,
1865
+ * `acknowledgeHeadingShapedEntry`, shares this function's guards and its
1866
+ * rewrite/insert machinery through the same `entryFieldLines` seam, with two
1867
+ * shape-specific rules: the status search runs over the READER-form lines
1868
+ * (the heading TEXT on a leaf's line 0 — including the corner where that
1869
+ * text itself parses as a status field, rewritten with its ATX prefix
1870
+ * preserved), and the insert branch inserts after the entry's LAST NON-BLANK
1871
+ * line, because a heading entry's body is frequently a soft-wrapped
1872
+ * sentence and splicing after line 0 would split it (#3781's sentence-split
1873
+ * trap). Entries whose span embeds a GFM table row are non-contiguous (table
1874
+ * lines are excluded from entries) and still refuse
1875
+ * (`unsupported_heading_shape`) rather than risk a wrong-entry write; the
1876
+ * fully-headless shape below is byte-for-byte the pre-#3781 path.
859
1877
  *
860
1878
  * Also refuses `ambiguous` (2+ entries share the exact same text — status must
861
1879
  * be unique to identify one) and `not_found`, and is a no-op
@@ -897,12 +1915,15 @@ function parseDeferredItems(content) {
897
1915
  function acknowledgeDeferredItem(content, targetText) {
898
1916
  const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
899
1917
  const sectionBody = deferredSection ? deferredSection.body : content;
900
- if (splitDeferredHeadingEntries(sectionBody) !== null) {
901
- return { content, status: 'unsupported_heading_shape' };
1918
+ // #3781: the heading-delimited shape carries its own spans, recorded by
1919
+ // the reader's walk; the headless path below is unchanged.
1920
+ const headingEntries = splitDeferredHeadingEntriesDetailed(sectionBody);
1921
+ if (headingEntries !== null) {
1922
+ return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText });
902
1923
  }
903
- const entries = splitGapsEntriesWithSpans(sectionBody);
1924
+ const entries = splitGapsEntriesWithSpans(sectionBody, DEFERRED_BULLET_MARKERS);
904
1925
  const matches = entries
905
- .map((entry) => ({ entry, text: rawGapEntryText(entry.lines) }))
1926
+ .map((entry) => ({ entry, text: rawGapEntryText(entry.lines, DEFERRED_BULLET_MARKERS) }))
906
1927
  .filter((e) => e.text === targetText);
907
1928
  if (matches.length === 0)
908
1929
  return { content, status: 'not_found' };
@@ -910,7 +1931,7 @@ function acknowledgeDeferredItem(content, targetText) {
910
1931
  return { content, status: 'ambiguous' };
911
1932
  const { entry } = matches[0];
912
1933
  const { lines: entryLines, start, end } = entry;
913
- const fields = extractGapEntryFields(entryLines);
1934
+ const fields = extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS);
914
1935
  if (fields.status && fields.status.toLowerCase() === 'resolved') {
915
1936
  return { content, status: 'already_resolved' };
916
1937
  }
@@ -925,94 +1946,559 @@ function acknowledgeDeferredItem(content, targetText) {
925
1946
  // comparison that selected this entry — this catches real drift between
926
1947
  // the two rather than a regex trivially guaranteed to agree with itself.
927
1948
  const strippedForVerify = matchedLines.map((l) => l.replace(/\r$/, ''));
928
- if (rawGapEntryText(strippedForVerify) !== targetText) {
1949
+ if (rawGapEntryText(strippedForVerify, DEFERRED_BULLET_MARKERS) !== targetText) {
929
1950
  return { content, status: 'match_verification_failed' };
930
1951
  }
931
1952
  const matchIndexInContent = sectionOffset + start;
932
- const statusFieldRe = /^\s*(?:-\s+)?(\*+status:\*+|status:)/i;
933
- const statusLineIdx = matchedLines.findIndex((rawLine) => statusFieldRe.test(rawLine.replace(/\r$/, '')));
934
- // No CRLF-preservation branch here (WARNING 1, #3458 follow-up review):
935
- // every write goes through `platformWriteSync` → `normalizeContent`, which
936
- // for a `.md` path unconditionally runs `_normalizeMd` — whole-file
937
- // `\r\n` → `\n`, plus blank-line normalization around headings/lists — on
938
- // EVERY write, not just this one. That is this codebase's single,
939
- // deliberate OS-facing I/O seam (`shell-command-projection.cts`), applied
940
- // uniformly to every `.md` writer; carving out one exception here would
941
- // fight it rather than follow it, for a guarantee (byte-identical CRLF on
942
- // disk) the seam already makes impossible. A marker write on a CRLF
943
- // `deferred-items.md` normalizes the WHOLE file to LF, same as any other
944
- // `.md` write in this codebase — expected, not a regression to guard
945
- // against. Where a source line still carries a trailing `\r` (read from an
946
- // on-disk CRLF document before normalization), `String.prototype.replace`
947
- // consumes it as part of `.*$` and the replacement text does not
948
- // reproduce it, so it is dropped here too — consistent with the eventual
949
- // whole-file normalization rather than duplicating it.
1953
+ // Locate the status line with the READER'S OWN classifier, never a
1954
+ // writer-side regex (#3702 round 3, B1/B3 — the shape is #3773's,
1955
+ // parameterised here by the widened marker set per the round-3 review's
1956
+ // prescribed end state). The only line worth rewriting in place is one the
1957
+ // reader will read back as `fields.status`.
1958
+ //
1959
+ // What this closes: round 2 widened the writer's finder to the deferred
1960
+ // marker set while `extractGapEntryFields` still read a marker only on line
1961
+ // 0. A nested ` * status: pending` was therefore SELECTED by the writer and
1962
+ // invisible to the reader — acknowledge rewrote it, returned `ok`, and the
1963
+ // item stayed outstanding forever. Measured against a `next` build: `*`, `+`
1964
+ // and `1.` all resolved on base and stopped resolving here, so it was a
1965
+ // regression, not a gap in new behaviour. The hyphen form of the same shape
1966
+ // (` - status:`) was already broken on `next`; it is fixed here too, since
1967
+ // one classifier cannot be right for three markers and wrong for the fourth.
1968
+ //
1969
+ // A line the reader skips falls through to the INSERT branch, which writes a
1970
+ // line the reader does read — the fail-safe direction. That covers a bare
1971
+ // capitalised `Status:` (the reader stores it under `Status`, not `status`)
1972
+ // and a `status:` line inside a fenced block, both of which the writer must
1973
+ // NOT rewrite in place. Selecting either one produced an entry that could not
1974
+ // be acknowledged at all; that is why the selection goes through
1975
+ // `entryFieldLines` rather than the classifier directly.
1976
+ const statusLineIdx = entryFieldLines(matchedLines, DEFERRED_BULLET_MARKERS)
1977
+ .findIndex((field) => field?.key === 'status');
1978
+ // Per-line CRLF preservation is the honest in-memory contract. The lines
1979
+ // here are RAW — `audit acknowledge` hands this function the `readFileSync`
1980
+ // content of an on-disk `deferred-items.md`, and `_normalizeMd` runs only on
1981
+ // WRITE — so on a CRLF document every line but the span's last still carries
1982
+ // its `\r`. The write path's whole-file normalization still decides what
1983
+ // reaches disk; this function does not duplicate that decision, and it no
1984
+ // longer silently drops the `\r` either. (Round 2's comment here argued the
1985
+ // opposite contract. It is withdrawn: #3773 documents per-line preservation
1986
+ // in this same function, and two opposite contracts in one function was a
1987
+ // round-3 blocker in its own right.)
950
1988
  let newMatchedLines;
951
1989
  if (statusLineIdx === -1) {
952
- const bulletIndentMatch = matchedLines[0].match(/^(\s*)-\s+/);
953
- const continuationIndent = ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
1990
+ const bulletIndentMatch = matchedLines[0].replace(/\r$/, '').match(DEFERRED_BULLET_MARKERS.strip);
1991
+ // The entry's own indent CHARACTERS, never a count of them — a tab counted
1992
+ // as one column and re-emitted as one space puts a 3-space continuation
1993
+ // under a tab-indented bullet. Identical output for all-space indents.
1994
+ const continuationIndent = `${bulletIndentMatch ? bulletIndentMatch[1] : ''} `;
1995
+ // The new line goes right after line 0, so line 0 stops being the span's
1996
+ // last line. Under CRLF the span's last line is the one WITHOUT a `\r`
1997
+ // (the file's own `\r\n` follows the span), so the ending is read from the
1998
+ // entry's OWN boundary and never sniffed from the whole document — a
1999
+ // mixed-ending file must keep its LF opener. At end-of-file there is no
2000
+ // following separator, so the boundary immediately PRECEDING the entry is
2001
+ // the remaining local evidence; an entry at offset 0 has neither and stays
2002
+ // LF rather than inventing an ending from nothing.
2003
+ const line0HadCr = matchedLines[0].endsWith('\r');
2004
+ // A single-line entry's line 0 IS the span's last line, so its own
2005
+ // terminator sits OUTSIDE the span and the separator FOLLOWING the span is
2006
+ // the evidence. At end-of-file there is no such separator, and reading its
2007
+ // absence as "not CRLF" is what joined a CRLF entry to its inserted line
2008
+ // with a bare `\n`. `crlfAtEof` answers the weaker question that remains,
2009
+ // over the entry's own SECTION when one is delimited and the entry list
2010
+ // alone when none is — widening to everything before the insertion point
2011
+ // only where that region is empty, which is #3773's B4. See its doc comment.
2012
+ const spanEnd = matchIndexInContent + (end - start);
2013
+ const eofScope = sectionBody.slice(deferredSection ? 0 : entries[0].start, start)
2014
+ || sectionBody.slice(0, start);
2015
+ const crlf = matchedLines.length > 1
2016
+ ? line0HadCr
2017
+ : content.startsWith('\r\n', spanEnd)
2018
+ || (spanEnd >= content.length && crlfAtEof(eofScope));
954
2019
  newMatchedLines = [
955
- matchedLines[0],
956
- `${continuationIndent}status: acknowledged`,
2020
+ crlf ? `${matchedLines[0].replace(/\r$/, '')}\r` : matchedLines[0],
2021
+ `${continuationIndent}status: acknowledged${line0HadCr ? '\r' : ''}`,
957
2022
  ...matchedLines.slice(1),
958
2023
  ];
959
2024
  }
960
2025
  else {
961
- const original = matchedLines[statusLineIdx];
962
- const replaced = original.replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
2026
+ // Rewrite at the offset the CLASSIFIER reported, rather than through a
2027
+ // second regex of the writer's own. This is what makes the selection and
2028
+ // the rewrite structurally incapable of disagreeing: a status line the
2029
+ // classifier can select is one whose value offset it has already
2030
+ // computed, so there is no shape it selects and then fails to rewrite.
2031
+ // (A widened classifier over a hyphen-only rewrite regex is exactly that
2032
+ // failure — it would select `* status: open` and hand back the line
2033
+ // untouched.) The key's own spelling and any `**bold**` wrapper survive
2034
+ // because only the value is replaced.
2035
+ const raw = matchedLines[statusLineIdx];
2036
+ const cr = raw.endsWith('\r') ? '\r' : '';
2037
+ const line = raw.slice(0, raw.length - cr.length);
2038
+ const field = parseGapEntryFieldLine(line, DEFERRED_BULLET_MARKERS, statusLineIdx === 0);
2039
+ const prefix = line.slice(0, field.valueStart);
2040
+ // `status:acknowledged` reads back fine, but a bare colon with no
2041
+ // separator is not what this file's convention looks like; supply one only
2042
+ // when the source had none.
2043
+ const sep = /[ \t]$/.test(prefix) ? '' : ' ';
963
2044
  newMatchedLines = matchedLines.slice();
964
- newMatchedLines[statusLineIdx] = replaced;
2045
+ newMatchedLines[statusLineIdx] = `${prefix}${sep}acknowledged${cr}`;
965
2046
  }
2047
+ // NO post-write read-back guard here, deliberately (round 4, B3). Round 3
2048
+ // added one — `rewrite_not_readable` — after a fenced `status:` line proved
2049
+ // the writer could select a line the reader would not read back. Round 3
2050
+ // then closed that divergence STRUCTURALLY, by routing the writer's line
2051
+ // selection and the reader's field extraction through the one
2052
+ // `entryFieldLines` seam above, and the guard became unreachable from the
2053
+ // public API: 21 document shapes were driven against it (fence openers on
2054
+ // the bullet line for every marker, duplicate and triplicate `status:`
2055
+ // lines, bolded and nested variants, fences between duplicates) and none
2056
+ // reached it.
2057
+ //
2058
+ // An unreachable branch is not free here. This repo's own
2059
+ // `RULESET.TESTS.mutation-score` runs Stryker incrementally over changed
2060
+ // files at an 80% threshold and says to "treat surviving mutant as a failing
2061
+ // test specification"; an undriven `if` is exactly that. The only seam that
2062
+ // would drive it is routing this call through the module's exports so a test
2063
+ // could stub it — production surface reshaped for a test, which is a worse
2064
+ // trade than the guard is worth now that construction, not assertion,
2065
+ // enforces the invariant.
2066
+ //
2067
+ // What that gives up, stated plainly rather than hidden: if a future change
2068
+ // re-splits the writer's selection from the reader's extraction, this
2069
+ // function returns `ok` over an item that stays outstanding — the original
2070
+ // #3702 defect class. `match_verification_failed` does NOT backfill it; that
2071
+ // check runs BEFORE the write and compares the matched span to the target,
2072
+ // so it cannot see a post-write read-back failure. The protection against
2073
+ // re-splitting is the shared seam plus the round-3 tests that pin it, not a
2074
+ // runtime assertion.
966
2075
  const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
967
2076
  return { content: newContent, status: 'ok' };
968
2077
  }
969
2078
  /**
970
- * Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred
971
- * entries carry their fields as sibling bullets; `extractGapEntryFields` only
972
- * de-bullets line 0 (Gaps-protective — there, a later `- ` line is a nested
973
- * sub-list), so the deferred heading path de-bullets every line itself before
974
- * field extraction. Non-bullet lines pass through untouched.
975
- */
976
- function stripLeadingBulletMarker(line) {
977
- return line.replace(/^(\s*)-\s+/, '');
978
- }
979
- /**
980
- * Split a deferred-items section body into entries delimited by LEAF headings
981
- * (#3457). Returns `null` when the body contains no heading at all — the
982
- * caller then falls back to `splitGapsEntries`, keeping headless
983
- * one-bullet-per-item files byte-for-byte on the pre-#3457 path.
984
- *
985
- * A heading is a CONTAINER (group/provenance/title label, contributes no
986
- * entry) iff the NEXT heading is deeper — a deeper heading lives inside its
987
- * span. Otherwise it is a LEAF: an entry boundary. This handles all three
988
- * corpus shapes without hardcoding a depth: flat `#` title + `##` entries
989
- * (title's next heading is deeper → container; each `##` followed by a
990
- * same-or-shallower heading → leaf), a `##` container with `###` entries
991
- * (container's next heading is deeper), and mixed-depth files where a
992
- * childless `##` entry sits alongside a `##` group with `###` children — every
993
- * childless heading is a leaf at whatever depth it is written. The shallower
994
- * rules the issue reports as already tried (split on every heading; shallowest
995
- * level; deepest level) each mis-count one of these shapes.
996
- *
997
- * A leaf entry is [heading text, ...body lines up to the next heading] and is
998
- * kept only when its body (minus table lines) contains at least one `- `
999
- * bullet:
1000
- * - a prose-only or bare heading contributes nothing — "prose is not an item"
1001
- * is this parser's pre-existing contract (see the `# Notes` case);
1002
- * - a table-only body is left entirely to `parseDeferredTableItems`, which
1003
- * unions over the same section body, so the heading cannot double-count the
1004
- * table's rows.
1005
- *
1006
- * Lines before the first heading, and lines directly under a container heading
1007
- * (before its first child), are split one-bullet-per-item by the unchanged
1008
- * `splitGapsEntries` — headless parity, so loose bullets before a later
1009
- * heading group (the mixed shape) stay one item each.
1010
- */
1011
- function splitDeferredHeadingEntries(sectionBody) {
2079
+ * #3781 — strip an ATX heading prefix, mirroring `tokenizeHeadings`' own ATX
2080
+ * regex (≤3 leading spaces, 1–6 `#`, space/tab separator, optional closing
2081
+ * `#` sequence) so the raw heading line reconciles byte-exactly with the
2082
+ * hash-stripped `text` the reader exposes. Returns null when the line is not
2083
+ * an ATX heading line.
2084
+ */
2085
+ function stripAtxPrefix(line) {
2086
+ const m = /^( {0,3})(#{1,6})([ \t]+.*|[ \t]*)?$/.exec(line.replace(/\r$/, ''));
2087
+ if (!m)
2088
+ return null;
2089
+ return m[3] === undefined
2090
+ ? ''
2091
+ : m[3].replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '').trim();
2092
+ }
2093
+ /**
2094
+ * #3781 — the heading-shaped half of `acknowledgeDeferredItem`, sharing the
2095
+ * headless path's guards (not_found / ambiguous / already_resolved /
2096
+ * match_verification_failed) and its rewrite/insert machinery, with the two
2097
+ * shape-specific rules documented on `acknowledgeDeferredItem` (reader-form
2098
+ * status search incl. the leaf line-0 ATX corner; insert after the entry's
2099
+ * last non-blank line). Extracted so the headless path stays byte-identical.
2100
+ *
2101
+ * Every question about an entry is asked of the READER'S OWN answer (#3702
2102
+ * round 3, B1/B3 — restated here rather than re-implemented): identity is
2103
+ * `rawGapEntryText` over the walk's lines and opener flags, exactly as
2104
+ * `parseDeferredItemsWithStatus` names the entry; the status line is whichever
2105
+ * line `entryFieldLines` classifies as `status`, so a fenced `status:` or a
2106
+ * rejected-ordinal prose line is never selected; and the rewrite lands at the
2107
+ * offset the classifier reported. Upstream's #3781 carried its own
2108
+ * hyphen-only walk and its own status regexes for this shape; under the
2109
+ * widened marker grammar those would have read a different entry set than
2110
+ * the reader and re-opened the writer/reader drift this PR closes.
2111
+ */
2112
+ function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }) {
2113
+ const matches = headingEntries.filter((e) => rawGapEntryText(e.lines, DEFERRED_BULLET_MARKERS, e.opener) === targetText);
2114
+ if (matches.length === 0)
2115
+ return { content, status: 'not_found' };
2116
+ if (matches.length > 1)
2117
+ return { content, status: 'ambiguous' };
2118
+ const entry = matches[0];
2119
+ // A table row inside the span: the walk skipped it, so `lines` is not 1:1
2120
+ // with the raw slice and no write can be anchored. Refuse, as before #3781.
2121
+ if (entry.embeddedTable)
2122
+ return { content, status: 'unsupported_heading_shape' };
2123
+ const fields = extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener);
2124
+ if (fields.status && fields.status.toLowerCase() === 'resolved') {
2125
+ return { content, status: 'already_resolved' };
2126
+ }
2127
+ const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
2128
+ const rawLines = sectionBody.slice(entry.start, entry.end).split('\n');
2129
+ // The reader-form of the raw slice, index-aligned with it: a leaf's line 0
2130
+ // is the heading TEXT (re-derived from the span's own bytes, not copied
2131
+ // from the walk, so the verification below is genuine), every other line
2132
+ // CR-stripped. Markers stay on the lines — the classifier strips them per
2133
+ // the opener flags, exactly as the reader does.
2134
+ const readerLines = rawLines.map((raw, i) => {
2135
+ const line = raw.replace(/\r$/, '');
2136
+ return i === 0 && entry.kind === 'leaf' ? (stripAtxPrefix(line) ?? line) : line;
2137
+ });
2138
+ // Genuine invariant re-verification: the identity re-derived from the
2139
+ // span's bytes must be the identity that selected the entry — the span was
2140
+ // recorded by offset bookkeeping independent of that comparison.
2141
+ if (readerLines.length !== entry.lines.length
2142
+ || rawGapEntryText(readerLines, DEFERRED_BULLET_MARKERS, entry.opener) !== targetText) {
2143
+ return { content, status: 'match_verification_failed' };
2144
+ }
2145
+ const statusLineIdx = entryFieldLines(readerLines, DEFERRED_BULLET_MARKERS, entry.opener)
2146
+ .findIndex((field) => field?.key === 'status');
2147
+ let newRawLines;
2148
+ if (statusLineIdx === -1) {
2149
+ // Insert branch: after the entry's LAST NON-BLANK line — a heading
2150
+ // entry's body is frequently a soft-wrapped sentence, and splicing after
2151
+ // line 0 would split it in half (#3781's sentence trap). The headless
2152
+ // (no-heading-anywhere) path keeps its own splice-after-line-0 shape.
2153
+ // …and never INSIDE a fence (round 5, RV6.5 review): an entry whose body
2154
+ // ends in a fenced block — closed or, worse, unclosed and so running to
2155
+ // the entry's end — would otherwise receive its marker as fence content,
2156
+ // a line the reader never reads: `ok` returned, item still outstanding.
2157
+ // Walk back over blank and fenced lines alike, classified exactly as the
2158
+ // reader classifies them, so the marker lands on a line the reader reads.
2159
+ const fencedInEntry = entryFencedLines(readerLines, DEFERRED_BULLET_MARKERS, entry.opener);
2160
+ let last = rawLines.length - 1;
2161
+ while (last > 0 && (readerLines[last].trim() === '' || fencedInEntry.has(last)))
2162
+ last--;
2163
+ // A pending entry's continuation sits two columns inside its own marker
2164
+ // indent (the entry's indent CHARACTERS, as the headless path does); a
2165
+ // leaf's body lines are sibling bullets, and an indented bare field line
2166
+ // among them is what the reader reads on that shape.
2167
+ const indent = entry.kind === 'pending'
2168
+ ? `${rawLines[0].replace(/\r$/, '').match(DEFERRED_BULLET_MARKERS.strip)?.[1] ?? ''} `
2169
+ : ' ';
2170
+ // The inserted line copies the ending of the line it follows. When that
2171
+ // line is the span's LAST, its terminator sits outside the span: the
2172
+ // separator following the span decides, else (end of file) the section's
2173
+ // own evidence — the same rule the headless path applies to line 0.
2174
+ const followsLast = last === rawLines.length - 1;
2175
+ const spanEnd = sectionOffset + entry.end;
2176
+ const prevCr = followsLast
2177
+ ? content.startsWith('\r\n', spanEnd) || (spanEnd >= content.length && crlfAtEof(sectionBody.slice(0, entry.start)))
2178
+ : rawLines[last].endsWith('\r');
2179
+ newRawLines = rawLines.slice();
2180
+ if (followsLast && prevCr)
2181
+ newRawLines[last] = `${rawLines[last].replace(/\r$/, '')}\r`;
2182
+ newRawLines.splice(last + 1, 0, `${indent}status: acknowledged${!followsLast && prevCr ? '\r' : ''}`);
2183
+ }
2184
+ else {
2185
+ // Rewrite at the offset the CLASSIFIER reported, on the RAW line — the
2186
+ // marker, the indent, the key's spelling and any `**bold**` wrapper all
2187
+ // survive because only the value is replaced. A leaf's line 0 is the
2188
+ // heading line, so its ATX prefix is put back in front of the rewritten
2189
+ // text (the reader reads the heading text itself as the field there).
2190
+ const raw = rawLines[statusLineIdx];
2191
+ const cr = raw.endsWith('\r') ? '\r' : '';
2192
+ const line = raw.slice(0, raw.length - cr.length);
2193
+ const reader = readerLines[statusLineIdx];
2194
+ const field = parseGapEntryFieldLine(reader, DEFERRED_BULLET_MARKERS, stripsMarkerAt(statusLineIdx, entry.opener));
2195
+ const prefix = reader.slice(0, field.valueStart);
2196
+ const sep = /[ \t]$/.test(prefix) ? '' : ' ';
2197
+ const leafLine0 = statusLineIdx === 0 && entry.kind === 'leaf';
2198
+ const atx = leafLine0 ? (/^( {0,3}#{1,6}[ \t]+)/.exec(line)?.[1] ?? '') : '';
2199
+ // A closing `#` sequence is Markdown the reader ignores; keep it (RV6.5).
2200
+ const closing = leafLine0 ? (/[ \t]+#+[ \t]*$/.exec(line)?.[0] ?? '') : '';
2201
+ newRawLines = rawLines.slice();
2202
+ newRawLines[statusLineIdx] = `${atx}${prefix}${sep}acknowledged${closing}${cr}`;
2203
+ }
2204
+ const matchIndexInContent = sectionOffset + entry.start;
2205
+ const newContent = content.slice(0, matchIndexInContent) + newRawLines.join('\n') + content.slice(matchIndexInContent + (entry.end - entry.start));
2206
+ return { content: newContent, status: 'ok' };
2207
+ }
2208
+ /**
2209
+ * Hyphen-only markers — the `## Gaps` form, unchanged by #3702. Gaps entries
2210
+ * come from a template that mandates the hyphen YAML-lite shape, so widening
2211
+ * that section's grammar is not what the deferred-items ruling required; the
2212
+ * shared splitting seam is parameterised rather than widened wholesale so the
2213
+ * Gaps path stays byte-for-byte on its existing behaviour.
2214
+ */
2215
+ const HYPHEN_BULLET_MARKERS = {
2216
+ open: /^(\s*)(-)\s/,
2217
+ strip: /^(\s*)-\s+(.*)$/,
2218
+ blockStructure: false,
2219
+ };
2220
+ /**
2221
+ * Deferred-items markers (#3702): the standard Markdown list markers, not the
2222
+ * hyphen alone. `deferred-items.md` has NO template and no mandated shape —
2223
+ * executors write it by hand (the same premise that justified the #2766 table
2224
+ * union) — so an author reaching for `*`, `+` or `1.` wrote a list by every
2225
+ * Markdown definition while this parser contributed ZERO entries for it. The
2226
+ * hyphen restriction was a regex literal inherited from the Gaps seam, never a
2227
+ * stated decision: measured in the wild, non-empty records parsed to a clean
2228
+ * zero, and a MIXED file dropped its non-hyphen entries while keeping their
2229
+ * hyphenated siblings — under-reporting without ever looking empty.
2230
+ *
2231
+ * Deliberately NOT widened to prose: "prose is not an item" is this parser's
2232
+ * pre-existing, test-asserted contract (the `# Notes` case) and is untouched
2233
+ * here. An asterisk bullet is not prose, and a `|` row is not a list marker —
2234
+ * table lines are still skipped before the body-bullet flag can be set, so
2235
+ * `parseDeferredTableItems` keeps sole ownership of table bodies and the
2236
+ * #2766 anti-double-count property holds unchanged.
2237
+ *
2238
+ * The paren-terminated ordered form (`1)`) is out of scope for this fix: the
2239
+ * #3702 ruling scopes the widening to `*`, `+` and the dot-terminated ordered
2240
+ * marker.
2241
+ *
2242
+ * `DEFERRED_MARKER_ALT` is THE source every deferred-items marker regex is
2243
+ * built from (#3702 round 2, M3). Since round 3 that is the splitter's
2244
+ * `open`/`strip` pair here and nothing else: `acknowledgeDeferredItem`'s two
2245
+ * status-line shapes used to be derived from it too, and are now deleted in
2246
+ * favour of the reader's classifier. CommonMark
2247
+ * §5.2: bullet markers `-`, `*`, `+`; an ordered marker is 1-9 digits and a
2248
+ * `.` (round-1's `\d+` was uncapped). The marker is followed by a space or a
2249
+ * tab — `[ \t]`, where round 1 wrote `\s`, which also accepted `\r`.
2250
+ * `markdown-sectionizer`'s `iterateBullets` is the repo's other list-marker
2251
+ * grammar; the `#3702 round 2: marker-grammar parity` test pins this one to
2252
+ * it on the shared vocabulary and names the two points they deliberately
2253
+ * differ (tab after the marker, the 9-digit cap).
2254
+ */
2255
+ const DEFERRED_MARKER_ALT = '(?:[-*+]|\\d{1,9}\\.)';
2256
+ const DEFERRED_BULLET_MARKERS = {
2257
+ open: new RegExp(`^(\\s*)(${DEFERRED_MARKER_ALT})[ \\t]`),
2258
+ strip: new RegExp(`^(\\s*)${DEFERRED_MARKER_ALT}[ \\t]+(.*?)\\r?$`),
2259
+ blockStructure: true,
2260
+ };
2261
+ // `acknowledgeDeferredItem` carries NO status-line regex of its own (#3702
2262
+ // round 3, B1/B3). It used to hold two — a finder and a rewrite — derived
2263
+ // from `DEFERRED_MARKER_ALT` so the two WRITER shapes could not drift from
2264
+ // each other. That kept the wrong pair in step: the finder's peer is the
2265
+ // READER, and widening detection without widening the read is what made a
2266
+ // nested ` * status:` line selectable by the writer and invisible to
2267
+ // `extractGapEntryFields`. Both are gone; the writer now locates its line
2268
+ // through `parseGapEntryFieldLine`, the reader's own classifier, and rewrites
2269
+ // at the offset that classifier reports. See `parseGapEntryFieldLine`.
2270
+ /**
2271
+ * CommonMark §4.1 thematic break: up to 3 spaces of indent, then three or
2272
+ * more of the SAME `-`, `*` or `_`, optionally space/tab-separated, and
2273
+ * nothing else. `- - -`, `* * *` and `+ + +` all also match a list opener —
2274
+ * `- - -` was a phantom `"- -"` entry on base, and #3702's widening added the
2275
+ * other two (#3702 round 2, M1). `+ + +` is not a CommonMark break, but it is
2276
+ * the same authoring gesture and no less garbage as an entry name, so the
2277
+ * class here is "three-or-more of one marker character, nothing else". The
2278
+ * indent is unbounded, not CommonMark's `{0,3}`: this parser reads a list at
2279
+ * any indent (see `#3702 round 2` m1), so a separator drawn at any indent is
2280
+ * a separator too — otherwise ` * * *` is a phantom entry named `* *`.
2281
+ */
2282
+ const THEMATIC_BREAK_RE = /^[ \t]*([-*+_])(?:[ \t]*\1){2,}[ \t]*$/;
2283
+ /**
2284
+ * The deferred grammar's line view for fence classification: the SAME lines,
2285
+ * with leading whitespace removed (#3702 round 4, M2).
2286
+ *
2287
+ * `scanFencedBlocks` is CommonMark, and CommonMark caps a fence delimiter's
2288
+ * indent at three spaces — a fourth makes it an indented code block instead.
2289
+ * The deferred grammar deliberately opted out of that cliff everywhere else:
2290
+ * an entry opener is `[ \t]*`-indented and `THEMATIC_BREAK_RE` is
2291
+ * `^[ \t]*`. Leaving the fence rule at CommonMark's cap while items and
2292
+ * breaks are unbounded is not a conservative choice, it is an inconsistent
2293
+ * one, and it is REACHED BY ORDINARY DOCUMENTS: a fenced block written under a
2294
+ * nested bullet sits at four spaces, so its `status: resolved` line resolved
2295
+ * the entry containing it. That is the #3702 silent-resolution defect class in
2296
+ * a new place — driven, at indents 4, 5, 8 and a leading tab, before this fix.
2297
+ *
2298
+ * Still NO second fence dialect (the rule `blankIndentedFenceDelimiters`
2299
+ * states): the classification is done by `scanFencedBlocks`, the one exported
2300
+ * CommonMark state machine, over a de-indented view. Run lengths, backtick
2301
+ * vs tilde, closer-must-match-and-not-trail and info-string rules are all
2302
+ * still that engine's answers, not re-derived here; the unterminated case is
2303
+ * its answer too, bounded by the walk (round 5, B1 — see `scanFencesFrom`). Indent is the only dimension this hides from it, and it is
2304
+ * the exact dimension the deferred grammar has already declared it does not
2305
+ * measure. Index alignment is 1:1 by construction — `map` preserves length —
2306
+ * so every line index the engine returns still addresses the original line.
2307
+ *
2308
+ * Scope: the deferred grammar only. Both marker-parameterised call sites gate
2309
+ * on `markers.blockStructure`, which the `## Gaps` set does not set, so Gaps
2310
+ * reaches an empty set and is untouched by this — the same opt-out
2311
+ * `indentWidth` documents for the indent half.
2312
+ */
2313
+ function deindentedForFences(lines) {
2314
+ return lines.map((line) => line.replace(/^[ \t]+/, ''));
2315
+ }
2316
+ /**
2317
+ * Indices (into `lines`) of every line that sits inside a fenced code block,
2318
+ * delimiters included — by the sectionizer's own fence state machine, so a
2319
+ * `~~~` fence, an indented fence and an unterminated fence (runs to the end)
2320
+ * are classified exactly as `stripFencedCode` would (#3702 round 2, M2), at
2321
+ * ANY indent (round 4, M2 — see `deindentedForFences`).
2322
+ * #3702's wild records carry reproduction blocks; `+`-prefixed diff lines and
2323
+ * `1.`-numbered steps are their normal content, not entries.
2324
+ *
2325
+ * ENTRY-scoped: `lines` are ONE entry's lines (`entryFieldLines`), so an
2326
+ * unterminated fence "running to the end" runs to the end of that entry —
2327
+ * exactly the bound the section-level walks give it (round 5, B1; see
2328
+ * `scanFencesFrom`). The two classifications agree by construction.
2329
+ */
2330
+ function fencedLineSet(lines) {
2331
+ const fenced = new Set();
2332
+ for (const block of scanFencedBlocks(deindentedForFences(lines))) {
2333
+ const last = block.closeLineIdx === -1 ? lines.length - 1 : block.closeLineIdx;
2334
+ for (let i = block.openLineIdx; i <= last; i++)
2335
+ fenced.add(i);
2336
+ }
2337
+ return fenced;
2338
+ }
2339
+ function scanFencesFrom(lines, from) {
2340
+ const scan = { fenced: new Set(), openers: new Set(), unterminatedFrom: -1 };
2341
+ for (const block of scanFencedBlocks(deindentedForFences(lines.slice(from)))) {
2342
+ const open = block.openLineIdx + from;
2343
+ scan.openers.add(open);
2344
+ if (block.closeLineIdx === -1) {
2345
+ scan.unterminatedFrom = open; // always the scan's last block
2346
+ break;
2347
+ }
2348
+ for (let i = open; i <= block.closeLineIdx + from; i++)
2349
+ scan.fenced.add(i);
2350
+ }
2351
+ return scan;
2352
+ }
2353
+ /** The scan a grammar without block structure (`## Gaps`) walks under: nothing is fenced. Never mutated. */
2354
+ const NO_FENCES = { fenced: new Set(), openers: new Set(), unterminatedFrom: -1 };
2355
+ /**
2356
+ * Does `line` LOOK like a top-level list item under `markers` — a marker at
2357
+ * or above the base indent, start value ignored? The bound an unterminated
2358
+ * fence runs to (round 5, B1; see `scanFencesFrom`). Shape rather than the
2359
+ * ordered-start rule, because the list memory inside a fence is not evidence
2360
+ * of anything, and closing a stray fence one line early errs in the
2361
+ * surfacing direction.
2362
+ */
2363
+ function topLevelItemShape(line, markers, baseIndent) {
2364
+ const m = line.match(markers.open);
2365
+ return m !== null && (baseIndent === null || indentWidth(m[1], markers) <= baseIndent);
2366
+ }
2367
+ /**
2368
+ * Per-indent LIST memory (#3702 round 2, round review; widened round 5, M2):
2369
+ * each list level remembers whether a list is OPEN there, so an ordered
2370
+ * marker that does not start at `0.`/`1.` is an item when it continues or
2371
+ * follows a list at its level, and prose otherwise. A new opener at indent
2372
+ * `d` resets every deeper level (a new item starts new sub-lists); a
2373
+ * paragraph after a blank at indent `d` ends the lists at `d` and deeper; a
2374
+ * thematic break or a heading clears everything.
2375
+ *
2376
+ * Round 2 keyed this on whether the previous opener was ORDERED, so a bullet
2377
+ * item closed the run and `1. a` / `- b` / `5. c` folded `5. c` into `b` —
2378
+ * the mixed-file under-report #3702 names as the shape that bites. In
2379
+ * CommonMark `5. c` there opens a fresh ordered list (`start=5`): a non-1
2380
+ * ordinal is refused only where it would INTERRUPT A PARAGRAPH (§5.3), and
2381
+ * after a list item it interrupts nothing. Keying on "a list is open here"
2382
+ * is that rule as far as this parser can state it without a paragraph model.
2383
+ */
2384
+ class ListRuns {
2385
+ byIndent = new Set();
2386
+ at(indent) { return this.byIndent.has(indent); }
2387
+ opened(indent) {
2388
+ for (const d of [...this.byIndent])
2389
+ if (d > indent)
2390
+ this.byIndent.delete(d);
2391
+ this.byIndent.add(indent);
2392
+ }
2393
+ endedAt(indent) {
2394
+ for (const d of [...this.byIndent])
2395
+ if (d >= indent)
2396
+ this.byIndent.delete(d);
2397
+ }
2398
+ clear() { this.byIndent.clear(); }
2399
+ }
2400
+ /**
2401
+ * Leading-whitespace width of a line in CommonMark COLUMNS (§2.2: a tab
2402
+ * advances to the next multiple of 4), so `\t` and ` ` are different levels
2403
+ * and `\t` equals four spaces — character counting aliased them.
2404
+ */
2405
+ /**
2406
+ * Indent WIDTH under a grammar (#3702 round 2, review round 6). The deferred
2407
+ * grammar measures CommonMark columns; the Gaps grammar keeps `next`'s raw
2408
+ * character count, because its `blockStructure: false` opt-out promises
2409
+ * byte-for-byte parity and a column measure silently breaks it — a
2410
+ * tab-indented Gaps item followed by a two-space one split into two entries
2411
+ * where `next` folded them into one, and the reverse pair folded where `next`
2412
+ * split. The opt-out now covers indent semantics, not only fences and breaks.
2413
+ */
2414
+ function indentWidth(indent, markers) {
2415
+ return markers.blockStructure ? indentOf(indent) : indent.length;
2416
+ }
2417
+ function indentOf(line) {
2418
+ let col = 0;
2419
+ for (const ch of line) {
2420
+ if (ch === ' ')
2421
+ col += 1;
2422
+ else if (ch === '\t')
2423
+ col += 4 - (col % 4);
2424
+ else
2425
+ break;
2426
+ }
2427
+ return col;
2428
+ }
2429
+ /**
2430
+ * Classify `line` as a list-item opener under `markers`, applying the
2431
+ * ORDERED-START rule (#3702 round 2, B2; round 5, M1/M2): a dot-terminated
2432
+ * ordered marker opens an item when it starts at `0.` or `1.` (`01.`
2433
+ * included), or when a list is already open at its level (`inList` — the
2434
+ * caller's per-indent memory).
2435
+ *
2436
+ * Why: `\d{1,9}\.` alone reads ordinary prose as a list. "2026. was a bad
2437
+ * year for this module" and, under a `### Notes` heading, "3. is the number
2438
+ * of retries we settled on." are both sentences, and both opened an entry on
2439
+ * round 1 — the second one straight through the "prose is not an item"
2440
+ * contract that round claimed to preserve. CommonMark §5.3 faces the same
2441
+ * ambiguity when an ordered list would interrupt a paragraph and resolves it
2442
+ * the same way: the list must start with 1. This parser has no paragraph
2443
+ * model, so it applies that rule wherever NO list is open at the line's
2444
+ * level — the positions a sentence can occupy. Where a list IS open,
2445
+ * CommonMark accepts any start (a list item interrupts no paragraph), and so
2446
+ * does this. Numbers after the first are ignored, as CommonMark ignores
2447
+ * them, so `1. / 3. / 7.` is a three-item run.
2448
+ *
2449
+ * `0.` is accepted as a start (round 5, M1): CommonMark §5.2 permits any
2450
+ * 1-9-digit start number and a `0.`-numbered list is ordinary; refusing it
2451
+ * dropped ONLY the first item, since the run then started at `1.` — the
2452
+ * under-report that looks like a clean parse. A sentence opening with "0."
2453
+ * is not a shape anyone writes.
2454
+ *
2455
+ * Stated cost, pinned by test: a list whose first ordinal is 2 or more, at a
2456
+ * paragraph position, reads as prose UNTIL its first `0.`/`1.` line — the
2457
+ * loss is that prefix, not the whole list. Every ordered record the #3702
2458
+ * scan found starts at 1, and the hyphen-style `- ` alternative loses
2459
+ * nothing, so the trade buys the prose contract back at no measured cost.
2460
+ *
2461
+ * Bullet markers carry no rule — an asterisk bullet is not prose.
2462
+ */
2463
+ function matchListOpener(line, markers, inList) {
2464
+ const m = line.match(markers.open);
2465
+ if (!m)
2466
+ return null;
2467
+ const token = m[2];
2468
+ if (/^\d/.test(token) && !inList && parseInt(token, 10) > 1)
2469
+ return null;
2470
+ return { indent: indentWidth(m[1], markers) };
2471
+ }
2472
+ /** Character offset of each line's start and end within the text they were split from (on `\n`). */
2473
+ function lineOffsets(lines) {
2474
+ const lineStarts = [];
2475
+ const lineEnds = [];
2476
+ let cursor = 0;
2477
+ for (const line of lines) {
2478
+ lineStarts.push(cursor);
2479
+ cursor += line.length;
2480
+ lineEnds.push(cursor);
2481
+ cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
2482
+ }
2483
+ return { lineStarts, lineEnds };
2484
+ }
2485
+ /**
2486
+ * The heading-delimited split, carrying the per-line opener flags the deferred
2487
+ * field-extraction path needs (#3702 round 2, round review): the heading path
2488
+ * strips the marker off EVERY body line before field extraction (#3457), and a
2489
+ * line whose ordinal `matchListOpener` REJECTED must not be stripped — or
2490
+ * "3. status: resolved" as prose loses its `3. ` and reads as a resolved field.
2491
+ *
2492
+ * Since #3781 it also records each entry's character span (see
2493
+ * `DeferredHeadingEntry`) in this same pass — the reader's walk IS the
2494
+ * writer's walk, so there is no second copy of the grouping rules to drift.
2495
+ */
2496
+ function splitDeferredHeadingEntriesDetailed(sectionBody) {
1012
2497
  const headings = tokenizeHeadings(sectionBody);
1013
2498
  if (headings.length === 0)
1014
2499
  return null;
1015
2500
  const lines = sectionBody.split('\n');
2501
+ const { lineStarts, lineEnds } = lineOffsets(lines);
1016
2502
  const headingByLine = new Map();
1017
2503
  for (let i = 0; i < headings.length; i++) {
1018
2504
  // Container iff the next heading is deeper (see doc comment). An empty
@@ -1022,20 +2508,92 @@ function splitDeferredHeadingEntries(sectionBody) {
1022
2508
  headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
1023
2509
  }
1024
2510
  const entries = [];
1025
- let current = null; // accumulating a leaf heading's entry
1026
- let pending = []; // preamble / container-heading body lines
2511
+ // The leaf entry being accumulated, with the raw line range it spans.
2512
+ let current = null;
1027
2513
  let currentHasBullet = false;
2514
+ // The headless-shaped region being accumulated (preamble / a container
2515
+ // heading's direct lines): the reader's table-filtered, CR-stripped view,
2516
+ // plus the raw line range it spans.
2517
+ let pending = [];
2518
+ let pendingStartLine = -1;
2519
+ let pendingEndLine = -1;
2520
+ // Table lines are never entry lines; where one sits INSIDE an entry's raw
2521
+ // range, that entry's span is non-contiguous (#3781, `embeddedTable`).
2522
+ const tableLines = [];
2523
+ const tableWithin = (from, to) => tableLines.some((t) => t >= from && t <= to);
2524
+ // List memory for the leaf body being accumulated (#3702 round 2, B2) —
2525
+ // reset at every heading, so `### Notes` + "3. is the number…" is prose
2526
+ // while `### Steps` + "1. do / 2. then" is a list. A blank line then a
2527
+ // non-indented non-list line is a PARAGRAPH, which ends the list
2528
+ // (CommonMark §5.3); a non-indented line with no blank before it is lazy
2529
+ // continuation and keeps it open.
2530
+ const runs = new ListRuns();
2531
+ let blankSeen = false;
2532
+ // Same level rule as the headless splitter: the first opener in a leaf body
2533
+ // sets the base, and every indent at or shallower than it is one level.
2534
+ let bodyBase = null;
2535
+ const levelOf = (line) => {
2536
+ const ind = indentOf(line);
2537
+ return bodyBase !== null && ind <= bodyBase ? bodyBase : ind;
2538
+ };
2539
+ let scan = scanFencesFrom(lines, 0);
1028
2540
  const flushCurrent = () => {
1029
- // Keep the leaf entry only when its body carries a bullet; the heading
2541
+ // Keep the leaf entry only when its body carries a list item; the heading
1030
2542
  // text line itself (element 0) never counts as one.
1031
- if (current !== null && currentHasBullet)
1032
- entries.push(current);
2543
+ if (current !== null && currentHasBullet) {
2544
+ const table = tableWithin(current.startLine, current.endLine);
2545
+ entries.push({
2546
+ lines: current.lines,
2547
+ opener: current.opener,
2548
+ kind: 'leaf',
2549
+ start: table ? -1 : lineStarts[current.startLine],
2550
+ end: table ? -1 : lineEnds[current.endLine],
2551
+ embeddedTable: table,
2552
+ });
2553
+ }
1033
2554
  current = null;
1034
2555
  currentHasBullet = false;
1035
2556
  };
1036
2557
  const flushPending = () => {
1037
- entries.push(...splitGapsEntries(pending.join('\n')));
2558
+ if (pendingStartLine !== -1) {
2559
+ // Headless-region entries carry the splitter's own opener flags — the
2560
+ // same run state (ordered start, paragraph reset) that split them. The
2561
+ // region is contiguous (a heading flushes it), so the core's
2562
+ // region-relative spans translate by the region's own offset — unless a
2563
+ // table row was skipped inside it, where the reader's view and the raw
2564
+ // region disagree and no span is claimed. Both views split identically
2565
+ // otherwise: the core CR-strips per line, and a table row is the only
2566
+ // line the reader's view omits.
2567
+ const table = tableWithin(pendingStartLine, pendingEndLine);
2568
+ const region = table ? pending.join('\n') : lines.slice(pendingStartLine, pendingEndLine + 1).join('\n');
2569
+ const base = lineStarts[pendingStartLine];
2570
+ for (const { lines: entryLines, opener, start, end } of splitGapsEntriesCore(region, DEFERRED_BULLET_MARKERS)) {
2571
+ entries.push({
2572
+ lines: entryLines,
2573
+ opener,
2574
+ kind: 'pending',
2575
+ start: table ? -1 : base + start,
2576
+ end: table ? -1 : base + end,
2577
+ embeddedTable: table,
2578
+ });
2579
+ }
2580
+ }
1038
2581
  pending = [];
2582
+ pendingStartLine = -1;
2583
+ pendingEndLine = -1;
2584
+ };
2585
+ const push = (line, i, opener) => {
2586
+ if (current !== null) {
2587
+ current.lines.push(line);
2588
+ current.opener.push(opener);
2589
+ current.endLine = i;
2590
+ }
2591
+ else {
2592
+ pending.push(line);
2593
+ if (pendingStartLine === -1)
2594
+ pendingStartLine = i;
2595
+ pendingEndLine = i;
2596
+ }
1039
2597
  };
1040
2598
  for (let i = 0; i < lines.length; i++) {
1041
2599
  const lineNo = i + 1;
@@ -1046,23 +2604,73 @@ function splitDeferredHeadingEntries(sectionBody) {
1046
2604
  // ANY heading; flushing here keeps entries in document order even when
1047
2605
  // a container's direct bullets precede its first child entry.
1048
2606
  flushPending();
2607
+ runs.clear();
2608
+ blankSeen = false;
2609
+ bodyBase = null;
2610
+ // A heading ends the entry, and with it any unterminated fence (B1).
2611
+ scan = scanFencesFrom(lines, i + 1);
1049
2612
  if (!heading.isContainer) {
1050
2613
  // Leaf heading: open an entry with the heading text as line 0.
1051
- current = [heading.text];
2614
+ current = { lines: [heading.text], opener: [false], startLine: i, endLine: i };
1052
2615
  currentHasBullet = false;
1053
2616
  }
1054
2617
  continue;
1055
2618
  }
2619
+ // CR-strip ONCE and carry the stripped line everywhere below — into the
2620
+ // entry itself included (#3702 round 2, B1). `collectSection` slices raw
2621
+ // `\n`-split lines, so on a CRLF file every body line but the last still
2622
+ // carries its `\r`; the per-line marker strip feeding field extraction is
2623
+ // `$`-anchored and fails on such a line, the marker survives into
2624
+ // `extractGapEntryFields`, and the field is silently lost — a `**Status:**`
2625
+ // that is not the file's final line then resurfaces its entry as open.
2626
+ // The headless path (`splitGapsEntriesCore`) already stores stripped lines.
2627
+ const line = lines[i].replace(/\r$/, '');
2628
+ // B1: an unterminated fence runs to the end of its entry. The next line
2629
+ // shaped like a top-level item ends it — rescan from there, so a later
2630
+ // delimiter is read on its own terms (see `scanFencesFrom`).
2631
+ if (scan.unterminatedFrom !== -1 && i > scan.unterminatedFrom && topLevelItemShape(line, DEFERRED_BULLET_MARKERS, bodyBase)) {
2632
+ scan = scanFencesFrom(lines, i);
2633
+ }
2634
+ if (scan.fenced.has(i) || (scan.unterminatedFrom !== -1 && i >= scan.unterminatedFrom)) {
2635
+ // Fence content is body text, never list-item evidence (M2) — and
2636
+ // never an opener, so it is never marker-stripped for fields either.
2637
+ // Its opener ends the runs at its level and deeper, as a paragraph does.
2638
+ if (scan.openers.has(i))
2639
+ runs.endedAt(levelOf(line));
2640
+ push(line, i, false);
2641
+ continue;
2642
+ }
2643
+ // A thematic break is a separator: not evidence, and it clears the list
2644
+ // memory (M1). It stays a BODY line — the entry's span must stay
2645
+ // contiguous for the writer, and the entry's name stays what `next`
2646
+ // reported for a body containing one (round 5, m3).
2647
+ if (THEMATIC_BREAK_RE.test(line)) {
2648
+ runs.clear();
2649
+ blankSeen = false;
2650
+ push(line, i, false);
2651
+ continue;
2652
+ }
1056
2653
  // Table lines belong to parseDeferredTableItems, never to a heading entry.
1057
- if (/^\s*\|/.test(lines[i].replace(/\r$/, '')))
2654
+ if (/^\s*\|/.test(line)) {
2655
+ tableLines.push(i);
1058
2656
  continue;
2657
+ }
1059
2658
  if (current !== null) {
1060
- current.push(lines[i]);
1061
- if (/^\s*-\s/.test(lines[i].replace(/\r$/, '')))
2659
+ const opener = matchListOpener(line, DEFERRED_BULLET_MARKERS, runs.at(levelOf(line)));
2660
+ push(line, i, opener !== null);
2661
+ if (opener !== null) {
1062
2662
  currentHasBullet = true;
2663
+ if (bodyBase === null)
2664
+ bodyBase = opener.indent;
2665
+ runs.opened(levelOf(line));
2666
+ }
2667
+ else if (blankSeen && line.trim() !== '') {
2668
+ runs.endedAt(levelOf(line)); // a paragraph after a blank line ends the lists at its level and deeper
2669
+ }
2670
+ blankSeen = line.trim() === '';
1063
2671
  }
1064
2672
  else {
1065
- pending.push(lines[i]);
2673
+ push(line, i, false); // the core derives its own opener verdicts for the region
1066
2674
  }
1067
2675
  }
1068
2676
  flushCurrent();
@@ -1117,46 +2725,139 @@ function parseDeferredTableItems(sectionBody) {
1117
2725
  * boundary — a second, independently-written grouping pass is exactly how a
1118
2726
  * span-carrying sibling could disagree with the plain-lines version it is
1119
2727
  * supposed to be span-annotating.
2728
+ *
2729
+ * `markers` selects the marker set an entry may OPEN with (#3702). It defaults
2730
+ * to the hyphen-only Gaps form, so every pre-existing caller is unaffected;
2731
+ * the deferred-items callers pass `DEFERRED_BULLET_MARKERS`. Parameterising
2732
+ * the shared seam — rather than widening it in place — is what keeps the
2733
+ * template-mandated Gaps grammar out of the deferred-items ruling's blast
2734
+ * radius while still leaving exactly ONE grouping pass in the module.
1120
2735
  */
1121
- function splitGapsEntriesCore(sectionBody) {
2736
+ function splitGapsEntriesCore(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
1122
2737
  const rawLines = sectionBody.split('\n');
1123
- const lineStarts = [];
1124
- const lineEnds = [];
1125
- let cursor = 0;
1126
- for (const rawLine of rawLines) {
1127
- lineStarts.push(cursor);
1128
- cursor += rawLine.length;
1129
- lineEnds.push(cursor);
1130
- cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
1131
- }
2738
+ const { lineStarts, lineEnds } = lineOffsets(rawLines);
1132
2739
  const entries = [];
1133
2740
  let current = null;
1134
2741
  let currentStartLine = -1;
1135
2742
  let currentEndLine = -1;
1136
2743
  let baseIndent = null;
2744
+ // List memory per indent (#3702 round 2, B2 + round review; round 5, M2) —
2745
+ // the top level decides entry boundaries; nested levels decide only which
2746
+ // continuation lines count as accepted openers for field stripping.
2747
+ const runs = new ListRuns();
2748
+ // Per-line opener flags for `current`, recorded HERE — the one place the
2749
+ // run state is known — so the heading path's strip-only-openers rule reads
2750
+ // the splitter's own verdict instead of re-deriving it (round review: a
2751
+ // re-derivation without the paragraph reset re-accepted a rejected ordinal).
2752
+ let currentOpeners = [];
1137
2753
  const flush = () => {
1138
2754
  if (current !== null) {
1139
- entries.push({ lines: current, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
2755
+ entries.push({ lines: current, opener: currentOpeners, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
1140
2756
  }
1141
2757
  };
2758
+ // #3898 (from `next`): a spaced-hyphen thematic break (`- - -`, `- -`,
2759
+ // `- - -`, …) in `## Gaps` is a SEPARATOR, not an entry. The hyphen opener
2760
+ // matches it (hyphen + whitespace), which fabricated a gap named `- -` with
2761
+ // result 'unknown' — an item no edit can clear, because there is no entry,
2762
+ // only the separator the author wrote deliberately. A line whose content
2763
+ // after the opening marker is solely hyphens and spaces (with at least one
2764
+ // further hyphen) is skipped: it neither opens an entry nor folds into the
2765
+ // current one. Deliberately NOT a full thematic-break concept (option 2 in
2766
+ // the issue): a break does not close the Gaps list — entries after it keep
2767
+ // parsing. The deferred grammar (`blockStructure`) has its own, CommonMark
2768
+ // reading of the same line through THEMATIC_BREAK_RE below, where a break
2769
+ // CLOSES the list; this helper is consulted only for the Gaps set.
2770
+ const isSeparatorShaped = (line, bulletPrefixLen) => {
2771
+ const remainder = line.slice(bulletPrefixLen);
2772
+ return /^[-\s]*$/.test(remainder) && remainder.includes('-');
2773
+ };
2774
+ // Block structure (M1/M2 + column indents) is a property of the GRAMMAR,
2775
+ // not of this seam: the Gaps set opts out and stays byte-for-byte on its
2776
+ // `next` behaviour — see `indentWidth` for the indent half of that opt-out.
2777
+ let scan = markers.blockStructure ? scanFencesFrom(rawLines, 0) : NO_FENCES;
2778
+ let blankSeen = false;
2779
+ // The run LEVEL of a line: every indent at or shallower than the list's
2780
+ // base is the one top level (a dedenting list keeps its entry boundaries);
2781
+ // deeper indents are their own nested levels.
2782
+ const levelOf = (line) => {
2783
+ const ind = indentWidth(line.match(/^[ \t]*/)[0], markers);
2784
+ return baseIndent !== null && ind <= baseIndent ? baseIndent : ind;
2785
+ };
1142
2786
  rawLines.forEach((rawLine, idx) => {
1143
2787
  const line = rawLine.replace(/\r$/, '');
1144
- const bulletMatch = line.match(/^(\s*)-\s/);
1145
- if (bulletMatch) {
1146
- const indent = bulletMatch[1].length;
2788
+ // B1: an unterminated fence runs to the end of its entry — the next line
2789
+ // shaped like a top-level item ends it; rescan from there so a later
2790
+ // delimiter is read on its own terms (see `scanFencesFrom`).
2791
+ if (scan.unterminatedFrom !== -1 && idx > scan.unterminatedFrom && topLevelItemShape(line, markers, baseIndent)) {
2792
+ scan = scanFencesFrom(rawLines, idx);
2793
+ }
2794
+ if (scan.fenced.has(idx) || (scan.unterminatedFrom !== -1 && idx >= scan.unterminatedFrom)) {
2795
+ // Fence content never opens an entry (M2). Inside an open entry it is
2796
+ // continuation — pushed, so the span invariant `acknowledgeDeferredItem`
2797
+ // re-verifies still holds; before the first entry it is discarded. A
2798
+ // fence is a non-list block: its opener ends the runs at its level and
2799
+ // deeper, exactly as a paragraph does.
2800
+ if (scan.openers.has(idx))
2801
+ runs.endedAt(levelOf(line));
2802
+ if (current !== null) {
2803
+ current.push(line);
2804
+ currentOpeners.push(false);
2805
+ currentEndLine = idx;
2806
+ }
2807
+ return;
2808
+ }
2809
+ if (markers.blockStructure && THEMATIC_BREAK_RE.test(line)) {
2810
+ // A thematic break closes the list (M1): the open entry ends here, the
2811
+ // break itself is neither an item nor a continuation, and nothing after
2812
+ // it joins the closed entry — the next opener starts fresh.
2813
+ flush();
2814
+ current = null;
2815
+ runs.clear();
2816
+ blankSeen = false;
2817
+ return;
2818
+ }
2819
+ // #3898 narrowed skip (review disposition a), Gaps set only: a
2820
+ // separator-shaped line is skipped when it sits BETWEEN entries (nothing
2821
+ // open yet, or it would open a top-level entry — where the phantom came
2822
+ // from). One landing strictly INSIDE a live entry (indent > baseIndent)
2823
+ // folds back as a continuation line, so the entry's GapsEntrySpan stays
2824
+ // byte-contiguous — the span invariant below and the ack writer's identity
2825
+ // re-verification both hold. The indent compare is the raw character
2826
+ // count, which is what `indentWidth` measures for the Gaps set.
2827
+ if (!markers.blockStructure) {
2828
+ const bulletMatch = line.match(/^(\s*)-\s/);
2829
+ if (bulletMatch && isSeparatorShaped(line, bulletMatch[0].length) &&
2830
+ (current === null || bulletMatch[1].length <= (baseIndent ?? 0))) {
2831
+ return; // separator line between entries — neither an opener nor a continuation
2832
+ }
2833
+ }
2834
+ const opener = matchListOpener(line, markers, runs.at(levelOf(line)));
2835
+ if (opener !== null) {
2836
+ const { indent } = opener;
1147
2837
  if (baseIndent === null)
1148
2838
  baseIndent = indent;
2839
+ runs.opened(levelOf(line));
1149
2840
  if (indent <= baseIndent) {
1150
2841
  flush();
1151
2842
  current = [line];
2843
+ currentOpeners = [true];
1152
2844
  currentStartLine = idx;
1153
2845
  currentEndLine = idx;
2846
+ blankSeen = false; // an opener is not blank — the memory must not survive it
1154
2847
  return;
1155
2848
  }
1156
2849
  }
1157
2850
  if (current !== null) {
1158
2851
  current.push(line);
2852
+ currentOpeners.push(opener !== null);
1159
2853
  currentEndLine = idx;
2854
+ // A blank line then a top-level non-list line is a PARAGRAPH: the list
2855
+ // is over (CommonMark §5.3) and a later `5. x` is prose. Without the
2856
+ // blank it is lazy continuation and the run stays open.
2857
+ const blank = line.trim() === '';
2858
+ if (!blank && blankSeen && opener === null)
2859
+ runs.endedAt(levelOf(line));
2860
+ blankSeen = opener === null && blank;
1160
2861
  }
1161
2862
  // else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
1162
2863
  });
@@ -1165,7 +2866,8 @@ function splitGapsEntriesCore(sectionBody) {
1165
2866
  }
1166
2867
  /**
1167
2868
  * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
1168
- * `- ` bullet openers.
2869
+ * bullet openers — `- ` for Gaps, or whichever set `markers` names (#3702:
2870
+ * the deferred-items callers pass the widened CommonMark set).
1169
2871
  *
1170
2872
  * The indentation of the FIRST bullet line encountered establishes the
1171
2873
  * "top-level" indent for the whole section; any subsequent `- `-opening line
@@ -1178,16 +2880,17 @@ function splitGapsEntriesCore(sectionBody) {
1178
2880
  *
1179
2881
  * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
1180
2882
  * the template emits) are discarded. An empty/whitespace-only section body
1181
- * (heading present, no bullets) returns `[]`.
2883
+ * (heading present, no bullets) returns `[]`. Fenced code never opens an
2884
+ * entry and a thematic break closes the open one (#3702 round 2, M1/M2).
1182
2885
  */
1183
- function splitGapsEntries(sectionBody) {
1184
- return splitGapsEntriesCore(sectionBody).map((entry) => entry.lines);
2886
+ function splitGapsEntries(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
2887
+ return splitGapsEntriesCore(sectionBody, markers).map((entry) => entry.lines);
1185
2888
  }
1186
2889
  /**
1187
2890
  * Sibling of `splitGapsEntries` (F1, #3458 follow-up review) that ADDITIVELY
1188
2891
  * carries each entry's character span — every existing `splitGapsEntries`
1189
2892
  * caller (`parseGapsItems`, `parseDeferredItemsWithStatus`,
1190
- * `splitDeferredHeadingEntries`'s `flushPending`) is unaffected and keeps
2893
+ * `splitDeferredHeadingEntriesDetailed`'s `flushPending`) is unaffected and keeps
1191
2894
  * using the plain `lines`-only shape. `acknowledgeDeferredItem` is the one
1192
2895
  * caller that needs a span: it used to select an entry via `splitGapsEntries`
1193
2896
  * and then RE-FIND that entry's location with a fresh regex search over
@@ -1199,8 +2902,8 @@ function splitGapsEntries(sectionBody) {
1199
2902
  * one. Carrying the span out of THIS same pass — the one that already knows
1200
2903
  * exactly where the entry lives — removes the re-derivation step entirely.
1201
2904
  */
1202
- function splitGapsEntriesWithSpans(sectionBody) {
1203
- return splitGapsEntriesCore(sectionBody);
2905
+ function splitGapsEntriesWithSpans(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
2906
+ return splitGapsEntriesCore(sectionBody, markers);
1204
2907
  }
1205
2908
  /**
1206
2909
  * Extract `key: value` fields from one Gaps entry's lines, anchored to the
@@ -1228,182 +2931,603 @@ function splitGapsEntriesWithSpans(sectionBody) {
1228
2931
  * keep their literal case, and mid-line emphasis is untouched, preserving the
1229
2932
  * start-anchored decoy invariant above.
1230
2933
  */
1231
- function extractGapEntryFields(entryLines) {
2934
+ function extractGapEntryFields(entryLines, markers = HYPHEN_BULLET_MARKERS, openerFlags) {
1232
2935
  const fields = {};
1233
- const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
1234
- const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
1235
- entryLines.forEach((rawLine, idx) => {
1236
- const line = rawLine.replace(/\r$/, '');
1237
- // Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
1238
- // a later line belongs to a nested sub-list and is handled by
1239
- // `splitGapsEntries` already folding it in — it is not itself a field
1240
- // line unless it independently matches `key: value` after stripping.
1241
- const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
1242
- const content = (idx === 0 && bulletStripped ? bulletStripped[2] : line.trim())
1243
- .replace(boldedKeyRe, (_m, key) => `${key.toLowerCase()}:`);
1244
- const m = fieldLineRe.exec(content);
1245
- if (!m)
2936
+ // A fenced line is content, not a field (#3702 round 2, round review): the
2937
+ // splitters already keep fence lines from OPENING an entry, and a
2938
+ // `status: resolved` quoted inside a code block must not resolve one either.
2939
+ // An entry is a contiguous slice and a fence never spans two entries (the
2940
+ // opener of the next entry would be fence content), so scanning the entry's
2941
+ // own lines classifies exactly what the splitter classified.
2942
+ //
2943
+ // RAW lines, and that is the fix for #3702 round 3, m7. The heading path
2944
+ // used to marker-strip its lines BEFORE calling this function, so the scan
2945
+ // below ran over text the splitter never saw: `- ```sh` is an ordinary
2946
+ // bullet to the splitter, but strips to ```` ```sh ````, which opens a
2947
+ // fence here that exists in no other pass. A `**Status:** resolved` line
2948
+ // after it was then suppressed as fence content and its resolved entry
2949
+ // resurfaced as open. The stripping now happens INSIDE this function, after
2950
+ // the fence scan, driven by the splitter's own per-line opener verdict.
2951
+ entryFieldLines(entryLines, markers, openerFlags).forEach((field) => {
2952
+ if (!field)
1246
2953
  return;
1247
- const key = m[1];
1248
- let value = m[2].trim();
1249
- if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
1250
- value = value.slice(1, -1);
1251
- }
1252
- if (!(key in fields))
1253
- fields[key] = value;
2954
+ if (!(field.key in fields))
2955
+ fields[field.key] = field.value;
1254
2956
  });
1255
2957
  return fields;
1256
2958
  }
1257
- /** Fallback display text for a Gaps entry with no parseable `truth:` field. */
1258
- function rawGapEntryText(entryLines) {
2959
+ /**
2960
+ * Per line of an entry, the field it declares — or `null` where it declares
2961
+ * none, INCLUDING because it is fenced.
2962
+ *
2963
+ * This is the seam, and it exists because `parseGapEntryFieldLine` alone was
2964
+ * not it (#3702 round 3, pre-push review). The reader applied the fence gate
2965
+ * before classifying and the acknowledge writer did not, so a `status:` line
2966
+ * inside a fenced block was selected by the writer and skipped by the reader:
2967
+ * the write produced a line nothing reads, the read-back guard refused it, and
2968
+ * the entry became impossible to acknowledge at all — `audit acknowledge`
2969
+ * surfaced an internal error and `complete-milestone` halted on it. That shape
2970
+ * acknowledged cleanly on `next`, so it was a regression introduced by the fix
2971
+ * for the nested-marker one, and the claim "the writer cannot select a line the
2972
+ * reader will not read back" was false while the fence gate lived on one side.
2973
+ *
2974
+ * Both sides call this now, so the claim is structural rather than asserted.
2975
+ */
2976
+ function entryFieldLines(entryLines, markers = HYPHEN_BULLET_MARKERS, openerFlags) {
2977
+ const fenced = entryFencedLines(entryLines, markers, openerFlags);
2978
+ return entryLines.map((rawLine, idx) => (fenced.has(idx) ? null : parseGapEntryFieldLine(rawLine, markers, stripsMarkerAt(idx, openerFlags))));
2979
+ }
2980
+ /**
2981
+ * The fenced lines of ONE entry, as the reader and the writer both see them.
2982
+ * A leaf's line 0 is its heading TEXT, not a Markdown line: a heading that
2983
+ * reads ``` or ~~~ is a heading, and must not open a fence over the body
2984
+ * beneath it (round 5, RV6.5 — it fenced every field line, so the reader
2985
+ * read nothing and the writer's marker landed on a line nothing reads).
2986
+ * `openerFlags[0] === false` is the leaf tell: a pending or headless entry's
2987
+ * line 0 is an accepted opener, and a marker line is never a delimiter.
2988
+ */
2989
+ function entryFencedLines(entryLines, markers, openerFlags) {
2990
+ if (!markers.blockStructure)
2991
+ return new Set();
2992
+ const leaf = openerFlags !== undefined && openerFlags[0] === false;
2993
+ return fencedLineSet(leaf ? ['', ...entryLines.slice(1)] : entryLines);
2994
+ }
2995
+ /**
2996
+ * Which lines of an entry carry an entry-opening marker to be stripped before
2997
+ * the line is read as a field.
2998
+ *
2999
+ * Without flags — the headless and `## Gaps` shapes — that is line 0 alone: a
3000
+ * marker on a later line belongs to a nested sub-list (`splitGapsEntries`
3001
+ * already folded it in) and is not a field line unless it independently
3002
+ * matches `key: value` after a plain trim.
3003
+ *
3004
+ * With flags — the heading shape — it is whichever lines the SPLITTER accepted
3005
+ * as list openers, because there every body line may be a sibling bullet
3006
+ * carrying a field (#3457) while line 0 is the heading TEXT and carries no
3007
+ * marker at all. Reading the splitter's verdict rather than re-deriving it is
3008
+ * what keeps a rejected ordinal (`3. status: resolved` as prose) from being
3009
+ * stripped into a field.
3010
+ */
3011
+ function stripsMarkerAt(idx, openerFlags) {
3012
+ return openerFlags ? openerFlags[idx] === true : idx === 0;
3013
+ }
3014
+ /**
3015
+ * The ONE place an entry line is classified as a `key: value` field line.
3016
+ * `extractGapEntryFields` reads through it, and `acknowledgeDeferredItem`
3017
+ * locates the line it will rewrite through it.
3018
+ *
3019
+ * Sharing the classifier is what makes the writer structurally unable to
3020
+ * select a line the reader will not read back (#3702 round 3, B1; the shape
3021
+ * is #3773's, parameterised here by `markers` per the round-3 review's
3022
+ * prescribed end state). The writer used to carry its own marker-widened
3023
+ * status regex, so a nested ` * status: pending` was selectable by the
3024
+ * writer and invisible to this reader: acknowledge rewrote it in place,
3025
+ * returned `ok`, and the item stayed outstanding forever. A single classifier
3026
+ * has no second copy to drift from.
3027
+ *
3028
+ * `valueStart` is the offset, in the CR-stripped line, at which the VALUE
3029
+ * begins — so a rewrite can replace the value without a second regex of its
3030
+ * own. The bolded-key unwrap below is a PREFIX rewrite, so the tail of the
3031
+ * rewritten content is byte-identical to the tail of the original and the
3032
+ * offset maps back directly.
3033
+ *
3034
+ * Returns `null` for a non-field line.
3035
+ */
3036
+ function parseGapEntryFieldLine(rawLine, markers = HYPHEN_BULLET_MARKERS, stripMarker = true) {
3037
+ const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
3038
+ const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
3039
+ const line = rawLine.replace(/\r$/, '');
3040
+ const bulletStripped = stripMarker ? line.match(markers.strip) : null;
3041
+ const bare = bulletStripped ? bulletStripped[2] : line.trim();
3042
+ // Where `bare` begins in `line`. The two branches differ: the marker strip's
3043
+ // group 2 runs to end-of-line, so it is a plain suffix; `trim()` also cuts
3044
+ // the tail, so its offset is the LEADING run alone. Computing one from the
3045
+ // other's shape under-counts by the trailing whitespace.
3046
+ const headLen = bulletStripped ? line.length - bare.length : line.length - line.trimStart().length;
3047
+ const content = bare.replace(boldedKeyRe, (_m, key) => `${key.toLowerCase()}:`);
3048
+ const m = fieldLineRe.exec(content);
3049
+ if (!m)
3050
+ return null;
3051
+ let value = m[2].trim();
3052
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
3053
+ value = value.slice(1, -1);
3054
+ }
3055
+ return { key: m[1], value, valueStart: headLen + (bare.length - m[2].length) };
3056
+ }
3057
+ /**
3058
+ * Fallback display text for a Gaps entry with no parseable `truth:` field.
3059
+ *
3060
+ * `markers` selects which opening marker is stripped (#3702) — hyphen-only by
3061
+ * default, the widened set for deferred-items callers, so a `*`-opened entry
3062
+ * renders the same name its hyphen twin would. That name is the key
3063
+ * `acknowledgeDeferredItem` matches on, so the two MUST use the same set:
3064
+ * rendering `* alpha` where the parse surfaced `alpha` would make the entry
3065
+ * un-acknowledgeable.
3066
+ *
3067
+ * `openerFlags` decides WHICH lines are stripped, and on the heading shape
3068
+ * line 0 is not one of them (#3702 round 3, m8). There line 0 is the heading
3069
+ * TEXT, so an unconditional strip renamed `### 1. Race in the writer` to
3070
+ * `Race in the writer` and `### * starred title` to `starred title` — both
3071
+ * silent renames of the very key acknowledge matches on, and both a change
3072
+ * from this parser's behaviour on `next`.
3073
+ */
3074
+ function rawGapEntryText(entryLines, markers = HYPHEN_BULLET_MARKERS, openerFlags) {
1259
3075
  return entryLines
1260
- .map((l, i) => (i === 0 ? l.replace(/^(\s*)-\s+/, '') : l.trim()))
3076
+ // Line 0 ONLY, and only if the splitter accepted it as an opener. The
3077
+ // opener flags say which lines carry a marker; the entry's NAME is a
3078
+ // different question, and stripping a body line's marker out of it changes
3079
+ // the key `acknowledgeDeferredItem` matches on.
3080
+ .map((l, i) => (i === 0 && stripsMarkerAt(0, openerFlags) ? l.replace(markers.strip, '$2') : l.trim()))
1261
3081
  .join(' ')
1262
3082
  .trim();
1263
3083
  }
1264
3084
  // ─── parseVerificationItems ───────────────────────────────────────────────────
3085
+ /**
3086
+ * The entry's `status:`, lowercased, or undefined when absent/blank/non-scalar.
3087
+ *
3088
+ * The entry is a PARSED OBJECT, so this reads a named field rather than
3089
+ * matching prose. That distinction is the whole point: against the
3090
+ * display-flattened string, a `truth:` whose text mentions "status: resolved"
3091
+ * is indistinguishable from an entry that carries the field.
3092
+ */
3093
+ function frontmatterEntryStatus(entry) {
3094
+ const status = entry['status'];
3095
+ if (typeof status !== 'string' || status.trim() === '')
3096
+ return undefined;
3097
+ return status.trim().toLowerCase();
3098
+ }
3099
+ /**
3100
+ * Is this `gaps:` frontmatter entry already closed? (#3850)
3101
+ *
3102
+ * `status: resolved`, and nothing else. Byte-identical to the rule
3103
+ * `parseGapsItems` applies to a `## Gaps` markdown section, deliberately: the
3104
+ * two readers see the SAME authored vocabulary in two places, and a closure
3105
+ * rule that differed between them would let one entry read closed in one
3106
+ * reader and open in the other. `parseVerificationGapsItems`' docstring claims
3107
+ * it mirrors `parseGapsItems`' fail-safe status handling; this is the line
3108
+ * that makes that claim true rather than approximately true.
3109
+ *
3110
+ * So a `gaps:` entry carrying `resolution:` and no `status:` SURFACES, via the
3111
+ * same 'unknown'-status fallback `parseGapsItems` already gives it (#3879
3112
+ * review round 4, Major).
3113
+ */
3114
+ function isGapsEntryResolved(entry) {
3115
+ if (!entry)
3116
+ return false;
3117
+ return frontmatterEntryStatus(entry) === 'resolved';
3118
+ }
3119
+ /**
3120
+ * Is this `human_verification:` frontmatter entry already closed? (#3850)
3121
+ *
3122
+ * Verifier-written entries record closure as a `resolution:` field with no
3123
+ * `status:` at all, so `resolution:` closes — but ONLY when no `status:`
3124
+ * contradicts it. `status:` is authoritative wherever it is readable.
3125
+ *
3126
+ * The contradiction guard is the #3879 round-4 Major fix. Without it,
3127
+ * `status: failed` + `resolution: "attempted retry, still failing"` — a
3128
+ * plausible informational note, not a closure assertion — is silently dropped
3129
+ * from the report, which is the exact silently-vanishing-item defect class
3130
+ * #3850 exists to close, reached by field COMBINATION instead of file STATUS.
3131
+ *
3132
+ * This is not a judgment call about YAML: it is the rule this codebase already
3133
+ * applies to the same field pair one module over. `validateResolution`
3134
+ * (`probe-core.cts`) rejects a populated `resolution:` on a non-resolved status
3135
+ * outright — "a populated payload is an authoring mistake (the author meant
3136
+ * resolved/dismissed) that would otherwise be silently dropped into the
3137
+ * unresolved count with no error pointing at it. Reject it so the mistake
3138
+ * surfaces." A reporter cannot throw, so the fail-safe equivalent of surfacing
3139
+ * the mistake is to surface the ITEM.
3140
+ *
3141
+ * #3850's suggested fix (2) states the skip unconditionally — "Skip entries
3142
+ * carrying a `resolution:` field" — and its named scenario (one file with 14 of
3143
+ * 16 entries resolved) is unaffected by the guard: those entries close either
3144
+ * on `resolution:` with no contradicting status, or on `status: resolved`.
3145
+ * Both still skip. The guard only changes entries whose own two fields
3146
+ * disagree, and for those the fail-safe direction on a false-NEGATIVE bug is to
3147
+ * report, not to drop.
3148
+ */
3149
+ function isHumanVerificationEntryResolved(entry) {
3150
+ if (!entry)
3151
+ return false;
3152
+ const status = frontmatterEntryStatus(entry);
3153
+ if (status !== undefined)
3154
+ return status === 'resolved';
3155
+ const resolution = entry['resolution'];
3156
+ return typeof resolution === 'string' && resolution.trim() !== '';
3157
+ }
3158
+ /**
3159
+ * A named string field of a parsed entry, or undefined when absent/non-scalar.
3160
+ *
3161
+ * A whitespace-only value counts as absent, but a present value is returned
3162
+ * VERBATIM — trimming it here would silently rewrite an author's `truth:` on
3163
+ * its way to becoming the item's display name, which is a different string from
3164
+ * the one in the file.
3165
+ */
3166
+ function isFrontmatterObjectEntry(entry) {
3167
+ return !!entry && typeof entry === 'object' && !Array.isArray(entry);
3168
+ }
3169
+ /**
3170
+ * The PARSED object behind each element of a frontmatter array, positionally
3171
+ * aligned with that array's DISPLAY renderings — `null` at any index whose
3172
+ * entry is not an object (#3850).
3173
+ *
3174
+ * Both frontmatter readers below need the same two things about one array: the
3175
+ * string each entry has always displayed as, and the fields it actually
3176
+ * carries. `extractFrontmatter` gives the first, `frontmatterListEntries` the
3177
+ * second, and the ONLY safe way to use them together is by index — so the
3178
+ * pairing is done once, here, rather than open-coded twice.
3179
+ *
3180
+ * Alignment is checked, not assumed. Both arrays come from one parse of one
3181
+ * region (they share a fence parser), so they agree in practice; if they ever
3182
+ * did not, an index would name a DIFFERENT entry's fields and the resolved-skip
3183
+ * would close the wrong row. All-`null` is the correct degradation: no entry is
3184
+ * skipped as closed, which over-reports rather than mis-attributes.
3185
+ *
3186
+ * The length check is UNREACHABLE through content today and is kept anyway
3187
+ * (#3879 review round 4, Minor 2). It was verified unreachable rather than
3188
+ * assumed: both readers enter through `frontmatterRegion`, `extractFrontmatter`'s
3189
+ * only extra argument (`sourcePath`) gates a warning and nothing else, and the
3190
+ * display step — `normalizeParsedValue`'s `value.map(...)` — is 1:1 and drops no
3191
+ * element. So the guard is a drift alarm for a future edit to either parser, not
3192
+ * a live branch. That makes it untestable through the two readers, which is why
3193
+ * this helper is exported for tests: the degradation is asserted against the
3194
+ * function directly rather than left as the one unpinned branch in the family.
3195
+ */
3196
+ function parsedEntriesFor(content, key, flattened) {
3197
+ const parsed = frontmatterListEntries(content, key);
3198
+ if (!parsed || parsed.length !== flattened.length)
3199
+ return flattened.map(() => null);
3200
+ return parsed.map((entry) => (isFrontmatterObjectEntry(entry) ? entry : null));
3201
+ }
3202
+ function entryField(entry, key) {
3203
+ const v = entry[key];
3204
+ if (typeof v === 'string')
3205
+ return v.trim() === '' ? undefined : v;
3206
+ if (typeof v === 'number' || typeof v === 'boolean')
3207
+ return String(v);
3208
+ return undefined;
3209
+ }
3210
+ /**
3211
+ * One parsed `gaps:` entry -> one `UatItem`.
3212
+ *
3213
+ * ONE call site, `parseVerificationGapsItems` (#3850 review round 3, Minor 1 —
3214
+ * an earlier revision's comment claimed both frontmatter readers shared this,
3215
+ * and a dead `forcedResult` option existed to serve the second one; neither was
3216
+ * ever true, and the claim made a deliberate difference read as an accident).
3217
+ *
3218
+ * WHY the two frontmatter readers derive fields differently, since they sit
3219
+ * side by side and it is a fair question: each mirrors its OWN established
3220
+ * sibling rather than each other.
3221
+ *
3222
+ * - This one mirrors `parseGapsItems`, the `## Gaps` markdown reader, field
3223
+ * for field: `status:` supplies `result` with the module's documented
3224
+ * fail-safe `'unknown'` when absent (surface a questionable entry rather
3225
+ * than drop a real one), `test` is taken ONLY when the entry declares one,
3226
+ * and `reason` passes through. A `gaps:` entry carries its own status, so
3227
+ * inventing one would be a lie.
3228
+ * - `parseHumanVerificationItems` mirrors #2286's `human_verification:`
3229
+ * behaviour: the array IS the outstanding list, so every surviving entry is
3230
+ * `human_needed` by construction and its `test` is its ROW, because those
3231
+ * entries carry no number of their own.
3232
+ *
3233
+ * Converging them would mean changing one of those two established contracts
3234
+ * for the convenience of symmetry. See `parseVerificationItems` for the one
3235
+ * consequence that is genuinely open (a `test` number is unique per array, not
3236
+ * per report).
3237
+ *
3238
+ * The display name falls back to `flattenObjectListItem` — the SAME renderer
3239
+ * `extractFrontmatter` applies — so an entry with no `truth:` reads exactly as
3240
+ * it always did, byte for byte.
3241
+ */
3242
+ function frontmatterEntryToUatItem(entry) {
3243
+ const status = entryField(entry, 'status') ?? 'unknown';
3244
+ const reason = entryField(entry, 'reason');
3245
+ const item = {
3246
+ name: entryField(entry, 'truth') || flattenObjectListItem(entry),
3247
+ result: status,
3248
+ category: categorizeItem(status, reason, undefined),
3249
+ };
3250
+ // No `test:` read (#3879 review round 4, Minor 4). A `gaps:` entry has no
3251
+ // `test:` in its vocabulary — the verification template's entries carry
3252
+ // `truth` / `status` / `reason` / `artifacts` / `missing` — so reading one was
3253
+ // speculative support for a field this shape does not have. It also collided:
3254
+ // `parseHumanVerificationItems` numbers its items 1..N by array POSITION,
3255
+ // so a `gaps:` entry that did carry `test: 1` produced two items numbered 1
3256
+ // in one file's combined list. Not reading it makes the collision impossible
3257
+ // rather than unlikely, and does not renumber anything: an offset would have
3258
+ // rewritten an authored value, which is the opposite of `entryField`'s
3259
+ // verbatim contract.
3260
+ if (reason)
3261
+ item.reason = reason;
3262
+ return item;
3263
+ }
3264
+ /**
3265
+ * Surface a `gaps_found` report's frontmatter `gaps:` array (#3850).
3266
+ *
3267
+ * Mirrors `parseGapsItems`' field vocabulary and fail-safe status handling, but
3268
+ * reads the FRONTMATTER array rather than a `## Gaps` markdown section —
3269
+ * `parseGapsItems` is reached only from `parseUatItems`, and the verification
3270
+ * template puts gaps in frontmatter, so no existing reader covers this shape.
3271
+ */
3272
+ function parseVerificationGapsItems(content) {
3273
+ const flattened = extractFrontmatter(content)['gaps'];
3274
+ if (!Array.isArray(flattened))
3275
+ return [];
3276
+ const parsed = parsedEntriesFor(content, 'gaps', flattened);
3277
+ const items = [];
3278
+ flattened.forEach((display, idx) => {
3279
+ const entry = parsed[idx];
3280
+ // A non-object entry (a bare scalar, a null from a `- ` with nothing after
3281
+ // it, a nested sequence) still surfaces, named by the SAME renderer every
3282
+ // other frontmatter reader names it by. Dropping it would be this module's
3283
+ // wrong direction on a false-NEGATIVE bug: `parseGapsItems`' own
3284
+ // 'unknown'-status fallback exists to surface a questionable entry rather
3285
+ // than lose a real one, and an entry with no readable status is exactly
3286
+ // that. It carries no fields, so it can never be skipped as closed.
3287
+ if (!entry) {
3288
+ items.push({
3289
+ name: normalizeHumanVerificationEntry(display),
3290
+ result: 'unknown',
3291
+ category: categorizeItem('unknown'),
3292
+ });
3293
+ return;
3294
+ }
3295
+ if (isGapsEntryResolved(entry))
3296
+ return;
3297
+ items.push(frontmatterEntryToUatItem(entry));
3298
+ });
3299
+ return items;
3300
+ }
3301
+ /**
3302
+ * #3850: `gaps_found` is as outstanding as `human_needed`.
3303
+ *
3304
+ * `cmdAuditUat` admits BOTH statuses, then this function honoured only one and
3305
+ * returned an empty array for the other. Because `cmdAuditUat` pushes a file
3306
+ * into `results` only when `items.length > 0`, a `gaps_found` report did not
3307
+ * merely under-report — it VANISHED, taking its phase's row out of `by_phase`
3308
+ * with it, so a clean-looking total gave the reader no cue that anything was
3309
+ * skipped. The trailing `plan-phase --gaps` note that stood in for a
3310
+ * `gaps_found` branch pointed at a DIFFERENT command that `audit-uat` never
3311
+ * reaches.
3312
+ *
3313
+ * Eligibility now has ONE owner — the caller — and this function reports what
3314
+ * the file says.
3315
+ *
3316
+ * Resolved entries are skipped on BOTH statuses (#3850 review m8). An earlier
3317
+ * revision skipped them only on `gaps_found`, citing an acceptance criterion
3318
+ * that the issue does not contain: #3850 has no AC section, and its suggested
3319
+ * fix (2) states the skip unconditionally — "Skip entries carrying a
3320
+ * `resolution:` field, or the fix trades one wrong number for another — one
3321
+ * file here has 14 of 16 entries resolved". That file is `human_needed`, so the
3322
+ * asymmetry left the reporter's own named scenario over-reporting by 14. The
3323
+ * SKIP applies on both paths.
3324
+ *
3325
+ * WHAT COUNTS AS RESOLVED is per-key, not universal (#3879 review round 4,
3326
+ * Major): `isGapsEntryResolved` takes `parseGapsItems`' `status: resolved` rule
3327
+ * verbatim so the two `gaps` readers cannot disagree, and
3328
+ * `isHumanVerificationEntryResolved` honours the `resolution:`-only closure the
3329
+ * issue names, guarded so a `status:` that contradicts it wins. The issue's
3330
+ * "skip entries carrying a `resolution:` field" is quoted above as written; it
3331
+ * holds for every entry whose fields agree, which is every entry the reporter's
3332
+ * own scenario contains.
3333
+ */
1265
3334
  function parseVerificationItems(content, status, sourcePath) {
1266
3335
  const items = [];
3336
+ if (status === 'gaps_found') {
3337
+ items.push(...parseHumanVerificationItems(content, sourcePath));
3338
+ items.push(...parseVerificationGapsItems(content));
3339
+ return items;
3340
+ }
1267
3341
  if (status === 'human_needed') {
1268
- // #2286: the frontmatter's structured `human_verification:` YAML array
1269
- // (extractFrontmatter) is the PRIMARY source of truth when present and
1270
- // non-empty — it fully bypasses the body-shape scan below, so a file
1271
- // whose frontmatter declares the array doesn't require any particular
1272
- // `## Human Verification` body shape at all. An absent or empty array
1273
- // (length 0) falls back to the body scan unchanged.
1274
- const frontmatter = extractFrontmatter(content, sourcePath);
1275
- const humanVerification = frontmatter.human_verification;
1276
- if (Array.isArray(humanVerification) && humanVerification.length > 0) {
1277
- humanVerification.forEach((entry, idx) => {
1278
- items.push({
1279
- test: idx + 1,
1280
- name: normalizeHumanVerificationEntry(entry),
1281
- result: 'human_needed',
1282
- category: 'human_uat',
1283
- });
3342
+ return parseHumanVerificationItems(content, sourcePath);
3343
+ }
3344
+ return items;
3345
+ }
3346
+ /**
3347
+ * The `human_verification:` reader, extracted from `parseVerificationItems` so
3348
+ * `gaps_found` and `human_needed` share ONE implementation rather than a second
3349
+ * copy that drifts (ref `DEFECT.GENERATIVE-FIX`). Both statuses now take the
3350
+ * identical path, resolved-entry skip included — see the dispatcher above.
3351
+ */
3352
+ function parseHumanVerificationItems(content, sourcePath) {
3353
+ const items = [];
3354
+ // #2286: the frontmatter's structured `human_verification:` YAML array
3355
+ // (extractFrontmatter) is the PRIMARY source of truth when present and
3356
+ // non-empty — it fully bypasses the body-shape scan below, so a file
3357
+ // whose frontmatter declares the array doesn't require any particular
3358
+ // `## Human Verification` body shape at all. An absent or empty array
3359
+ // (length 0) falls back to the body scan unchanged.
3360
+ const frontmatter = extractFrontmatter(content, sourcePath);
3361
+ const humanVerification = frontmatter.human_verification;
3362
+ if (Array.isArray(humanVerification) && humanVerification.length > 0) {
3363
+ // #3850: ONE source for both the display name and the sibling fields.
3364
+ //
3365
+ // `extractFrontmatter` renders each object entry for humans
3366
+ // (`flattenObjectListItem`), which is right for printing and wrong for
3367
+ // branching: `resolution:` is recoverable from that string only by matching
3368
+ // prose, and prose cannot tell a real field from the same text quoted
3369
+ // inside `truth:`. `frontmatterListEntries` returns the same entries one
3370
+ // step earlier, off the same parse.
3371
+ //
3372
+ // The flattened array stays the #2286 GATE — a non-empty
3373
+ // `human_verification:` fully bypasses the body-shape scan below — but the
3374
+ // raw entries are the source of the items, so there is no second reader to
3375
+ // desynchronise against.
3376
+ //
3377
+ // WALK THE FLATTENED ARRAY, and use the parsed one only to answer "is this
3378
+ // entry closed?" (#3850 review round 3, Blocker).
3379
+ //
3380
+ // This is base's loop — every element, at its own index, named by the
3381
+ // renderer it has always been named by — plus one skip. It is deliberately
3382
+ // NOT "iterate the parsed entries": an earlier revision did that against an
3383
+ // object-FILTERED array, which compacted it, so a list mixing object and
3384
+ // non-object entries lost the non-object rows outright and renumbered the
3385
+ // survivors. That is the silently-vanishing row this issue exists to close,
3386
+ // reintroduced by entry SHAPE instead of file STATUS. Numbering off the
3387
+ // flattened array cannot drift from what the file says, because that array
3388
+ // is the one #2286 already gated on.
3389
+ //
3390
+ // The name therefore stays byte-identical to base for every entry shape,
3391
+ // including the ones with no object to read: a YAML null renders `''`, a
3392
+ // nested sequence renders `[nested]`. Re-deriving those from the parsed
3393
+ // value would have printed `["nested"]` — a rendering nobody asked this
3394
+ // change to alter.
3395
+ //
3396
+ // `parsedEntriesFor` owns the pairing and its alignment check.
3397
+ const parsed = parsedEntriesFor(content, 'human_verification', humanVerification);
3398
+ humanVerification.forEach((flattened, idx) => {
3399
+ const object = parsed[idx];
3400
+ if (object && isHumanVerificationEntryResolved(object))
3401
+ return;
3402
+ items.push({
3403
+ // The entry's ORIGINAL 1-based position, so a surfaced item still
3404
+ // names its row in the file when a closed sibling was skipped.
3405
+ test: idx + 1,
3406
+ name: normalizeHumanVerificationEntry(flattened),
3407
+ result: 'human_needed',
3408
+ category: 'human_uat',
1284
3409
  });
1285
- return items;
1286
- }
1287
- // Use the seam to locate the ## Human Verification section (ADR-1372 T5).
1288
- const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
1289
- if (hvSection) {
1290
- // #2245 review Fix 3: reverted to the pre-Phase-4 (HEAD 2cbf18642)
1291
- // implementation. The live Human Verification section is NOT a strict
1292
- // GFM table — the planner/verifier templates mix table rows, numbered
1293
- // items, and bullet items in the same section (and a `### N.` heading
1294
- // format is common too), so a table-XOR-list read (parse a table, and
1295
- // if it parses, suppress numbered/bullet items entirely) silently
1296
- // dropped items on any mixed or malformed section: a malformed
1297
- // `| N | … |` table with no valid header/delimiter yielded ZERO items
1298
- // instead of reading the rows positionally. This per-line scan reads
1299
- // table rows AND numbered items AND bullet items as a UNION (whichever
1300
- // pattern a given line matches), exactly like OLD, and reads
1301
- // `| N | desc |` rows even without a valid table header/delimiter.
3410
+ });
3411
+ return items;
3412
+ }
3413
+ // Use the seam to locate the ## Human Verification section (ADR-1372 T5).
3414
+ const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
3415
+ if (hvSection) {
3416
+ // #2245 review Fix 3: reverted to the pre-Phase-4 (HEAD 2cbf18642)
3417
+ // implementation. The live Human Verification section is NOT a strict
3418
+ // GFM table — the planner/verifier templates mix table rows, numbered
3419
+ // items, and bullet items in the same section (and a `### N.` heading
3420
+ // format is common too), so a table-XOR-list read (parse a table, and
3421
+ // if it parses, suppress numbered/bullet items entirely) silently
3422
+ // dropped items on any mixed or malformed section: a malformed
3423
+ // `| N | … |` table with no valid header/delimiter yielded ZERO items
3424
+ // instead of reading the rows positionally. This per-line scan reads
3425
+ // table rows AND numbered items AND bullet items as a UNION (whichever
3426
+ // pattern a given line matches), exactly like OLD, and reads
3427
+ // `| N | desc |` rows even without a valid table header/delimiter.
3428
+ //
3429
+ // #2245 audit: the table-row branch's CELL SPLIT is name/position-
3430
+ // addressed via `splitTableRow` (escape-aware, canonical) instead of a
3431
+ // hand-rolled pipe regex — candidacy itself is decided WITHOUT a table
3432
+ // regex (a leading `|` plus a purely-numeric first cell), so this no
3433
+ // longer needs an allow-adhoc-markdown suppression at all.
3434
+ const lines = hvSection.body.split('\n');
3435
+ for (const line of lines) {
3436
+ const trimmedLine = line.trim();
3437
+ // Match table rows: | N | description | ... — candidacy requires a
3438
+ // leading pipe and a purely-numeric first cell (mirrors what the old
3439
+ // regex effectively required: a "|digit|" cell immediately followed
3440
+ // by more content), with at least 2 physical cells so a bare "| N |"
3441
+ // with nothing after it is NOT treated as a row.
1302
3442
  //
1303
- // #2245 audit: the table-row branch's CELL SPLIT is name/position-
1304
- // addressed via `splitTableRow` (escape-aware, canonical) instead of a
1305
- // hand-rolled pipe regex — candidacy itself is decided WITHOUT a table
1306
- // regex (a leading `|` plus a purely-numeric first cell), so this no
1307
- // longer needs an allow-adhoc-markdown suppression at all.
1308
- const lines = hvSection.body.split('\n');
1309
- for (const line of lines) {
1310
- const trimmedLine = line.trim();
1311
- // Match table rows: | N | description | ... — candidacy requires a
1312
- // leading pipe and a purely-numeric first cell (mirrors what the old
1313
- // regex effectively required: a "|digit|" cell immediately followed
1314
- // by more content), with at least 2 physical cells so a bare "| N |"
1315
- // with nothing after it is NOT treated as a row.
1316
- //
1317
- // #2245 review Fix 9: this is NOT the same as OLD for a row whose
1318
- // ONLY content past the digit cell is trailing whitespace (e.g.
1319
- // "| N | ", no second delimiting `|`). OLD's `([^|]+)` regex ran
1320
- // against the RAW (untrimmed) line and its `\s*` would backtrack to
1321
- // let `[^|]+` swallow that trailing whitespace, so OLD matched and
1322
- // pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
1323
- // `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
1324
- // `splitTableRow` ever sees it, collapsing the line to a single cell
1325
- // (`candidateCells.length === 1`), which fails the `>= 2` check —
1326
- // the item is silently dropped instead. A real, acceptable behaviour
1327
- // change (an empty-named UAT item is not useful either way), but the
1328
- // two implementations are NOT equivalent on this input.
1329
- let tableCells = null;
1330
- if (trimmedLine.startsWith('|')) {
1331
- const candidateCells = splitTableRow(trimmedLine);
1332
- if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
1333
- tableCells = candidateCells;
1334
- }
1335
- }
1336
- // Match bullet items: - description
1337
- const bulletMatch = line.match(/^[-*]\s+(.+)/);
1338
- // Match numbered items: 1. description
1339
- const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
1340
- if (tableCells) {
1341
- // Skip rows that already have a passing result (PASS, pass, resolved, etc.)
1342
- // — checked over every cell AFTER the description column, mirroring
1343
- // OLD's rowRemainder scan (which only ever saw cells past the
1344
- // description, the description itself having already been consumed).
1345
- const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
1346
- if (hasPassResult)
1347
- continue;
1348
- items.push({
1349
- test: parseInt(tableCells[0], 10),
1350
- name: tableCells[1] ?? '',
1351
- result: 'human_needed',
1352
- category: 'human_uat',
1353
- });
1354
- }
1355
- else if (numberedMatch) {
1356
- items.push({
1357
- test: parseInt(numberedMatch[1], 10),
1358
- name: numberedMatch[2].trim(),
1359
- result: 'human_needed',
1360
- category: 'human_uat',
1361
- });
1362
- }
1363
- else if (bulletMatch && bulletMatch[1].length > 10) {
1364
- items.push({
1365
- name: bulletMatch[1].trim(),
1366
- result: 'human_needed',
1367
- category: 'human_uat',
1368
- });
3443
+ // #2245 review Fix 9: this is NOT the same as OLD for a row whose
3444
+ // ONLY content past the digit cell is trailing whitespace (e.g.
3445
+ // "| N | ", no second delimiting `|`). OLD's `([^|]+)` regex ran
3446
+ // against the RAW (untrimmed) line and its `\s*` would backtrack to
3447
+ // let `[^|]+` swallow that trailing whitespace, so OLD matched and
3448
+ // pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
3449
+ // `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
3450
+ // `splitTableRow` ever sees it, collapsing the line to a single cell
3451
+ // (`candidateCells.length === 1`), which fails the `>= 2` check —
3452
+ // the item is silently dropped instead. A real, acceptable behaviour
3453
+ // change (an empty-named UAT item is not useful either way), but the
3454
+ // two implementations are NOT equivalent on this input.
3455
+ let tableCells = null;
3456
+ if (trimmedLine.startsWith('|')) {
3457
+ const candidateCells = splitTableRow(trimmedLine);
3458
+ if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
3459
+ tableCells = candidateCells;
1369
3460
  }
1370
3461
  }
1371
- // #2286: fall back to the `### N. <label>` heading + bold-led paragraph
1372
- // shape (the canonical form emitted by `templates/verification-report.md`
1373
- // — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
1374
- // **Why human:** ...`), which the table/bullet/numbered per-line scan
1375
- // above never recognises (a `###`-prefixed line matches none of those
1376
- // three patterns). Uses the same `tokenizeHeadings` seam
1377
- // `parseFirstPendingTest` already uses for `### N.` sub-headings,
1378
- // applied here to the Human Verification section body. Runs in
1379
- // addition to (a union with) the scan above — the two shapes don't
1380
- // collide, so this only adds items a `###` heading page would have
1381
- // silently produced zero for.
1382
- const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
1383
- for (let i = 0; i < hvSubHeadings.length; i += 1) {
1384
- const current = hvSubHeadings[i];
1385
- const next = hvSubHeadings[i + 1];
1386
- const block = next
1387
- ? hvSection.body.slice(current.offset, next.offset)
1388
- : hvSection.body.slice(current.offset);
1389
- const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
1390
- // Require a bold-led paragraph body (`**Test:** ...`) to distinguish
1391
- // a genuine verification item from an unrelated numbered heading.
1392
- if (!/^\s*\*\*/.test(bodyAfterHeading))
1393
- continue;
1394
- const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
1395
- if (!headingParts)
3462
+ // Match bullet items: - description
3463
+ const bulletMatch = line.match(/^[-*]\s+(.+)/);
3464
+ // Match numbered items: 1. description
3465
+ const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
3466
+ if (tableCells) {
3467
+ // Skip rows that already have a passing result (PASS, pass, resolved, etc.)
3468
+ // — checked over every cell AFTER the description column, mirroring
3469
+ // OLD's rowRemainder scan (which only ever saw cells past the
3470
+ // description, the description itself having already been consumed).
3471
+ const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
3472
+ if (hasPassResult)
1396
3473
  continue;
1397
3474
  items.push({
1398
- test: parseInt(headingParts[1], 10),
1399
- name: headingParts[2].trim(),
3475
+ test: parseInt(tableCells[0], 10),
3476
+ name: tableCells[1] ?? '',
1400
3477
  result: 'human_needed',
1401
3478
  category: 'human_uat',
1402
3479
  });
1403
3480
  }
3481
+ else if (numberedMatch) {
3482
+ items.push({
3483
+ test: parseInt(numberedMatch[1], 10),
3484
+ name: numberedMatch[2].trim(),
3485
+ result: 'human_needed',
3486
+ category: 'human_uat',
3487
+ });
3488
+ }
3489
+ else if (bulletMatch && bulletMatch[1].length > 10) {
3490
+ items.push({
3491
+ name: bulletMatch[1].trim(),
3492
+ result: 'human_needed',
3493
+ category: 'human_uat',
3494
+ });
3495
+ }
3496
+ }
3497
+ // #2286: fall back to the `### N. <label>` heading + bold-led paragraph
3498
+ // shape (the canonical form emitted by `templates/verification-report.md`
3499
+ // — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
3500
+ // **Why human:** ...`), which the table/bullet/numbered per-line scan
3501
+ // above never recognises (a `###`-prefixed line matches none of those
3502
+ // three patterns). Uses the same `tokenizeHeadings` seam
3503
+ // `parseFirstPendingTest` already uses for `### N.` sub-headings,
3504
+ // applied here to the Human Verification section body. Runs in
3505
+ // addition to (a union with) the scan above — the two shapes don't
3506
+ // collide, so this only adds items a `###` heading page would have
3507
+ // silently produced zero for.
3508
+ const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
3509
+ for (let i = 0; i < hvSubHeadings.length; i += 1) {
3510
+ const current = hvSubHeadings[i];
3511
+ const next = hvSubHeadings[i + 1];
3512
+ const block = next
3513
+ ? hvSection.body.slice(current.offset, next.offset)
3514
+ : hvSection.body.slice(current.offset);
3515
+ const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
3516
+ // Require a bold-led paragraph body (`**Test:** ...`) to distinguish
3517
+ // a genuine verification item from an unrelated numbered heading.
3518
+ if (!/^\s*\*\*/.test(bodyAfterHeading))
3519
+ continue;
3520
+ const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
3521
+ if (!headingParts)
3522
+ continue;
3523
+ items.push({
3524
+ test: parseInt(headingParts[1], 10),
3525
+ name: headingParts[2].trim(),
3526
+ result: 'human_needed',
3527
+ category: 'human_uat',
3528
+ });
1404
3529
  }
1405
3530
  }
1406
- // gaps_found items are already handled by plan-phase --gaps pipeline
1407
3531
  return items;
1408
3532
  }
1409
3533
  /**
@@ -1434,7 +3558,13 @@ function normalizeHumanVerificationEntry(raw) {
1434
3558
  return s || raw.trim();
1435
3559
  }
1436
3560
  // ─── categorizeItem ───────────────────────────────────────────────────────────
1437
- function categorizeItem(result, reason, blockedBy) {
3561
+ function categorizeItem(rawResult, reason, blockedBy) {
3562
+ // Normalize once so this comparison agrees with the PASS-token check
3563
+ // (`UAT_PASS_RESULTS.has(result)`, over an already-lower-cased token):
3564
+ // `result: PENDING` and
3565
+ // `result: Blocked` must categorize the same as their lowercase forms,
3566
+ // not fall through to 'unknown'.
3567
+ const result = rawResult.toLowerCase();
1438
3568
  if (result === 'blocked' || blockedBy) {
1439
3569
  if (blockedBy) {
1440
3570
  if (/server/i.test(blockedBy))
@@ -1463,18 +3593,43 @@ function categorizeItem(result, reason, blockedBy) {
1463
3593
  return 'pending';
1464
3594
  if (result === 'human_needed')
1465
3595
  return 'human_uat';
3596
+ // #3707: the template-sanctioned `result: issue` token (templates/UAT.md)
3597
+ // has no UatCategory branch here, so a surfaced issue row previously fell
3598
+ // through to 'unknown' — placed AFTER the blocked/skipped/pending checks
3599
+ // above so it never shadows their more specific categorization.
3600
+ if (result === 'issue')
3601
+ return 'issue';
1466
3602
  return 'unknown';
1467
3603
  }
1468
3604
  module.exports = {
1469
3605
  cmdAuditUat,
1470
3606
  cmdRenderCheckpoint,
1471
3607
  parseCurrentTest,
3608
+ parseUatItems,
3609
+ parseUatItemsWithStats,
3610
+ selectPhaseUatFiles,
1472
3611
  buildCheckpoint,
1473
3612
  CHECKPOINT_FRAMES,
1474
3613
  CHECKPOINT_LANGUAGE_ALIASES,
1475
3614
  resolveCheckpointFrame,
1476
- checkpointBoxLine,
1477
3615
  parseDeferredItems,
1478
3616
  parseDeferredItemsWithStatus,
1479
3617
  acknowledgeDeferredItem,
3618
+ // #3702 round 2 (M3): exposed for the marker-grammar parity test only.
3619
+ // Narrowed in round 3 (M6): the two status-line regexes are gone from the
3620
+ // module, so nothing exports them, and the parity test they were widened for
3621
+ // could not reach the defect it was meant to guard anyway — it asserted the
3622
+ // four WRITER regexes shared a source string, which is true of a detect/read
3623
+ // asymmetry too. The pair below is what the behavioural parity test against
3624
+ // `iterateBullets` actually reads.
3625
+ DEFERRED_MARKER_ALT,
3626
+ DEFERRED_BULLET_MARKERS,
3627
+ // #3850: exported so the `gaps_found` partition invariant is asserted
3628
+ // against the parser itself rather than only through a CLI round-trip
3629
+ // (RULESET.TESTS.property-based-testing).
3630
+ parseVerificationItems,
3631
+ // #3879 review round 4, Minor 2: exported for tests so the degrade-to-all-null
3632
+ // branch is asserted directly. It cannot be reached through the two readers —
3633
+ // see the alignment note on the function.
3634
+ parsedEntriesFor,
1480
3635
  };