@opengsd/gsd-core 1.12.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 (286) 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-executor.md +63 -35
  5. package/agents/gsd-plan-checker.md +76 -57
  6. package/agents/gsd-planner.md +14 -0
  7. package/agents/gsd-ui-checker.md +19 -3
  8. package/agents/gsd-ui-researcher.md +29 -0
  9. package/agents/gsd-verifier.md +23 -1
  10. package/bin/install.js +239 -67
  11. package/commands/gsd/execute-phase.md +1 -1
  12. package/commands/gsd/ns-workflow.md +2 -1
  13. package/commands/gsd/phase.md +1 -1
  14. package/commands/gsd/quick-batch.md +105 -0
  15. package/commands/gsd/surface.md +18 -8
  16. package/gsd-core/bin/gsd-tools.cjs +195 -50
  17. package/gsd-core/bin/lib/capability-activation.cjs +27 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +514 -114
  19. package/gsd-core/bin/lib/capability-state.cjs +7 -1
  20. package/gsd-core/bin/lib/capability-validator.cjs +120 -4
  21. package/gsd-core/bin/lib/capability-writer.cjs +14 -4
  22. package/gsd-core/bin/lib/check-command-router.cjs +85 -2
  23. package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
  24. package/gsd-core/bin/lib/clusters.cjs +1 -0
  25. package/gsd-core/bin/lib/command-aliases.cjs +16 -0
  26. package/gsd-core/bin/lib/commands.cjs +337 -13
  27. package/gsd-core/bin/lib/config-loader.cjs +3 -0
  28. package/gsd-core/bin/lib/core-utils.cjs +34 -7
  29. package/gsd-core/bin/lib/decisions.cjs +213 -1
  30. package/gsd-core/bin/lib/edge-probe.cjs +14 -1
  31. package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
  32. package/gsd-core/bin/lib/frontmatter.cjs +137 -23
  33. package/gsd-core/bin/lib/gap-checker.cjs +22 -13
  34. package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
  35. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
  36. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +54 -11
  37. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
  38. package/gsd-core/bin/lib/host-integration.cjs +57 -5
  39. package/gsd-core/bin/lib/init-command-router.cjs +14 -0
  40. package/gsd-core/bin/lib/init.cjs +132 -15
  41. package/gsd-core/bin/lib/install-engine.cjs +184 -12
  42. package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
  43. package/gsd-core/bin/lib/install-profiles.cjs +22 -14
  44. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  45. package/gsd-core/bin/lib/io.cjs +35 -0
  46. package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
  47. package/gsd-core/bin/lib/markdown-table.cjs +123 -0
  48. package/gsd-core/bin/lib/milestone.cjs +22 -2
  49. package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
  50. package/gsd-core/bin/lib/phase-id.cjs +251 -9
  51. package/gsd-core/bin/lib/phase.cjs +774 -35
  52. package/gsd-core/bin/lib/plan-document.cjs +10 -0
  53. package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
  54. package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
  55. package/gsd-core/bin/lib/quick-batch-command-router.cjs +285 -0
  56. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
  57. package/gsd-core/bin/lib/quick-batch.cjs +840 -0
  58. package/gsd-core/bin/lib/review-lane-descriptor.cjs +53 -5
  59. package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
  60. package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
  61. package/gsd-core/bin/lib/roadmap-parser.cjs +499 -26
  62. package/gsd-core/bin/lib/roadmap.cjs +187 -58
  63. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
  64. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
  65. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
  66. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
  67. package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
  68. package/gsd-core/bin/lib/smart-entry.cjs +7 -9
  69. package/gsd-core/bin/lib/state-document.cjs +30 -5
  70. package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
  71. package/gsd-core/bin/lib/state-transition.cjs +333 -44
  72. package/gsd-core/bin/lib/state.cjs +684 -125
  73. package/gsd-core/bin/lib/surface.cjs +23 -8
  74. package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
  75. package/gsd-core/bin/lib/uat.cjs +1419 -515
  76. package/gsd-core/bin/lib/update-context.cjs +6 -2
  77. package/gsd-core/bin/lib/validate.cjs +230 -12
  78. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  79. package/gsd-core/bin/lib/verification.cjs +273 -12
  80. package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
  81. package/gsd-core/bin/lib/verify.cjs +346 -16
  82. package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
  84. package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
  85. package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
  86. package/gsd-core/references/agent-contracts.md +3 -3
  87. package/gsd-core/references/edge-probe.md +17 -13
  88. package/gsd-core/references/execute-mvp-tdd.md +18 -16
  89. package/gsd-core/references/execute-phase-response-language.md +6 -0
  90. package/gsd-core/references/executor-examples.md +42 -0
  91. package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
  92. package/gsd-core/references/mvp-concepts.md +2 -2
  93. package/gsd-core/references/plan-checker-examples.md +41 -0
  94. package/gsd-core/references/planner-antipatterns.md +25 -0
  95. package/gsd-core/references/planner-chunked.md +5 -1
  96. package/gsd-core/references/planner-coupling.md +42 -0
  97. package/gsd-core/references/planner-quick-batch.md +71 -0
  98. package/gsd-core/references/planner-reviews.md +47 -0
  99. package/gsd-core/references/planner-revision.md +75 -2
  100. package/gsd-core/references/planning-config.md +2 -1
  101. package/gsd-core/references/response-language-directive.md +9 -0
  102. package/gsd-core/references/revision-loop.md +118 -11
  103. package/gsd-core/references/tdd.md +14 -9
  104. package/gsd-core/references/verifier-evidence-gate.md +160 -0
  105. package/gsd-core/templates/phase-prompt.md +4 -0
  106. package/gsd-core/templates/verification-report.md +5 -0
  107. package/gsd-core/workflows/add-backlog.md +2 -0
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +1 -1
  110. package/gsd-core/workflows/add-todo.md +1 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  112. package/gsd-core/workflows/analyze-dependencies.md +2 -0
  113. package/gsd-core/workflows/audit-fix.md +2 -0
  114. package/gsd-core/workflows/audit-milestone.md +2 -0
  115. package/gsd-core/workflows/audit-uat.md +2 -0
  116. package/gsd-core/workflows/autonomous.md +2 -0
  117. package/gsd-core/workflows/check-todos.md +1 -1
  118. package/gsd-core/workflows/cleanup.md +1 -1
  119. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
  120. package/gsd-core/workflows/code-review-fix.md +2 -0
  121. package/gsd-core/workflows/code-review.md +73 -31
  122. package/gsd-core/workflows/complete-milestone.md +13 -4
  123. package/gsd-core/workflows/debug.md +1 -1
  124. package/gsd-core/workflows/diagnose-issues.md +5 -1
  125. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
  126. package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
  127. package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
  128. package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
  129. package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
  130. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -0
  131. package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
  132. package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
  133. package/gsd-core/workflows/discuss-phase/modes/text.md +2 -0
  134. package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
  135. package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
  136. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  137. package/gsd-core/workflows/discuss-phase-power.md +2 -0
  138. package/gsd-core/workflows/discuss-phase.md +1 -1
  139. package/gsd-core/workflows/do.md +43 -13
  140. package/gsd-core/workflows/docs-update.md +1 -1
  141. package/gsd-core/workflows/edit-phase.md +2 -0
  142. package/gsd-core/workflows/eval-review.md +1 -1
  143. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
  144. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
  145. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
  146. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
  147. package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
  148. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
  149. package/gsd-core/workflows/execute-phase.md +32 -14
  150. package/gsd-core/workflows/execute-plan.md +8 -8
  151. package/gsd-core/workflows/explore.md +2 -0
  152. package/gsd-core/workflows/extract-learnings.md +2 -0
  153. package/gsd-core/workflows/fast.md +6 -0
  154. package/gsd-core/workflows/forensics.md +2 -0
  155. package/gsd-core/workflows/graduation.md +1 -1
  156. package/gsd-core/workflows/health.md +1 -1
  157. package/gsd-core/workflows/help/modes/brief.md +2 -0
  158. package/gsd-core/workflows/help/modes/default.md +2 -0
  159. package/gsd-core/workflows/help/modes/full.md +12 -0
  160. package/gsd-core/workflows/help/modes/topic.md +2 -0
  161. package/gsd-core/workflows/help.md +2 -0
  162. package/gsd-core/workflows/import.md +3 -3
  163. package/gsd-core/workflows/inbox.md +1 -1
  164. package/gsd-core/workflows/ingest-docs.md +1 -1
  165. package/gsd-core/workflows/insert-phase.md +2 -0
  166. package/gsd-core/workflows/list-phase-assumptions.md +2 -0
  167. package/gsd-core/workflows/list-seeds.md +2 -0
  168. package/gsd-core/workflows/list-workspaces.md +2 -0
  169. package/gsd-core/workflows/manager.md +3 -3
  170. package/gsd-core/workflows/map-codebase.md +2 -0
  171. package/gsd-core/workflows/milestone-summary.md +2 -0
  172. package/gsd-core/workflows/mvp-phase.md +1 -1
  173. package/gsd-core/workflows/new-milestone.md +1 -1
  174. package/gsd-core/workflows/new-project.md +5 -3
  175. package/gsd-core/workflows/new-workspace.md +1 -1
  176. package/gsd-core/workflows/next.md +2 -0
  177. package/gsd-core/workflows/node-repair.md +2 -0
  178. package/gsd-core/workflows/note.md +2 -0
  179. package/gsd-core/workflows/onboard.md +1 -1
  180. package/gsd-core/workflows/pause-work.md +19 -4
  181. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
  182. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
  183. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
  184. package/gsd-core/workflows/plan-phase.md +130 -12
  185. package/gsd-core/workflows/plan-review-convergence.md +102 -10
  186. package/gsd-core/workflows/plant-seed.md +1 -1
  187. package/gsd-core/workflows/pr-branch.md +11 -3
  188. package/gsd-core/workflows/profile-user.md +1 -1
  189. package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
  190. package/gsd-core/workflows/progress.md +25 -3
  191. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
  192. package/gsd-core/workflows/quick/steps/research-phase.md +3 -3
  193. package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
  194. package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
  195. package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
  196. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
  197. package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
  198. package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
  199. package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
  200. package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
  201. package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
  202. package/gsd-core/workflows/quick-batch.md +203 -0
  203. package/gsd-core/workflows/quick.md +13 -3
  204. package/gsd-core/workflows/reapply-patches.md +2 -0
  205. package/gsd-core/workflows/remove-phase.md +2 -0
  206. package/gsd-core/workflows/remove-workspace.md +1 -1
  207. package/gsd-core/workflows/resume-project.md +6 -2
  208. package/gsd-core/workflows/review.md +215 -10
  209. package/gsd-core/workflows/scan.md +2 -0
  210. package/gsd-core/workflows/section-manifest.json +12 -0
  211. package/gsd-core/workflows/secure-phase.md +1 -1
  212. package/gsd-core/workflows/session-report.md +2 -0
  213. package/gsd-core/workflows/settings-advanced.md +2 -0
  214. package/gsd-core/workflows/settings-integrations.md +9 -8
  215. package/gsd-core/workflows/settings.md +1 -1
  216. package/gsd-core/workflows/ship.md +10 -10
  217. package/gsd-core/workflows/sketch-wrap-up.md +2 -0
  218. package/gsd-core/workflows/sketch.md +1 -1
  219. package/gsd-core/workflows/smart-entry.md +1 -1
  220. package/gsd-core/workflows/spec-phase.md +24 -19
  221. package/gsd-core/workflows/spike-wrap-up.md +2 -0
  222. package/gsd-core/workflows/spike.md +1 -1
  223. package/gsd-core/workflows/stats.md +2 -0
  224. package/gsd-core/workflows/sync-skills.md +12 -4
  225. package/gsd-core/workflows/thread.md +2 -0
  226. package/gsd-core/workflows/transition.md +2 -0
  227. package/gsd-core/workflows/ui-phase.md +26 -5
  228. package/gsd-core/workflows/ui-review.md +1 -1
  229. package/gsd-core/workflows/ultraplan-phase.md +2 -0
  230. package/gsd-core/workflows/undo.md +1 -1
  231. package/gsd-core/workflows/update.md +41 -38
  232. package/gsd-core/workflows/validate-phase.md +1 -1
  233. package/gsd-core/workflows/verify-work.md +49 -3
  234. package/hooks/dist/gsd-check-update-worker.js +19 -2
  235. package/hooks/dist/gsd-context-monitor.js +283 -12
  236. package/hooks/dist/gsd-node-runner.sh +1 -0
  237. package/hooks/dist/gsd-prompt-guard.js +30 -5
  238. package/hooks/dist/gsd-read-guard.js +2 -0
  239. package/hooks/dist/gsd-read-injection-scanner.js +5 -5
  240. package/hooks/dist/gsd-secret-read-guard.js +1079 -0
  241. package/hooks/dist/gsd-statusline.js +7 -3
  242. package/hooks/dist/gsd-validate-commit.sh +444 -7
  243. package/hooks/dist/gsd-workflow-guard.js +2 -1
  244. package/hooks/dist/lib/git-cmd.js +210 -1
  245. package/hooks/dist/lib/injection-patterns.js +36 -6
  246. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  247. package/hooks/gsd-check-update-worker.js +19 -2
  248. package/hooks/gsd-context-monitor.js +283 -12
  249. package/hooks/gsd-node-runner.sh +1 -0
  250. package/hooks/gsd-prompt-guard.js +30 -5
  251. package/hooks/gsd-read-guard.js +2 -0
  252. package/hooks/gsd-read-injection-scanner.js +5 -5
  253. package/hooks/gsd-secret-read-guard.js +1079 -0
  254. package/hooks/gsd-statusline.js +7 -3
  255. package/hooks/gsd-validate-commit.sh +444 -7
  256. package/hooks/gsd-workflow-guard.js +2 -1
  257. package/hooks/hooks.json +6 -0
  258. package/hooks/lib/git-cmd.js +210 -1
  259. package/hooks/lib/injection-patterns.js +36 -6
  260. package/hooks/managed-hooks-registry.cjs +1 -0
  261. package/package.json +5 -5
  262. package/scripts/build-hooks.js +11 -4
  263. package/scripts/ci-test-scope.cjs +7 -0
  264. package/scripts/docs-guard-registry.cjs +10 -0
  265. package/scripts/gen-loop-host-contract.cjs +67 -15
  266. package/scripts/lib/shellcheck-fetch.cjs +247 -0
  267. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
  268. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
  269. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
  270. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +5 -0
  271. package/scripts/lint-phase-enumeration-drift.cjs +24 -6
  272. package/scripts/lint-phase-id-drift.cjs +133 -8
  273. package/scripts/lint-portable-grep.cjs +176 -0
  274. package/scripts/lint-response-language-coverage.cjs +524 -0
  275. package/scripts/lint-test-file-count.allowlist.json +3 -1
  276. package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
  277. package/scripts/lint-workflow-shellcheck.cjs +614 -0
  278. package/scripts/npm-audit-baseline.cjs +376 -0
  279. package/scripts/prompt-injection-scan.sh +8 -0
  280. package/scripts/require-issue-link-policy.cjs +16 -1
  281. package/skills/gsd-execute-phase/SKILL.md +1 -1
  282. package/skills/gsd-ns-workflow/SKILL.md +1 -0
  283. package/skills/gsd-phase/SKILL.md +1 -1
  284. package/skills/gsd-quick-batch/SKILL.md +105 -0
  285. package/skills/gsd-surface/SKILL.md +18 -8
  286. package/vscode/package.json +1 -1
@@ -31,7 +31,7 @@ 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;
@@ -278,8 +278,26 @@ function cmdAuditUat(cwd, raw) {
278
278
  parse_gap_files: results.filter((r) => r.parse_gap).length,
279
279
  by_category: {},
280
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: {} },
281
288
  };
282
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
+ }
283
301
  // Deliberate (#3707 follow-up MINOR): this seeds a `by_phase` key at 0
284
302
  // even for a parse-gap-only phase whose `items` is empty — do NOT "tidy"
285
303
  // this away as dead code. The 0-valued key is itself the cue that this
@@ -1706,7 +1724,7 @@ function parseGapsTableItems(sectionBody) {
1706
1724
  * surfaced.
1707
1725
  *
1708
1726
  * #3457: when the section body contains headings, entries are delimited by
1709
- * LEAF headings (see `splitDeferredHeadingEntries`) rather than by bullets —
1727
+ * LEAF headings (see `splitDeferredHeadingEntriesDetailed`) rather than by bullets —
1710
1728
  * the executor convention writes one deferred item as a heading followed by
1711
1729
  * sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as
1712
1730
  * one item PER BULLET. A body with no headings keeps the original
@@ -1732,18 +1750,27 @@ function parseDeferredItemsWithStatus(content) {
1732
1750
  // before field extraction, not just line 0 (which `extractGapEntryFields`
1733
1751
  // does for the headless/Gaps shape, where a later `- ` line is a nested
1734
1752
  // sub-list, not a field).
1735
- 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.
1736
1761
  const entries = headingEntries !== null
1737
- ? headingEntries.map((entryLines) => ({
1738
- lines: entryLines,
1739
- 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),
1740
1766
  }))
1741
- : splitGapsEntries(sectionBody).map((entryLines) => ({
1767
+ : splitGapsEntries(sectionBody, DEFERRED_BULLET_MARKERS).map((entryLines) => ({
1742
1768
  lines: entryLines,
1743
- fields: extractGapEntryFields(entryLines),
1769
+ opener: undefined,
1770
+ fields: extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS),
1744
1771
  }));
1745
- for (const { lines: entryLines, fields } of entries) {
1746
- const text = rawGapEntryText(entryLines);
1772
+ for (const { lines: entryLines, opener, fields } of entries) {
1773
+ const text = rawGapEntryText(entryLines, DEFERRED_BULLET_MARKERS, opener);
1747
1774
  if (!text)
1748
1775
  continue;
1749
1776
  items.push({ name: text, status: fields.status || '' });
@@ -1771,6 +1798,47 @@ function parseDeferredItems(content) {
1771
1798
  category: 'deferred',
1772
1799
  }));
1773
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
+ }
1774
1842
  /**
1775
1843
  * CLI-writer half of the #3458 follow-up deferred_items suppression seam.
1776
1844
  * Sets the ONE deferred entry whose rendered text (`rawGapEntryText`, the
@@ -1785,25 +1853,27 @@ function parseDeferredItems(content) {
1785
1853
  * `status:` away from `acknowledged` (or delete the field) and it resurfaces
1786
1854
  * with no separate cleanup step, exactly like every other category's marker.
1787
1855
  *
1788
- * #3781: the heading-delimited (#3457) entry shape is SUPPORTED, via
1789
- * `splitDeferredHeadingEntriesWithSpans` — a span-carrying sibling of the
1790
- * reader's walk that records each entry's (start, end) character span in the
1791
- * SAME pass that groups its lines (the identical technique
1792
- * `splitGapsEntriesWithSpans` uses for the headless shape). Leaf entries keep
1793
- * their RAW heading line as `lines[0]` so `sectionBody.slice(start, end)` is
1794
- * byte-verbatim; pending (preamble / container-direct) regions are contiguous
1795
- * slices handed to `splitGapsEntriesWithSpans` with a baseOffset translation.
1796
- * Two write rules differ from the headless path on this shape: the status
1797
- * search runs over the READER-form lines (what the reader actually parses —
1798
- * including the leaf line-0 corner where the heading text itself parses as a
1799
- * status field, whose raw line is rewritten with its ATX prefix preserved),
1800
- * and the insert branch inserts after the entry's LAST NON-BLANK line — a
1801
- * heading entry's body is frequently a soft-wrapped sentence, and splicing
1802
- * after line 0 would split it in half (#3781's sentence-split trap).
1803
- * Entries whose span embeds a GFM table row are non-contiguous (table lines
1804
- * are excluded from entries) and still refuse (`unsupported_heading_shape`)
1805
- * rather than risk a wrong-entry write; the fully-headless shape below is
1806
- * byte-for-byte the pre-#3781 path.
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.
1807
1877
  *
1808
1878
  * Also refuses `ambiguous` (2+ entries share the exact same text — status must
1809
1879
  * be unique to identify one) and `not_found`, and is a no-op
@@ -1845,15 +1915,15 @@ function parseDeferredItems(content) {
1845
1915
  function acknowledgeDeferredItem(content, targetText) {
1846
1916
  const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
1847
1917
  const sectionBody = deferredSection ? deferredSection.body : content;
1848
- // #3781: the heading-delimited shape carries its own span walk; the
1849
- // headless path below is unchanged.
1850
- const headingEntries = splitDeferredHeadingEntriesWithSpans(sectionBody);
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);
1851
1921
  if (headingEntries !== null) {
1852
1922
  return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText });
1853
1923
  }
1854
- const entries = splitGapsEntriesWithSpans(sectionBody);
1924
+ const entries = splitGapsEntriesWithSpans(sectionBody, DEFERRED_BULLET_MARKERS);
1855
1925
  const matches = entries
1856
- .map((entry) => ({ entry, text: rawGapEntryText(entry.lines) }))
1926
+ .map((entry) => ({ entry, text: rawGapEntryText(entry.lines, DEFERRED_BULLET_MARKERS) }))
1857
1927
  .filter((e) => e.text === targetText);
1858
1928
  if (matches.length === 0)
1859
1929
  return { content, status: 'not_found' };
@@ -1861,7 +1931,7 @@ function acknowledgeDeferredItem(content, targetText) {
1861
1931
  return { content, status: 'ambiguous' };
1862
1932
  const { entry } = matches[0];
1863
1933
  const { lines: entryLines, start, end } = entry;
1864
- const fields = extractGapEntryFields(entryLines);
1934
+ const fields = extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS);
1865
1935
  if (fields.status && fields.status.toLowerCase() === 'resolved') {
1866
1936
  return { content, status: 'already_resolved' };
1867
1937
  }
@@ -1876,69 +1946,132 @@ function acknowledgeDeferredItem(content, targetText) {
1876
1946
  // comparison that selected this entry — this catches real drift between
1877
1947
  // the two rather than a regex trivially guaranteed to agree with itself.
1878
1948
  const strippedForVerify = matchedLines.map((l) => l.replace(/\r$/, ''));
1879
- if (rawGapEntryText(strippedForVerify) !== targetText) {
1949
+ if (rawGapEntryText(strippedForVerify, DEFERRED_BULLET_MARKERS) !== targetText) {
1880
1950
  return { content, status: 'match_verification_failed' };
1881
1951
  }
1882
1952
  const matchIndexInContent = sectionOffset + start;
1883
- // #3740: the search must mirror the reader exactly. extractGapEntryFields
1884
- // strips a bullet marker on line 0 ALONE — a later `- ` line is a nested
1885
- // sub-list, never a field line — so a marker-prefixed match on any
1886
- // continuation line would rewrite a line no reader reads and report `ok`
1887
- // while the entry stays outstanding. Line 0 KEEPS the marker-optional
1888
- // form: the reader de-bullets it, so `- status: open` as the entry line is
1889
- // a real field there (and first-wins means the insert branch could not
1890
- // outrank it). Everything else falls through to the insert branch below,
1891
- // which the marker-free and no-status controls already round-trip.
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`.
1892
1958
  //
1893
- // #3775: the CASE axis of the same rule. The reader lowercases BOLDED
1894
- // keys only; bare keys keep their literal case (#3457 design), so a bare
1895
- // `Status:`/`STATUS:` line is stored under key `Status` and never read as
1896
- // fields.status. The search therefore matches a bolded key in ANY case
1897
- // and a bare key in LOWERCASE only — never a bare Title-case/UPPER line,
1898
- // which must fall through to the insert branch whose lowercase output the
1899
- // reader consumes (leaving any human `Status: resolved` untouched).
1900
- const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
1901
- const statusFieldBareRe = /^\s*status:/;
1902
- const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
1903
- const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
1904
- const statusLineIdx = matchedLines.findIndex((rawLine, idx) => {
1905
- const line = rawLine.replace(/\r$/, '');
1906
- return idx === 0
1907
- ? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
1908
- : (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line));
1909
- });
1910
- // No CRLF-preservation branch here (WARNING 1, #3458 follow-up review):
1911
- // every write goes through `platformWriteSync` → `normalizeContent`, which
1912
- // for a `.md` path unconditionally runs `_normalizeMd` — whole-file
1913
- // `\r\n` → `\n`, plus blank-line normalization around headings/lists — on
1914
- // EVERY write, not just this one. That is this codebase's single,
1915
- // deliberate OS-facing I/O seam (`shell-command-projection.cts`), applied
1916
- // uniformly to every `.md` writer; carving out one exception here would
1917
- // fight it rather than follow it, for a guarantee (byte-identical CRLF on
1918
- // disk) the seam already makes impossible. A marker write on a CRLF
1919
- // `deferred-items.md` normalizes the WHOLE file to LF, same as any other
1920
- // `.md` write in this codebase — expected, not a regression to guard
1921
- // against. Where a source line still carries a trailing `\r` (read from an
1922
- // on-disk CRLF document before normalization), `String.prototype.replace`
1923
- // consumes it as part of `.*$` and the replacement text does not
1924
- // reproduce it, so it is dropped here too — consistent with the eventual
1925
- // whole-file normalization rather than duplicating it.
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.)
1926
1988
  let newMatchedLines;
1927
1989
  if (statusLineIdx === -1) {
1928
- const bulletIndentMatch = matchedLines[0].match(/^(\s*)-\s+/);
1929
- 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));
1930
2019
  newMatchedLines = [
1931
- matchedLines[0],
1932
- `${continuationIndent}status: acknowledged`,
2020
+ crlf ? `${matchedLines[0].replace(/\r$/, '')}\r` : matchedLines[0],
2021
+ `${continuationIndent}status: acknowledged${line0HadCr ? '\r' : ''}`,
1933
2022
  ...matchedLines.slice(1),
1934
2023
  ];
1935
2024
  }
1936
2025
  else {
1937
- const original = matchedLines[statusLineIdx];
1938
- 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) ? '' : ' ';
1939
2044
  newMatchedLines = matchedLines.slice();
1940
- newMatchedLines[statusLineIdx] = replaced;
2045
+ newMatchedLines[statusLineIdx] = `${prefix}${sep}acknowledged${cr}`;
1941
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.
1942
2075
  const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
1943
2076
  return { content: newContent, status: 'ok' };
1944
2077
  }
@@ -1957,132 +2090,6 @@ function stripAtxPrefix(line) {
1957
2090
  ? ''
1958
2091
  : m[3].replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '').trim();
1959
2092
  }
1960
- /**
1961
- * #3781 — span-carrying sibling of `splitDeferredHeadingEntries`: ONE walk,
1962
- * identical grouping rules (leaf = childless heading whose body carries a
1963
- * bullet; container = next heading deeper; preamble/container-direct lines →
1964
- * headless entries; table lines excluded), additionally recording each
1965
- * entry's (start, end) character span within `sectionBody`. Returns null when
1966
- * the body contains no heading at all — the caller then takes the unchanged
1967
- * fully-headless path.
1968
- */
1969
- function splitDeferredHeadingEntriesWithSpans(sectionBody) {
1970
- const headings = tokenizeHeadings(sectionBody);
1971
- if (headings.length === 0)
1972
- return null;
1973
- const lines = sectionBody.split('\n');
1974
- const lineStarts = [];
1975
- const lineEnds = [];
1976
- let cursor = 0;
1977
- for (const rawLine of lines) {
1978
- lineStarts.push(cursor);
1979
- cursor += rawLine.length;
1980
- lineEnds.push(cursor);
1981
- cursor += 1;
1982
- }
1983
- const headingByLine = new Map();
1984
- for (let i = 0; i < headings.length; i++) {
1985
- const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
1986
- headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
1987
- }
1988
- const isTableLine = (l) => /^\s*\|/.test(l.replace(/\r$/, ''));
1989
- const isBulletLine = (l) => /^\s*-\s/.test(l.replace(/\r$/, ''));
1990
- const entries = [];
1991
- let current = null;
1992
- let currentReaderLine0 = null;
1993
- let currentStartLine = -1;
1994
- let currentEndLine = -1;
1995
- let currentHasBullet = false;
1996
- let currentTable = false;
1997
- let pendingStartLine = -1;
1998
- let pendingEndLine = -1;
1999
- const flushCurrent = () => {
2000
- if (current !== null && currentHasBullet && currentReaderLine0 !== null) {
2001
- const bodyReader = current.slice(1).map(stripLeadingBulletMarker);
2002
- entries.push({
2003
- kind: 'leaf',
2004
- lines: current,
2005
- readerLines: [currentReaderLine0, ...bodyReader],
2006
- text: rawGapEntryText([currentReaderLine0, ...current.slice(1)]),
2007
- fields: extractGapEntryFields([currentReaderLine0, ...bodyReader]),
2008
- start: lineStarts[currentStartLine],
2009
- end: lineEnds[currentEndLine],
2010
- embeddedTable: currentTable,
2011
- });
2012
- }
2013
- current = null;
2014
- currentReaderLine0 = null;
2015
- currentStartLine = -1;
2016
- currentEndLine = -1;
2017
- currentHasBullet = false;
2018
- currentTable = false;
2019
- };
2020
- const flushPending = () => {
2021
- if (pendingStartLine === -1)
2022
- return;
2023
- // The pending region is contiguous (a heading flushes it), but table lines
2024
- // inside it were skipped by the walk: the reader's identity for this
2025
- // region is computed over the table-FILTERED join, which may merge
2026
- // entries across the gap, so spans cannot be translated faithfully —
2027
- // mark the region's entries as refusing instead.
2028
- let regionTable = false;
2029
- for (let i = pendingStartLine; i <= pendingEndLine; i++) {
2030
- if (isTableLine(lines[i]))
2031
- regionTable = true;
2032
- }
2033
- const base = lineStarts[pendingStartLine];
2034
- const regionText = sectionBody.slice(lineStarts[pendingStartLine], lineEnds[pendingEndLine]);
2035
- for (const e of splitGapsEntriesWithSpans(regionText)) {
2036
- entries.push({
2037
- kind: 'pending',
2038
- lines: e.lines,
2039
- readerLines: e.lines,
2040
- text: rawGapEntryText(e.lines),
2041
- fields: extractGapEntryFields(e.lines),
2042
- start: base + e.start,
2043
- end: base + e.end,
2044
- embeddedTable: regionTable,
2045
- });
2046
- }
2047
- pendingStartLine = -1;
2048
- pendingEndLine = -1;
2049
- };
2050
- for (let i = 0; i < lines.length; i++) {
2051
- const heading = headingByLine.get(i + 1);
2052
- if (heading !== undefined) {
2053
- flushCurrent();
2054
- flushPending();
2055
- if (!heading.isContainer) {
2056
- current = [lines[i]];
2057
- currentReaderLine0 = heading.text;
2058
- currentStartLine = i;
2059
- currentEndLine = i;
2060
- currentHasBullet = false;
2061
- currentTable = false;
2062
- }
2063
- continue;
2064
- }
2065
- if (isTableLine(lines[i])) {
2066
- if (current !== null)
2067
- currentTable = true;
2068
- continue;
2069
- }
2070
- if (current !== null) {
2071
- current.push(lines[i]);
2072
- currentEndLine = i;
2073
- if (isBulletLine(lines[i]))
2074
- currentHasBullet = true;
2075
- }
2076
- else {
2077
- if (pendingStartLine === -1)
2078
- pendingStartLine = i;
2079
- pendingEndLine = i;
2080
- }
2081
- }
2082
- flushCurrent();
2083
- flushPending();
2084
- return entries;
2085
- }
2086
2093
  /**
2087
2094
  * #3781 — the heading-shaped half of `acknowledgeDeferredItem`, sharing the
2088
2095
  * headless path's guards (not_found / ambiguous / already_resolved /
@@ -2090,142 +2097,408 @@ function splitDeferredHeadingEntriesWithSpans(sectionBody) {
2090
2097
  * shape-specific rules documented on `acknowledgeDeferredItem` (reader-form
2091
2098
  * status search incl. the leaf line-0 ATX corner; insert after the entry's
2092
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.
2093
2111
  */
2094
2112
  function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }) {
2095
- const matches = headingEntries.filter((e) => e.text === targetText);
2113
+ const matches = headingEntries.filter((e) => rawGapEntryText(e.lines, DEFERRED_BULLET_MARKERS, e.opener) === targetText);
2096
2114
  if (matches.length === 0)
2097
2115
  return { content, status: 'not_found' };
2098
2116
  if (matches.length > 1)
2099
2117
  return { content, status: 'ambiguous' };
2100
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.
2101
2121
  if (entry.embeddedTable)
2102
2122
  return { content, status: 'unsupported_heading_shape' };
2103
- if (entry.fields.status && entry.fields.status.toLowerCase() === 'resolved') {
2123
+ const fields = extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener);
2124
+ if (fields.status && fields.status.toLowerCase() === 'resolved') {
2104
2125
  return { content, status: 'already_resolved' };
2105
2126
  }
2106
2127
  const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
2107
- const rawSlice = sectionBody.slice(entry.start, entry.end);
2108
- const rawSliceLines = rawSlice.split('\n');
2109
- // Genuine invariant re-verification: re-derive the entry's identity from
2110
- // the span's own bytes and compare against the targetText that selected it
2111
- // — the span was recorded by an offset bookkeeping independent of the
2112
- // identity comparison above.
2113
- const verifyText = entry.kind === 'leaf'
2114
- ? (() => {
2115
- const stripped = stripAtxPrefix(rawSliceLines[0]);
2116
- return stripped === null ? null : rawGapEntryText([stripped, ...rawSliceLines.slice(1)]);
2117
- })()
2118
- : rawGapEntryText(rawSliceLines.map((l) => l.replace(/\r$/, '')));
2119
- if (verifyText !== targetText) {
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) {
2120
2143
  return { content, status: 'match_verification_failed' };
2121
2144
  }
2122
- // Status search over the READER-form lines — the exact set the reader
2123
- // parses (bolded any case + bare lowercase, per #3775; line-0 forms per
2124
- // #3740). Reader lines are index-aligned 1:1 with the raw lines.
2125
- const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
2126
- const statusFieldBareRe = /^\s*status:/;
2127
- const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
2128
- const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
2129
- const readerLines = entry.kind === 'leaf'
2130
- ? [
2131
- stripAtxPrefix(rawSliceLines[0]) ?? rawSliceLines[0].replace(/\r$/, ''),
2132
- ...rawSliceLines.slice(1).map((l) => stripLeadingBulletMarker(l.replace(/\r$/, ''))),
2133
- ]
2134
- : rawSliceLines.map((l) => l.replace(/\r$/, ''));
2135
- const statusLineIdx = readerLines.findIndex((line, idx) => idx === 0
2136
- ? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
2137
- : (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line)));
2145
+ const statusLineIdx = entryFieldLines(readerLines, DEFERRED_BULLET_MARKERS, entry.opener)
2146
+ .findIndex((field) => field?.key === 'status');
2138
2147
  let newRawLines;
2139
2148
  if (statusLineIdx === -1) {
2140
2149
  // Insert branch: after the entry's LAST NON-BLANK line — a heading
2141
2150
  // entry's body is frequently a soft-wrapped sentence, and splicing after
2142
2151
  // line 0 would split it in half (#3781's sentence trap). The headless
2143
2152
  // (no-heading-anywhere) path keeps its own splice-after-line-0 shape.
2144
- let lastNonBlank = rawSliceLines.length - 1;
2145
- while (lastNonBlank > 0 && rawSliceLines[lastNonBlank].replace(/\r$/, '').trim() === '') {
2146
- lastNonBlank--;
2147
- }
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.
2148
2167
  const indent = entry.kind === 'pending'
2149
- ? (() => {
2150
- const bulletIndentMatch = rawSliceLines[0].match(/^(\s*)-\s+/);
2151
- return ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
2152
- })()
2168
+ ? `${rawLines[0].replace(/\r$/, '').match(DEFERRED_BULLET_MARKERS.strip)?.[1] ?? ''} `
2153
2169
  : ' ';
2154
- newRawLines = [
2155
- ...rawSliceLines.slice(0, lastNonBlank + 1),
2156
- `${indent}status: acknowledged`,
2157
- ...rawSliceLines.slice(lastNonBlank + 1),
2158
- ];
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' : ''}`);
2159
2183
  }
2160
2184
  else {
2161
- newRawLines = rawSliceLines.slice();
2162
- if (entry.kind === 'leaf' && statusLineIdx === 0) {
2163
- // Leaf line 0 is the RAW heading line — rewrite the heading-text portion
2164
- // the reader treats as a field, with the ATX prefix preserved.
2165
- const replacedReader = readerLines[statusLineIdx].replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
2166
- const atxMatch = /^(\s*#+[ \t]*)(.*)$/.exec(rawSliceLines[0].replace(/\r$/, ''));
2167
- newRawLines[0] = atxMatch ? atxMatch[1] + replacedReader : replacedReader;
2168
- }
2169
- else {
2170
- // Every other status line is rewritten on its RAW line, so the bullet
2171
- // marker and indent survive the write (the reader-form line has the
2172
- // marker stripped — writing it back would mangle the markdown shape and
2173
- // change the entry's identity text). Same replacement regex as the
2174
- // headless path.
2175
- newRawLines[statusLineIdx] = rawSliceLines[statusLineIdx].replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
2176
- }
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}`;
2177
2203
  }
2178
2204
  const matchIndexInContent = sectionOffset + entry.start;
2179
2205
  const newContent = content.slice(0, matchIndexInContent) + newRawLines.join('\n') + content.slice(matchIndexInContent + (entry.end - entry.start));
2180
2206
  return { content: newContent, status: 'ok' };
2181
2207
  }
2182
2208
  /**
2183
- * Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred
2184
- * entries carry their fields as sibling bullets; `extractGapEntryFields` only
2185
- * de-bullets line 0 (Gaps-protective — there, a later `- ` line is a nested
2186
- * sub-list), so the deferred heading path de-bullets every line itself before
2187
- * field extraction. Non-bullet lines pass through untouched.
2188
- */
2189
- function stripLeadingBulletMarker(line) {
2190
- return line.replace(/^(\s*)-\s+/, '');
2191
- }
2192
- /**
2193
- * Split a deferred-items section body into entries delimited by LEAF headings
2194
- * (#3457). Returns `null` when the body contains no heading at all — the
2195
- * caller then falls back to `splitGapsEntries`, keeping headless
2196
- * one-bullet-per-item files byte-for-byte on the pre-#3457 path.
2197
- *
2198
- * A heading is a CONTAINER (group/provenance/title label, contributes no
2199
- * entry) iff the NEXT heading is deeper — a deeper heading lives inside its
2200
- * span. Otherwise it is a LEAF: an entry boundary. This handles all three
2201
- * corpus shapes without hardcoding a depth: flat `#` title + `##` entries
2202
- * (title's next heading is deeper → container; each `##` followed by a
2203
- * same-or-shallower heading → leaf), a `##` container with `###` entries
2204
- * (container's next heading is deeper), and mixed-depth files where a
2205
- * childless `##` entry sits alongside a `##` group with `###` children — every
2206
- * childless heading is a leaf at whatever depth it is written. The shallower
2207
- * rules the issue reports as already tried (split on every heading; shallowest
2208
- * level; deepest level) each mis-count one of these shapes.
2209
- *
2210
- * A leaf entry is [heading text, ...body lines up to the next heading] and is
2211
- * kept only when its body (minus table lines) contains at least one `- `
2212
- * bullet:
2213
- * - a prose-only or bare heading contributes nothing — "prose is not an item"
2214
- * is this parser's pre-existing contract (see the `# Notes` case);
2215
- * - a table-only body is left entirely to `parseDeferredTableItems`, which
2216
- * unions over the same section body, so the heading cannot double-count the
2217
- * table's rows.
2218
- *
2219
- * Lines before the first heading, and lines directly under a container heading
2220
- * (before its first child), are split one-bullet-per-item by the unchanged
2221
- * `splitGapsEntries` — headless parity, so loose bullets before a later
2222
- * heading group (the mixed shape) stay one item each.
2223
- */
2224
- function splitDeferredHeadingEntries(sectionBody) {
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) {
2225
2497
  const headings = tokenizeHeadings(sectionBody);
2226
2498
  if (headings.length === 0)
2227
2499
  return null;
2228
2500
  const lines = sectionBody.split('\n');
2501
+ const { lineStarts, lineEnds } = lineOffsets(lines);
2229
2502
  const headingByLine = new Map();
2230
2503
  for (let i = 0; i < headings.length; i++) {
2231
2504
  // Container iff the next heading is deeper (see doc comment). An empty
@@ -2235,20 +2508,92 @@ function splitDeferredHeadingEntries(sectionBody) {
2235
2508
  headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
2236
2509
  }
2237
2510
  const entries = [];
2238
- let current = null; // accumulating a leaf heading's entry
2239
- let pending = []; // preamble / container-heading body lines
2511
+ // The leaf entry being accumulated, with the raw line range it spans.
2512
+ let current = null;
2240
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);
2241
2540
  const flushCurrent = () => {
2242
- // 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
2243
2542
  // text line itself (element 0) never counts as one.
2244
- if (current !== null && currentHasBullet)
2245
- 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
+ }
2246
2554
  current = null;
2247
2555
  currentHasBullet = false;
2248
2556
  };
2249
2557
  const flushPending = () => {
2250
- 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
+ }
2251
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
+ }
2252
2597
  };
2253
2598
  for (let i = 0; i < lines.length; i++) {
2254
2599
  const lineNo = i + 1;
@@ -2259,23 +2604,73 @@ function splitDeferredHeadingEntries(sectionBody) {
2259
2604
  // ANY heading; flushing here keeps entries in document order even when
2260
2605
  // a container's direct bullets precede its first child entry.
2261
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);
2262
2612
  if (!heading.isContainer) {
2263
2613
  // Leaf heading: open an entry with the heading text as line 0.
2264
- current = [heading.text];
2614
+ current = { lines: [heading.text], opener: [false], startLine: i, endLine: i };
2265
2615
  currentHasBullet = false;
2266
2616
  }
2267
2617
  continue;
2268
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
+ }
2269
2653
  // Table lines belong to parseDeferredTableItems, never to a heading entry.
2270
- if (/^\s*\|/.test(lines[i].replace(/\r$/, '')))
2654
+ if (/^\s*\|/.test(line)) {
2655
+ tableLines.push(i);
2271
2656
  continue;
2657
+ }
2272
2658
  if (current !== null) {
2273
- current.push(lines[i]);
2274
- 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) {
2275
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() === '';
2276
2671
  }
2277
2672
  else {
2278
- pending.push(lines[i]);
2673
+ push(line, i, false); // the core derives its own opener verdicts for the region
2279
2674
  }
2280
2675
  }
2281
2676
  flushCurrent();
@@ -2330,70 +2725,139 @@ function parseDeferredTableItems(sectionBody) {
2330
2725
  * boundary — a second, independently-written grouping pass is exactly how a
2331
2726
  * span-carrying sibling could disagree with the plain-lines version it is
2332
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.
2333
2735
  */
2334
- function splitGapsEntriesCore(sectionBody) {
2736
+ function splitGapsEntriesCore(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
2335
2737
  const rawLines = sectionBody.split('\n');
2336
- const lineStarts = [];
2337
- const lineEnds = [];
2338
- let cursor = 0;
2339
- for (const rawLine of rawLines) {
2340
- lineStarts.push(cursor);
2341
- cursor += rawLine.length;
2342
- lineEnds.push(cursor);
2343
- cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
2344
- }
2738
+ const { lineStarts, lineEnds } = lineOffsets(rawLines);
2345
2739
  const entries = [];
2346
2740
  let current = null;
2347
2741
  let currentStartLine = -1;
2348
2742
  let currentEndLine = -1;
2349
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 = [];
2350
2753
  const flush = () => {
2351
2754
  if (current !== null) {
2352
- entries.push({ lines: current, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
2755
+ entries.push({ lines: current, opener: currentOpeners, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
2353
2756
  }
2354
2757
  };
2355
- // #3898: a spaced-hyphen thematic break (`- - -`, `- -`, `- - -`, …) is a
2356
- // SEPARATOR, not an entry. The opener regex below matches it (hyphen +
2357
- // whitespace), which fabricated a gap named `- -` with result 'unknown' —
2358
- // an item that cannot be cleared by editing any entry, because there is no
2359
- // entry, only the separator the author wrote deliberately. A line whose
2360
- // content after the opening marker consists solely of hyphens and spaces
2361
- // (with at least one further hyphen) is skipped entirely: it neither opens
2362
- // an entry nor is folded into the current one. This is deliberately NOT a
2363
- // full thematic-break concept (option 2 in the issue): a break does not
2364
- // close the Gaps list — entries after it keep parsing.
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.
2365
2770
  const isSeparatorShaped = (line, bulletPrefixLen) => {
2366
2771
  const remainder = line.slice(bulletPrefixLen);
2367
2772
  return /^[-\s]*$/.test(remainder) && remainder.includes('-');
2368
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
+ };
2369
2786
  rawLines.forEach((rawLine, idx) => {
2370
2787
  const line = rawLine.replace(/\r$/, '');
2371
- const bulletMatch = line.match(/^(\s*)-\s/);
2372
- // Narrowed skip (review disposition a): a separator-shaped line is skipped
2373
- // only when it sits BETWEEN entries (nothing open yet, or it would open a
2374
- // top-level entry — where the phantom came from). One landing strictly
2375
- // INSIDE a live entry (indent > baseIndent) folds back as a continuation
2376
- // line, so the entry's GapsEntrySpan stays byte-contiguous — the span
2377
- // invariant below and the ack writer's identity re-verification both hold.
2378
- if (bulletMatch && isSeparatorShaped(line, bulletMatch[0].length) &&
2379
- (current === null || bulletMatch[1].length <= (baseIndent ?? 0))) {
2380
- return; // separator line between entries — neither an opener nor a continuation
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);
2381
2793
  }
2382
- if (bulletMatch) {
2383
- const indent = bulletMatch[1].length;
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;
2384
2837
  if (baseIndent === null)
2385
2838
  baseIndent = indent;
2839
+ runs.opened(levelOf(line));
2386
2840
  if (indent <= baseIndent) {
2387
2841
  flush();
2388
2842
  current = [line];
2843
+ currentOpeners = [true];
2389
2844
  currentStartLine = idx;
2390
2845
  currentEndLine = idx;
2846
+ blankSeen = false; // an opener is not blank — the memory must not survive it
2391
2847
  return;
2392
2848
  }
2393
2849
  }
2394
2850
  if (current !== null) {
2395
2851
  current.push(line);
2852
+ currentOpeners.push(opener !== null);
2396
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;
2397
2861
  }
2398
2862
  // else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
2399
2863
  });
@@ -2402,7 +2866,8 @@ function splitGapsEntriesCore(sectionBody) {
2402
2866
  }
2403
2867
  /**
2404
2868
  * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
2405
- * `- ` bullet openers.
2869
+ * bullet openers — `- ` for Gaps, or whichever set `markers` names (#3702:
2870
+ * the deferred-items callers pass the widened CommonMark set).
2406
2871
  *
2407
2872
  * The indentation of the FIRST bullet line encountered establishes the
2408
2873
  * "top-level" indent for the whole section; any subsequent `- `-opening line
@@ -2415,16 +2880,17 @@ function splitGapsEntriesCore(sectionBody) {
2415
2880
  *
2416
2881
  * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
2417
2882
  * the template emits) are discarded. An empty/whitespace-only section body
2418
- * (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).
2419
2885
  */
2420
- function splitGapsEntries(sectionBody) {
2421
- return splitGapsEntriesCore(sectionBody).map((entry) => entry.lines);
2886
+ function splitGapsEntries(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
2887
+ return splitGapsEntriesCore(sectionBody, markers).map((entry) => entry.lines);
2422
2888
  }
2423
2889
  /**
2424
2890
  * Sibling of `splitGapsEntries` (F1, #3458 follow-up review) that ADDITIVELY
2425
2891
  * carries each entry's character span — every existing `splitGapsEntries`
2426
2892
  * caller (`parseGapsItems`, `parseDeferredItemsWithStatus`,
2427
- * `splitDeferredHeadingEntries`'s `flushPending`) is unaffected and keeps
2893
+ * `splitDeferredHeadingEntriesDetailed`'s `flushPending`) is unaffected and keeps
2428
2894
  * using the plain `lines`-only shape. `acknowledgeDeferredItem` is the one
2429
2895
  * caller that needs a span: it used to select an entry via `splitGapsEntries`
2430
2896
  * and then RE-FIND that entry's location with a fresh regex search over
@@ -2436,8 +2902,8 @@ function splitGapsEntries(sectionBody) {
2436
2902
  * one. Carrying the span out of THIS same pass — the one that already knows
2437
2903
  * exactly where the entry lives — removes the re-derivation step entirely.
2438
2904
  */
2439
- function splitGapsEntriesWithSpans(sectionBody) {
2440
- return splitGapsEntriesCore(sectionBody);
2905
+ function splitGapsEntriesWithSpans(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
2906
+ return splitGapsEntriesCore(sectionBody, markers);
2441
2907
  }
2442
2908
  /**
2443
2909
  * Extract `key: value` fields from one Gaps entry's lines, anchored to the
@@ -2465,182 +2931,603 @@ function splitGapsEntriesWithSpans(sectionBody) {
2465
2931
  * keep their literal case, and mid-line emphasis is untouched, preserving the
2466
2932
  * start-anchored decoy invariant above.
2467
2933
  */
2468
- function extractGapEntryFields(entryLines) {
2934
+ function extractGapEntryFields(entryLines, markers = HYPHEN_BULLET_MARKERS, openerFlags) {
2469
2935
  const fields = {};
2470
- const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
2471
- const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
2472
- entryLines.forEach((rawLine, idx) => {
2473
- const line = rawLine.replace(/\r$/, '');
2474
- // Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
2475
- // a later line belongs to a nested sub-list and is handled by
2476
- // `splitGapsEntries` already folding it in — it is not itself a field
2477
- // line unless it independently matches `key: value` after stripping.
2478
- const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
2479
- const content = (idx === 0 && bulletStripped ? bulletStripped[2] : line.trim())
2480
- .replace(boldedKeyRe, (_m, key) => `${key.toLowerCase()}:`);
2481
- const m = fieldLineRe.exec(content);
2482
- 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)
2483
2953
  return;
2484
- const key = m[1];
2485
- let value = m[2].trim();
2486
- if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
2487
- value = value.slice(1, -1);
2488
- }
2489
- if (!(key in fields))
2490
- fields[key] = value;
2954
+ if (!(field.key in fields))
2955
+ fields[field.key] = field.value;
2491
2956
  });
2492
2957
  return fields;
2493
2958
  }
2494
- /** Fallback display text for a Gaps entry with no parseable `truth:` field. */
2495
- 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) {
2496
3075
  return entryLines
2497
- .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()))
2498
3081
  .join(' ')
2499
3082
  .trim();
2500
3083
  }
2501
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
+ */
2502
3334
  function parseVerificationItems(content, status, sourcePath) {
2503
3335
  const items = [];
3336
+ if (status === 'gaps_found') {
3337
+ items.push(...parseHumanVerificationItems(content, sourcePath));
3338
+ items.push(...parseVerificationGapsItems(content));
3339
+ return items;
3340
+ }
2504
3341
  if (status === 'human_needed') {
2505
- // #2286: the frontmatter's structured `human_verification:` YAML array
2506
- // (extractFrontmatter) is the PRIMARY source of truth when present and
2507
- // non-empty — it fully bypasses the body-shape scan below, so a file
2508
- // whose frontmatter declares the array doesn't require any particular
2509
- // `## Human Verification` body shape at all. An absent or empty array
2510
- // (length 0) falls back to the body scan unchanged.
2511
- const frontmatter = extractFrontmatter(content, sourcePath);
2512
- const humanVerification = frontmatter.human_verification;
2513
- if (Array.isArray(humanVerification) && humanVerification.length > 0) {
2514
- humanVerification.forEach((entry, idx) => {
2515
- items.push({
2516
- test: idx + 1,
2517
- name: normalizeHumanVerificationEntry(entry),
2518
- result: 'human_needed',
2519
- category: 'human_uat',
2520
- });
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',
2521
3409
  });
2522
- return items;
2523
- }
2524
- // Use the seam to locate the ## Human Verification section (ADR-1372 T5).
2525
- const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
2526
- if (hvSection) {
2527
- // #2245 review Fix 3: reverted to the pre-Phase-4 (HEAD 2cbf18642)
2528
- // implementation. The live Human Verification section is NOT a strict
2529
- // GFM table — the planner/verifier templates mix table rows, numbered
2530
- // items, and bullet items in the same section (and a `### N.` heading
2531
- // format is common too), so a table-XOR-list read (parse a table, and
2532
- // if it parses, suppress numbered/bullet items entirely) silently
2533
- // dropped items on any mixed or malformed section: a malformed
2534
- // `| N | … |` table with no valid header/delimiter yielded ZERO items
2535
- // instead of reading the rows positionally. This per-line scan reads
2536
- // table rows AND numbered items AND bullet items as a UNION (whichever
2537
- // pattern a given line matches), exactly like OLD, and reads
2538
- // `| 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.
2539
3442
  //
2540
- // #2245 audit: the table-row branch's CELL SPLIT is name/position-
2541
- // addressed via `splitTableRow` (escape-aware, canonical) instead of a
2542
- // hand-rolled pipe regex — candidacy itself is decided WITHOUT a table
2543
- // regex (a leading `|` plus a purely-numeric first cell), so this no
2544
- // longer needs an allow-adhoc-markdown suppression at all.
2545
- const lines = hvSection.body.split('\n');
2546
- for (const line of lines) {
2547
- const trimmedLine = line.trim();
2548
- // Match table rows: | N | description | ... — candidacy requires a
2549
- // leading pipe and a purely-numeric first cell (mirrors what the old
2550
- // regex effectively required: a "|digit|" cell immediately followed
2551
- // by more content), with at least 2 physical cells so a bare "| N |"
2552
- // with nothing after it is NOT treated as a row.
2553
- //
2554
- // #2245 review Fix 9: this is NOT the same as OLD for a row whose
2555
- // ONLY content past the digit cell is trailing whitespace (e.g.
2556
- // "| N | ", no second delimiting `|`). OLD's `([^|]+)` regex ran
2557
- // against the RAW (untrimmed) line and its `\s*` would backtrack to
2558
- // let `[^|]+` swallow that trailing whitespace, so OLD matched and
2559
- // pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
2560
- // `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
2561
- // `splitTableRow` ever sees it, collapsing the line to a single cell
2562
- // (`candidateCells.length === 1`), which fails the `>= 2` check —
2563
- // the item is silently dropped instead. A real, acceptable behaviour
2564
- // change (an empty-named UAT item is not useful either way), but the
2565
- // two implementations are NOT equivalent on this input.
2566
- let tableCells = null;
2567
- if (trimmedLine.startsWith('|')) {
2568
- const candidateCells = splitTableRow(trimmedLine);
2569
- if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
2570
- tableCells = candidateCells;
2571
- }
2572
- }
2573
- // Match bullet items: - description
2574
- const bulletMatch = line.match(/^[-*]\s+(.+)/);
2575
- // Match numbered items: 1. description
2576
- const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
2577
- if (tableCells) {
2578
- // Skip rows that already have a passing result (PASS, pass, resolved, etc.)
2579
- // — checked over every cell AFTER the description column, mirroring
2580
- // OLD's rowRemainder scan (which only ever saw cells past the
2581
- // description, the description itself having already been consumed).
2582
- const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
2583
- if (hasPassResult)
2584
- continue;
2585
- items.push({
2586
- test: parseInt(tableCells[0], 10),
2587
- name: tableCells[1] ?? '',
2588
- result: 'human_needed',
2589
- category: 'human_uat',
2590
- });
2591
- }
2592
- else if (numberedMatch) {
2593
- items.push({
2594
- test: parseInt(numberedMatch[1], 10),
2595
- name: numberedMatch[2].trim(),
2596
- result: 'human_needed',
2597
- category: 'human_uat',
2598
- });
2599
- }
2600
- else if (bulletMatch && bulletMatch[1].length > 10) {
2601
- items.push({
2602
- name: bulletMatch[1].trim(),
2603
- result: 'human_needed',
2604
- category: 'human_uat',
2605
- });
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;
2606
3460
  }
2607
3461
  }
2608
- // #2286: fall back to the `### N. <label>` heading + bold-led paragraph
2609
- // shape (the canonical form emitted by `templates/verification-report.md`
2610
- // — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
2611
- // **Why human:** ...`), which the table/bullet/numbered per-line scan
2612
- // above never recognises (a `###`-prefixed line matches none of those
2613
- // three patterns). Uses the same `tokenizeHeadings` seam
2614
- // `parseFirstPendingTest` already uses for `### N.` sub-headings,
2615
- // applied here to the Human Verification section body. Runs in
2616
- // addition to (a union with) the scan above — the two shapes don't
2617
- // collide, so this only adds items a `###` heading page would have
2618
- // silently produced zero for.
2619
- const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
2620
- for (let i = 0; i < hvSubHeadings.length; i += 1) {
2621
- const current = hvSubHeadings[i];
2622
- const next = hvSubHeadings[i + 1];
2623
- const block = next
2624
- ? hvSection.body.slice(current.offset, next.offset)
2625
- : hvSection.body.slice(current.offset);
2626
- const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
2627
- // Require a bold-led paragraph body (`**Test:** ...`) to distinguish
2628
- // a genuine verification item from an unrelated numbered heading.
2629
- if (!/^\s*\*\*/.test(bodyAfterHeading))
2630
- continue;
2631
- const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
2632
- 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)
2633
3473
  continue;
2634
3474
  items.push({
2635
- test: parseInt(headingParts[1], 10),
2636
- name: headingParts[2].trim(),
3475
+ test: parseInt(tableCells[0], 10),
3476
+ name: tableCells[1] ?? '',
3477
+ result: 'human_needed',
3478
+ category: 'human_uat',
3479
+ });
3480
+ }
3481
+ else if (numberedMatch) {
3482
+ items.push({
3483
+ test: parseInt(numberedMatch[1], 10),
3484
+ name: numberedMatch[2].trim(),
2637
3485
  result: 'human_needed',
2638
3486
  category: 'human_uat',
2639
3487
  });
2640
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
+ });
2641
3529
  }
2642
3530
  }
2643
- // gaps_found items are already handled by plan-phase --gaps pipeline
2644
3531
  return items;
2645
3532
  }
2646
3533
  /**
@@ -2728,4 +3615,21 @@ module.exports = {
2728
3615
  parseDeferredItems,
2729
3616
  parseDeferredItemsWithStatus,
2730
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,
2731
3635
  };