@pareto4/pp-core 1.11.1 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -0
  3. package/agents/pp-code-fixer.md +1 -1
  4. package/agents/pp-debug-session-manager.md +1 -1
  5. package/agents/pp-debugger.md +1 -1
  6. package/agents/pp-executor.md +20 -10
  7. package/agents/pp-intel-updater.md +1 -1
  8. package/agents/pp-phase-researcher.md +1 -1
  9. package/agents/pp-plan-checker.md +5 -5
  10. package/agents/pp-planner.md +10 -24
  11. package/agents/pp-project-researcher.md +1 -1
  12. package/agents/pp-research-synthesizer.md +1 -1
  13. package/agents/pp-ui-researcher.md +1 -1
  14. package/agents/pp-verifier.md +8 -4
  15. package/bin/install.js +429 -82
  16. package/commands/pp-autonomous.md +8 -6
  17. package/commands/pp-discovery-to-build.md +1 -1
  18. package/commands/pp-discovery.md +3 -2
  19. package/commands/pp-discuss-phase.md +2 -2
  20. package/commands/pp-execute-phase.md +3 -1
  21. package/commands/pp-graphify.md +5 -5
  22. package/commands/pp-import.md +1 -1
  23. package/commands/pp-map-codebase.md +1 -1
  24. package/commands/pp-ns-project.md +2 -3
  25. package/commands/pp-ns-workflow.md +1 -1
  26. package/commands/pp-plan-phase.md +2 -1
  27. package/commands/pp-verify-work.md +2 -1
  28. package/gemini-extension.json +1 -1
  29. package/hooks/dist/pp-cursor-session-start.js +2 -2
  30. package/hooks/dist/pp-session-state.sh +1 -1
  31. package/hooks/dist/pp-statusline.js +21 -2
  32. package/hooks/pp-cursor-session-start.js +2 -2
  33. package/hooks/pp-session-state.sh +1 -1
  34. package/hooks/pp-statusline.js +21 -2
  35. package/package.json +18 -5
  36. package/pareto-skills/pp-project-setup/SKILL.md +5 -63
  37. package/pp-core/bin/detect-build-commands.sh +182 -0
  38. package/pp-core/bin/lib/capability-activation.cjs +19 -9
  39. package/pp-core/bin/lib/capability-registry.cjs +65 -7
  40. package/pp-core/bin/lib/check-command-router.cjs +79 -38
  41. package/pp-core/bin/lib/clusters.cjs +2 -3
  42. package/pp-core/bin/lib/command-aliases.cjs +36 -0
  43. package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
  44. package/pp-core/bin/lib/commands.cjs +21 -6
  45. package/pp-core/bin/lib/compose-bindings.cjs +70 -6
  46. package/pp-core/bin/lib/config-loader.cjs +151 -8
  47. package/pp-core/bin/lib/config-schema.cjs +16 -0
  48. package/pp-core/bin/lib/config.cjs +142 -27
  49. package/pp-core/bin/lib/core-utils.cjs +43 -7
  50. package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
  51. package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
  52. package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
  53. package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
  54. package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
  55. package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
  56. package/pp-core/bin/lib/discovery-table.cjs +98 -0
  57. package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
  58. package/pp-core/bin/lib/frontmatter.cjs +17 -2
  59. package/pp-core/bin/lib/init-command-router.cjs +8 -5
  60. package/pp-core/bin/lib/init-contract.cjs +93 -4
  61. package/pp-core/bin/lib/init.cjs +124 -134
  62. package/pp-core/bin/lib/install-profiles.cjs +5 -0
  63. package/pp-core/bin/lib/install-run.cjs +536 -0
  64. package/pp-core/bin/lib/io.cjs +15 -0
  65. package/pp-core/bin/lib/loop-resolver.cjs +40 -2
  66. package/pp-core/bin/lib/milestone.cjs +345 -48
  67. package/pp-core/bin/lib/phase-command-router.cjs +23 -1
  68. package/pp-core/bin/lib/phase-locator.cjs +15 -6
  69. package/pp-core/bin/lib/phase-scope.cjs +91 -0
  70. package/pp-core/bin/lib/phase-tier.cjs +467 -0
  71. package/pp-core/bin/lib/phase.cjs +247 -21
  72. package/pp-core/bin/lib/phases-command-router.cjs +2 -0
  73. package/pp-core/bin/lib/plan-graph.cjs +368 -0
  74. package/pp-core/bin/lib/planning-workspace.cjs +53 -0
  75. package/pp-core/bin/lib/platform-bindings.cjs +32 -2
  76. package/pp-core/bin/lib/profile-output.cjs +11 -3
  77. package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
  78. package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
  79. package/pp-core/bin/lib/roadmap.cjs +141 -39
  80. package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  81. package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
  82. package/pp-core/bin/lib/state-command-router.cjs +82 -13
  83. package/pp-core/bin/lib/state-document.cjs +124 -7
  84. package/pp-core/bin/lib/state.cjs +784 -257
  85. package/pp-core/bin/lib/template.cjs +22 -6
  86. package/pp-core/bin/lib/vault-command-router.cjs +57 -6
  87. package/pp-core/bin/lib/vault-practices.cjs +6 -36
  88. package/pp-core/bin/lib/vault-write-back.cjs +20 -4
  89. package/pp-core/bin/lib/vault.cjs +592 -23
  90. package/pp-core/bin/lib/verify.cjs +53 -31
  91. package/pp-core/bin/lib/workstream.cjs +1 -1
  92. package/pp-core/bin/lib/worktree-safety.cjs +26 -15
  93. package/pp-core/bin/portable-timeout.sh +89 -0
  94. package/pp-core/bin/pp-tools.cjs +149 -9
  95. package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
  96. package/pp-core/bin/shared/config-schema.manifest.json +3 -0
  97. package/pp-core/references/autonomous-best-effort.md +461 -0
  98. package/pp-core/references/autonomous-lifecycle.md +146 -0
  99. package/pp-core/references/autonomous-smart-discuss.md +16 -12
  100. package/pp-core/references/checkpoints.md +1 -1
  101. package/pp-core/references/discovery-id-conventions.md +202 -0
  102. package/pp-core/references/discovery-rebaseline.md +254 -0
  103. package/pp-core/references/discovery-research-track.md +283 -0
  104. package/pp-core/references/discovery-traceability.md +400 -0
  105. package/pp-core/references/loop-hook-dispatch.md +14 -0
  106. package/pp-core/references/mandatory-initial-read.md +1 -1
  107. package/pp-core/references/planner-gap-closure.md +1 -0
  108. package/pp-core/references/planner-human-verify-mode.md +1 -1
  109. package/pp-core/references/planner-load-graph-context.md +1 -1
  110. package/pp-core/references/planner-plan-graph.md +66 -0
  111. package/pp-core/references/planner-tdd.md +1 -0
  112. package/pp-core/references/planning-config.md +8 -0
  113. package/pp-core/references/sketch-tooling.md +4 -0
  114. package/pp-core/references/tdd.md +1 -0
  115. package/pp-core/templates/claude-md.md +1 -1
  116. package/pp-core/templates/phase-prompt.md +6 -2
  117. package/pp-core/templates/prototype-review/review.js +303 -0
  118. package/pp-core/templates/state.md +6 -0
  119. package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
  120. package/pp-core/workflows/add-backlog.md +1 -1
  121. package/pp-core/workflows/add-phase.md +1 -1
  122. package/pp-core/workflows/add-tests.md +1 -1
  123. package/pp-core/workflows/add-todo.md +1 -1
  124. package/pp-core/workflows/ai-integration-phase.md +2 -2
  125. package/pp-core/workflows/audit-fix.md +12 -16
  126. package/pp-core/workflows/audit-milestone.md +2 -2
  127. package/pp-core/workflows/audit-uat.md +1 -1
  128. package/pp-core/workflows/autonomous.md +68 -306
  129. package/pp-core/workflows/check-todos.md +1 -1
  130. package/pp-core/workflows/cleanup.md +1 -1
  131. package/pp-core/workflows/code-review-fix.md +2 -2
  132. package/pp-core/workflows/code-review.md +20 -8
  133. package/pp-core/workflows/complete-milestone.md +41 -60
  134. package/pp-core/workflows/debug.md +1 -1
  135. package/pp-core/workflows/diagnose-issues.md +1 -1
  136. package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
  137. package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
  138. package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
  139. package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
  140. package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
  141. package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
  142. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
  143. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
  144. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
  145. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
  146. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
  147. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
  148. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
  149. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
  150. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
  151. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
  152. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
  153. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
  154. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
  155. package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
  156. package/pp-core/workflows/discovery-sprint.md +79 -1714
  157. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
  158. package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
  159. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
  160. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
  161. package/pp-core/workflows/discovery-to-build.md +21 -238
  162. package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
  163. package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
  164. package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
  165. package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
  166. package/pp-core/workflows/discuss-phase.md +11 -4
  167. package/pp-core/workflows/do.md +4 -4
  168. package/pp-core/workflows/docs-update.md +1 -1
  169. package/pp-core/workflows/edit-phase.md +1 -1
  170. package/pp-core/workflows/eval-review.md +1 -1
  171. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
  172. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
  173. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  174. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
  175. package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
  176. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
  177. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
  178. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
  179. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
  180. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
  181. package/pp-core/workflows/execute-phase.md +104 -218
  182. package/pp-core/workflows/execute-plan.md +6 -2
  183. package/pp-core/workflows/explore.md +1 -1
  184. package/pp-core/workflows/extract-learnings.md +1 -1
  185. package/pp-core/workflows/forensics.md +4 -4
  186. package/pp-core/workflows/graduation.md +1 -1
  187. package/pp-core/workflows/health.md +1 -1
  188. package/pp-core/workflows/help/modes/brief.md +1 -0
  189. package/pp-core/workflows/help/modes/default.md +2 -1
  190. package/pp-core/workflows/help/modes/full.md +12 -5
  191. package/pp-core/workflows/import.md +1 -1
  192. package/pp-core/workflows/ingest-docs.md +1 -1
  193. package/pp-core/workflows/init.md +18 -2
  194. package/pp-core/workflows/insert-phase.md +1 -1
  195. package/pp-core/workflows/list-workspaces.md +1 -1
  196. package/pp-core/workflows/manager.md +3 -3
  197. package/pp-core/workflows/map-codebase.md +2 -2
  198. package/pp-core/workflows/milestone-summary.md +4 -4
  199. package/pp-core/workflows/mvp-phase.md +1 -1
  200. package/pp-core/workflows/new-milestone.md +7 -8
  201. package/pp-core/workflows/new-project.md +6 -1
  202. package/pp-core/workflows/new-workspace.md +1 -1
  203. package/pp-core/workflows/next.md +2 -2
  204. package/pp-core/workflows/note.md +2 -2
  205. package/pp-core/workflows/pause-work.md +1 -1
  206. package/pp-core/workflows/plan-milestone-gaps.md +1 -1
  207. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
  208. package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
  209. package/pp-core/workflows/plan-phase.md +43 -167
  210. package/pp-core/workflows/plan-review-convergence.md +2 -2
  211. package/pp-core/workflows/plant-seed.md +1 -1
  212. package/pp-core/workflows/pr-branch.md +1 -1
  213. package/pp-core/workflows/profile-user.md +1 -1
  214. package/pp-core/workflows/progress.md +3 -3
  215. package/pp-core/workflows/quick.md +4 -2
  216. package/pp-core/workflows/remove-phase.md +1 -1
  217. package/pp-core/workflows/remove-workspace.md +1 -1
  218. package/pp-core/workflows/resume-project.md +6 -12
  219. package/pp-core/workflows/review.md +1 -1
  220. package/pp-core/workflows/scan.md +1 -1
  221. package/pp-core/workflows/secure-phase.md +2 -2
  222. package/pp-core/workflows/settings-advanced.md +1 -1
  223. package/pp-core/workflows/settings-integrations.md +1 -1
  224. package/pp-core/workflows/settings.md +1 -1
  225. package/pp-core/workflows/ship.md +26 -9
  226. package/pp-core/workflows/sketch-wrap-up.md +1 -1
  227. package/pp-core/workflows/sketch.md +2 -1
  228. package/pp-core/workflows/spec-phase.md +1 -1
  229. package/pp-core/workflows/spike-wrap-up.md +1 -1
  230. package/pp-core/workflows/spike.md +1 -1
  231. package/pp-core/workflows/stats.md +2 -2
  232. package/pp-core/workflows/thread.md +1 -1
  233. package/pp-core/workflows/transition.md +5 -9
  234. package/pp-core/workflows/ui-phase.md +23 -2
  235. package/pp-core/workflows/ui-review.md +1 -1
  236. package/pp-core/workflows/ultraplan-phase.md +2 -2
  237. package/pp-core/workflows/validate-phase.md +2 -2
  238. package/pp-core/workflows/vault-write-back.md +1 -1
  239. package/pp-core/workflows/verify-phase.md +29 -26
  240. package/pp-core/workflows/verify-work.md +14 -10
  241. package/scripts/base64-scan.sh +7 -41
  242. package/scripts/changeset/cli.cjs +25 -5
  243. package/scripts/dev-env.cjs +462 -0
  244. package/scripts/lib/allowlist-ratchet.cjs +103 -1
  245. package/scripts/lib/size-budget.cjs +311 -0
  246. package/scripts/lint-neutrality-names.cjs +694 -0
  247. package/scripts/lint-org-coordinates.allowlist.json +35 -0
  248. package/scripts/lint-org-coordinates.cjs +426 -0
  249. package/scripts/lint-skill-deps.cjs +48 -9
  250. package/scripts/lint-test-file-count.cjs +17 -2
  251. package/scripts/prompt-injection-scan.sh +41 -20
  252. package/scripts/sync-manifest-versions.cjs +41 -7
  253. package/scripts/sync-runtime-launcher.cjs +106 -1
  254. package/scripts/update-size-baseline.cjs +274 -30
  255. package/scripts/workflow-size.cjs +61 -9
  256. package/commands/pp-build-handoff.md +0 -46
  257. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -94
@@ -0,0 +1,1372 @@
1
+ "use strict";
2
+ /**
3
+ * Discovery scope re-baselining enforcement engine (discovery-v2 Phase 5,
4
+ * VAR-02, D-65..D-77).
5
+ *
6
+ * Read-only structural validator over `.planning/discovery/_rebaseline.md` —
7
+ * the client-doc ↔ requirement ↔ built-phase matrix (D-66). It exists for one
8
+ * situation: a client scope document arrives AFTER build has started, and the
9
+ * functionality already built has to be reconciled against it, phase by phase,
10
+ * with a disposition on each and an argued reason attached to every disposition
11
+ * that is not "carry on".
12
+ *
13
+ * The spine is one row per BUILT unit (D-68), because the thing being
14
+ * dispositioned is built functionality — the only spine that gives a row to
15
+ * functionality the new document simply stops mentioning, which is the whole
16
+ * `lapsed` case. Row identity is the round number plus the built unit's
17
+ * existing roadmap phase number; this engine mints NO ID family at all (D-67).
18
+ *
19
+ * Fail-loud (D-72/D-73), one bullet per fault class this module can emit:
20
+ * - bad_id — a `## Round` heading that CLAIMS the round family but does not
21
+ * match the grammar (a three-digit round number lands here), and the matrix
22
+ * row arity fault: a pipe line inside a round that is neither the header
23
+ * nor the separator and does not carry exactly six cells
24
+ * - duplicate_id — the same round number heading two sections, or the same
25
+ * phase dispositioned twice inside one round. The first row keeps the slot;
26
+ * the later row is reported rather than dropped, because its disposition may
27
+ * contradict the one already recorded
28
+ * - sequence_gap — round numbers that are not contiguous from 1 (D-41/D-66);
29
+ * the locator names the MISSING number, so a deleted round cannot lapse
30
+ * - missing_section — a PRESENT artifact holding no round at all, empty or
31
+ * not: a bootstrap that never got its rows, not a project with nothing to
32
+ * say. A zero-byte or whitespace-only file is the purest instance of that,
33
+ * and exempting it would let the gate exit 0 while the report claimed the
34
+ * artifact had been checked
35
+ * - missing_round_metadata — a round that records no phase denominator. A
36
+ * fault rather than a graceful skip on purpose (D-76): without a denominator
37
+ * the phase-coverage rule can never fire, and a check that exits 0 having
38
+ * verified nothing is precisely the fail-open this engine exists to close
39
+ * - bad_phase_token — a token outside the phase grammar, in the denominator
40
+ * line or in the `Phase` cell alike. ONE grammar for both, deliberately, so
41
+ * coverage is a token-for-token comparison with no normalisation step
42
+ * - bad_built_status — a `Built` cell outside `executed` / `in-flight` /
43
+ * `not started` (D-69)
44
+ * - bad_disposition — a `Disposition` value outside the four scope states
45
+ * (D-70)
46
+ * - disposition_without_reason — a bare `validate`, `dormant` or `lapsed`
47
+ * (D-71). This is the mechanism that stops an agent proposing a lapse
48
+ * without arguing it; a bare `active` is clean, and that polarity is
49
+ * load-bearing rather than incidental
50
+ * - bad_clause_citation — a malformed token in the `Clauses` cell, or a
51
+ * `validate` whose parenthesised reason is prose rather than a D-23 citation
52
+ * - unknown_requirement_id — a `Discovery requirements` token of the wrong
53
+ * shape. The resolves-to-nothing arm of the same code, together with
54
+ * missing_phase_row, requirements_unparseable and artifact_unreadable, is
55
+ * emitted by the aggregator this module's sibling plan adds; all fifteen
56
+ * codes are declared here so the tuple is complete from the start and the
57
+ * reference agrees with it in both directions
58
+ * - unterminated_fence — a code fence opened inside the artifact and never
59
+ * closed. A fenced block is data and its contents are skipped, so an
60
+ * UNBALANCED fence would otherwise skip every remaining round heading,
61
+ * metadata bullet and matrix row and let the gate exit 0 having verified
62
+ * nothing — the same fail-open `missing_round_metadata` exists to close,
63
+ * with the whole rest of the file as its blast radius
64
+ *
65
+ * Graceful arms (D-42/D-66, wired into the aggregator): an ABSENT
66
+ * `_rebaseline.md` is a clean pass — most engagements never re-baseline, and a
67
+ * check that reddened on the absence would turn a variation nobody triggered
68
+ * into a red gate for everybody — while a PRESENT-BUT-UNREADABLE one is
69
+ * `artifact_unreadable`. Collapsing the two would let the gate exit 0 having
70
+ * validated nothing.
71
+ *
72
+ * Reads `.planning/discovery/` and NOTHING else (D-76). The phase denominator
73
+ * every coverage judgement is made against is recorded by the round itself, so
74
+ * this engine performs no roadmap access at all. That keeps each round the
75
+ * self-contained snapshot D-66 already says it is, keeps the dependency
76
+ * footprint identical to the four sibling engines, and dissolves the asymmetry
77
+ * that `.planning/discovery/` is hard-coded non-workstream-scoped while a
78
+ * roadmap path would follow `--ws`.
79
+ *
80
+ * Content-free contract (T-05-01): a violation carries the artifact name, a
81
+ * 1-based source row, a validated round number, a phase token that has already
82
+ * passed its grammar, a validated requirement ID, a closed-list field name and a
83
+ * BOUNDED closed-vocabulary cell prefix — and nothing else. The `Violation`
84
+ * interface has NO free-text member: the `Roadmap requirements` provenance cell
85
+ * (D-77a), the parenthesised disposition reason (D-71) and the round label are
86
+ * parsed and stored on the parse RESULT, and no code path can place any of them
87
+ * on a violation. `bad_phase_token` and the wrong-shape arm of
88
+ * `unknown_requirement_id` carry no echo at all — a cell that failed its grammar
89
+ * has passed no validation, so there is nothing about it that is safe to quote.
90
+ * The `ECHO_MAX_CHARS` bound is a confidentiality control, not a formatting
91
+ * preference: matrix prose is written out of client documents (D-24).
92
+ *
93
+ * Never-judges-a-disposition contract (D-72): this check tests STRUCTURE only —
94
+ * whether the value is in the enum, whether a non-`active` value carries a
95
+ * reason, whether a cited discovery requirement ID resolves, whether every
96
+ * recorded phase has a row, and whether the round headings and their metadata
97
+ * are well-formed. Whether a given `lapsed` is a GOOD call is not
98
+ * machine-checkable, and settling that is the job of the targeted-review walk,
99
+ * with a human in it. There is likewise no round-to-round monotonicity rule,
100
+ * and that is settled rather than pending (D-76): rounds are independent
101
+ * snapshots, a later round may reverse an earlier verdict, and both records
102
+ * survive.
103
+ *
104
+ * The single home for the matrix template, the vocabularies and the
105
+ * parser-contract table this module implements is
106
+ * `pp-core/references/discovery-rebaseline.md` (D-65).
107
+ */
108
+ var __importDefault = (this && this.__importDefault) || function (mod) {
109
+ return (mod && mod.__esModule) ? mod : { "default": mod };
110
+ };
111
+ const node_fs_1 = __importDefault(require("node:fs"));
112
+ const node_path_1 = __importDefault(require("node:path"));
113
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
114
+ const io = require("./io.cjs");
115
+ const { output, error, ERROR_REASON } = io;
116
+ // `_requirements.md` is owned by `discovery rollup`; the D-77b resolution arm
117
+ // consumes its single exported reader and nothing else, so a grammar fault
118
+ // there is reported once by name rather than re-enumerated here.
119
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
120
+ const rollup = require("./discovery-rollup.cjs");
121
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
122
+ const table = require("./discovery-table.cjs");
123
+ const { splitTableCells, SEPARATOR_ROW_RE, boundedSequenceMax } = table;
124
+ // ─── Types ────────────────────────────────────────────────────────────────────
125
+ /**
126
+ * The closed set of reason codes this engine can emit, in declaration order.
127
+ *
128
+ * Exported as a runtime VALUE, not merely as a type, specifically so the
129
+ * parser-contract table in `pp-core/references/discovery-rebaseline.md` can be
130
+ * pinned against it by a test instead of by a manual diff — in BOTH directions:
131
+ * a code here with no row there fails, and a row there naming a code this engine
132
+ * cannot emit fails too. `ViolationReason` is derived from the tuple, so a code
133
+ * added to one is a code added to both.
134
+ *
135
+ * Declared IN FULL here even though this module emits only the first ten and
136
+ * the fifteenth. Codes eleven through fourteen are raised by the aggregator
137
+ * added alongside the CLI arm; the declaration ORDER is part of the contract,
138
+ * so a code moved here is a row the reference table has to move too.
139
+ *
140
+ * Append-only by convention: `unterminated_fence` sits last so the fourteen
141
+ * earlier entries keep their positions and the reference table extends by a row
142
+ * rather than being reordered — which is what keeps a fault code's spelling
143
+ * stable for the readers and scripts that have already learned it. That is also
144
+ * why the unbalanced fence got a code of its OWN rather than reusing `bad_id`:
145
+ * a fault code that names the wrong condition is the defect this gap set exists
146
+ * to remove, not a saving.
147
+ */
148
+ const VIOLATION_REASONS = Object.freeze([
149
+ 'bad_id',
150
+ 'duplicate_id',
151
+ 'sequence_gap',
152
+ 'missing_section',
153
+ 'missing_round_metadata',
154
+ 'bad_phase_token',
155
+ 'bad_built_status',
156
+ 'bad_disposition',
157
+ 'disposition_without_reason',
158
+ 'bad_clause_citation',
159
+ 'unknown_requirement_id',
160
+ 'missing_phase_row',
161
+ 'requirements_unparseable',
162
+ 'artifact_unreadable',
163
+ 'unterminated_fence',
164
+ ]);
165
+ // ─── Closed vocabularies ──────────────────────────────────────────────────────
166
+ /**
167
+ * The four SCOPE-STATE values a row may be dispositioned with (D-70).
168
+ *
169
+ * `active` — the new scope still wants it as built, nothing to do.
170
+ * `validate` — still in scope, but the new document changes its expected
171
+ * behaviour; it must be re-verified against the named clause.
172
+ * `dormant` — out of scope now but plausibly returns; the code stays,
173
+ * unadvertised and unmaintained, with a named re-entry condition.
174
+ * `lapsed` — the new scope removes it outright; the code stays in the tree but
175
+ * is no longer claimed as delivered.
176
+ *
177
+ * The cut between the last two is "may return" versus "won't". Nothing in this
178
+ * vocabulary demands deletion.
179
+ *
180
+ * NAMED `SCOPE_STATES` RATHER THAN A DISPOSITION-SHAPED NAME ON PURPOSE.
181
+ * `_requirements.md` already owns a vocabulary called dispositions, with the
182
+ * values MVP / Later / Dropped, exported from `discovery-rollup.cts` as
183
+ * `DISPOSITIONS`. D-03/D-70/D-74 make this a THIRD, orthogonal axis living in a
184
+ * different artifact on a different spine, and re-baselining writes no
185
+ * `_requirements.md` cell at all. Two frozen objects sharing one name across two
186
+ * engines is how a reader conflates two axes the design deliberately separated.
187
+ */
188
+ const SCOPE_STATES = Object.freeze({
189
+ ACTIVE: 'active',
190
+ VALIDATE: 'validate',
191
+ DORMANT: 'dormant',
192
+ LAPSED: 'lapsed',
193
+ });
194
+ const SCOPE_VALUES = Object.values(SCOPE_STATES);
195
+ /**
196
+ * The closed three-value `Built` enum (D-69), mapped by the workflow from the
197
+ * `disk_status` field of `pp-tools query roadmap analyze`: `complete` →
198
+ * `executed`; `partial` / `planned` → `in-flight`; everything else → `not
199
+ * started`.
200
+ *
201
+ * Every phase in the denominator gets a row, INCLUDING phases nobody has
202
+ * started: the cheapest scope kill is a phase nobody has started, and excluding
203
+ * queued work would hide precisely the decisions that cost nothing to make.
204
+ */
205
+ const BUILT_STATES = Object.freeze({
206
+ EXECUTED: 'executed',
207
+ IN_FLIGHT: 'in-flight',
208
+ NOT_STARTED: 'not started',
209
+ });
210
+ const BUILT_VALUES = Object.values(BUILT_STATES);
211
+ // ─── Module constants ─────────────────────────────────────────────────────────
212
+ /**
213
+ * The confidentiality bound on every echoed detail. Not a formatting
214
+ * preference: matrix prose is derived from client documents (D-24), so an
215
+ * unbounded echo is a disclosure. Same value as all four sibling engines,
216
+ * deliberately — a bound that differs per engine is a bound somebody has to
217
+ * remember, and the one they misremember is the one that leaks.
218
+ */
219
+ const ECHO_MAX_CHARS = 24;
220
+ /**
221
+ * The cap on how many violations a failure message RENDERS. A pathological
222
+ * artifact would otherwise inline one line each into the `--json-errors` JSON
223
+ * `message`. The cap bounds the report, never the accounting: the count in the
224
+ * message prefix and the `violations` field of the failure-path summary both
225
+ * stay the true total.
226
+ */
227
+ const MAX_REPORTED_VIOLATIONS = 50;
228
+ /**
229
+ * Hard-coded exactly as all four sibling engines hard-code it, and deliberately
230
+ * NOT resolved through the workspace path helper. `.planning/discovery/` is not
231
+ * workstream-scoped, so a `--ws`-following roadmap path beside it would be an
232
+ * asymmetry the engine has to reconcile at every read. D-76 dissolves that
233
+ * asymmetry instead of papering over it, by recording the phase denominator in
234
+ * the round itself — an engine that never opens a roadmap never has to
235
+ * reconcile the two.
236
+ */
237
+ const DISCOVERY_DIR = '.planning/discovery';
238
+ const REBASELINE_FILE = '_rebaseline.md';
239
+ const REQUIREMENTS_FILE = '_requirements.md';
240
+ /** The em dash an author writes to mean "nothing here" in a list-bearing cell. */
241
+ const NOT_APPLICABLE = '—';
242
+ /**
243
+ * The gate's identity, exported under its own name as well as through
244
+ * `ERROR_REASON`: it is cited by the workflow steps that run it, by the
245
+ * reference, and by the derived-command audit that regexes the subcommand
246
+ * spelling straight out of this module rather than re-spelling it as a string
247
+ * literal in a second place.
248
+ */
249
+ const REBASELINE_CHECK_FAILED = ERROR_REASON.DISCOVERY_REBASELINE_CHECK_FAILED;
250
+ const REBASELINE_CHECK_USAGE = ERROR_REASON.USAGE;
251
+ // ─── Grammar ──────────────────────────────────────────────────────────────────
252
+ // Anchored, non-backtracking regexes (threat T-05-07). Every one of them is
253
+ // applied either to a SINGLE already-split line or to a SINGLE already-split
254
+ // table cell — never unanchored across whole-file untrusted content — and every
255
+ // quantifier is bounded. The matrix is written out of client documents, so a
256
+ // catastrophic backtracking pattern here would be a denial-of-service surface.
257
+ /**
258
+ * D-66 round heading, byte-identical in shape to the `_feedback.md` one: a
259
+ * second-level heading, the word Round, a decimal round number, then an
260
+ * em-dash-separated ISO date and an em-dash-separated free-text label. The
261
+ * triggering accession lives in a metadata BULLET rather than in the heading
262
+ * precisely so this shape could transfer unchanged.
263
+ *
264
+ * ONLY the round number is validated. The date and the label are matched for
265
+ * SHAPE and their content is never inspected — stated here so the reference's
266
+ * parser-contract table can say so honestly rather than implying a date check
267
+ * this engine does not perform.
268
+ *
269
+ * The round number is BOUNDED to two digits, like every other discovery ID
270
+ * family. An unbounded `\d+` let a plain typo — the ISO date written into the
271
+ * round-number slot, `## Round 20250115` — drive `validateRoundSequence` into
272
+ * allocating twenty million gap violations and abort the process on a V8 heap
273
+ * fatal, which is not a JavaScript exception and therefore not something a
274
+ * try/catch can turn into a structured result.
275
+ */
276
+ const ROUND_HEADING_RE = /^## Round (\d{1,2}) — ([^—]+) — (.+)$/;
277
+ /**
278
+ * A heading that CLAIMS the round family. A claimed-but-malformed one is a
279
+ * reported fault, not a skipped line (T-05-04): a malformed heading a parser
280
+ * passes over is a whole section of the audit trail the gate never looked at.
281
+ * A heading that never claims the family is ignored outright — other
282
+ * second-level sections in the document are not this parser's business.
283
+ */
284
+ const ROUND_CLAIM_RE = /^## Round\b/;
285
+ /**
286
+ * The bound on the phase-denominator payload, in characters after the colon.
287
+ *
288
+ * Derived from the shape the rebaseline WORKFLOW actually instructs rather than
289
+ * chosen for roundness. A partitioned project prefixes every phase token with
290
+ * its workstream name — `discovery-v2:04, ` — which runs about seventeen
291
+ * characters, so the previous 400 was exhausted at roughly twenty-four phases:
292
+ * an ordinary project size, and a line the author had every reason to believe
293
+ * was correct (WR-03). 2000 admits well over a hundred such tokens, which no
294
+ * real roadmap reaches.
295
+ *
296
+ * The larger bound adds NO backtracking surface (T-05-07, T-05-31). The
297
+ * quantifier is still single, still non-nested, still anchored at both ends, and
298
+ * still applied to ONE already-split line, so the match is linear in the line
299
+ * length — and the split list it feeds is bounded by the same number.
300
+ *
301
+ * EXPORTED, and stated by number in the reference, because the whole of WR-03
302
+ * was a bound nobody could see reported under a code that named a different
303
+ * condition. A bound an author can neither read nor derive is a bound they can
304
+ * only discover by failing.
305
+ */
306
+ const DENOMINATOR_MAX_CHARS = 2000;
307
+ /**
308
+ * The per-round phase-denominator metadata bullet (D-76). Its capture is the
309
+ * comma-separated phase-token list every coverage judgement is made against.
310
+ *
311
+ * Built from `DENOMINATOR_MAX_CHARS` rather than restating it, so the bound the
312
+ * reference documents, the bound the tests derive their payloads from and the
313
+ * bound the grammar enforces are one number. The pattern is a fixed string
314
+ * composed at module load from a module constant — no input reaches it.
315
+ */
316
+ const DENOMINATOR_LINE_RE = new RegExp(`^- Phases at this round:(.{0,${DENOMINATOR_MAX_CHARS}})$`);
317
+ /**
318
+ * A line that CLAIMS the phase-denominator family. Same claim-versus-match
319
+ * idiom as `ROUND_CLAIM_RE` versus `ROUND_HEADING_RE`, and for the same reason:
320
+ * a claimed-but-malformed line a parser passes over is a line the author
321
+ * believes is recording a denominator that the gate never read.
322
+ *
323
+ * Before WR-03 an over-long bullet matched nothing at all and fell through the
324
+ * accession check, the separator check and the leading-pipe check to be dropped
325
+ * in silence — leaving the round to close with no denominator and report
326
+ * `missing_round_metadata`, a fault whose text describes a round that records no
327
+ * denominator about a round that records one perfectly well.
328
+ */
329
+ const DENOMINATOR_CLAIM_RE = /^- Phases at this round:/;
330
+ /**
331
+ * The per-round accession metadata bullet, matched for PRESENCE only. The
332
+ * accession is recorded for provenance and is never resolved against the
333
+ * input-package README — do not read a resolution guarantee into a passing run.
334
+ */
335
+ const ACCESSION_LINE_RE = /^- Triggering document:(.{0,80})$/;
336
+ /**
337
+ * A roadmap phase number, optionally prefixed by a lower-case workstream name
338
+ * and a colon when a project is partitioned across several roadmaps — `04`, or
339
+ * `discovery-v2:04`. An optional dotted sub-number covers a split phase.
340
+ *
341
+ * The SAME grammar governs the denominator line and the `Phase` cell,
342
+ * deliberately, so the two can be compared token for token without a
343
+ * normalisation step — the cheapest possible implementation of the coverage
344
+ * rule and the one least likely to drift.
345
+ */
346
+ const PHASE_TOKEN_RE = /^(?:[a-z][a-z0-9-]{0,39}:)?\d{1,3}(?:\.\d{1,2})?$/;
347
+ /**
348
+ * The D-23 bracketed accession-plus-locator citation, the same shape
349
+ * `discovery-id-conventions.md` defines and `discovery-rollup.cts` already
350
+ * enforces — `[03]`, `[03 §2.1]`. The eighty-character locator bound is a
351
+ * confidentiality control (D-24), not a formatting preference.
352
+ */
353
+ const CLAUSE_CITE_RE = /^\[(\d{2})( §[^\]]{1,80})?\]$/;
354
+ /**
355
+ * A discovery requirement ID as `_requirements.md` mints them. Bounded rather
356
+ * than the siblings' open `[A-Z0-9]*-\d+`, per the T-05-07 discipline; the
357
+ * bounds are a generous superset of every reserved discovery prefix, so nothing
358
+ * the owning gate accepts is rejected here.
359
+ */
360
+ const REQUIREMENT_ID_RE = /^[A-Z][A-Z0-9]{0,15}-\d{1,6}$/;
361
+ /**
362
+ * The D-71 value-plus-structured-reason `Disposition` cell — the same trick as
363
+ * `Closed (<evidence>)`, `Dropped (D-<ss>-<n>)` and `DEFERRED (<reason>)`.
364
+ * Capture 1 is the bounded lower-case value token, capture 2 the optional
365
+ * bounded parenthesised reason.
366
+ *
367
+ * A separate Reason COLUMN was rejected because a separate column lets the
368
+ * reason be empty without breaking the enum's grammar — decoupling precisely
369
+ * what this shape couples. The reason text is free text and is never echoed.
370
+ */
371
+ const DISPOSITION_CELL_RE = /^([a-z][a-z-]{0,23})(?: \((.{0,400})\))?$/;
372
+ /** A fenced block opener or closer. Contents are data, never parsed as rows. */
373
+ const FENCE_RE = /^(?:```|~~~)/;
374
+ /**
375
+ * The six column names in the fixed order the reference sets. The header row is
376
+ * recognised by COMPARISON against this array rather than by a regex, so the
377
+ * order is a value the reference and the engine can be pinned against rather
378
+ * than a pattern buried in a match.
379
+ */
380
+ const MATRIX_HEADER_CELLS = Object.freeze([
381
+ 'Phase',
382
+ 'Built',
383
+ 'Roadmap requirements',
384
+ 'Discovery requirements',
385
+ 'Clauses',
386
+ 'Disposition',
387
+ ]);
388
+ /** Column indices into a split matrix row, in the fixed order above. */
389
+ const MATRIX_COL = Object.freeze({
390
+ PHASE: 0,
391
+ BUILT: 1,
392
+ ROADMAP_REQUIREMENTS: 2,
393
+ DISCOVERY_REQUIREMENTS: 3,
394
+ CLAUSES: 4,
395
+ DISPOSITION: 5,
396
+ });
397
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
398
+ /**
399
+ * Every control character the whitespace collapse below does NOT handle: the C0
400
+ * plane minus `\t\n\v\f\r`, DEL, and the whole C1 plane.
401
+ *
402
+ * Stripped BEFORE the collapse, and the ordering is the whole point. JavaScript
403
+ * `\s` matches only the five C0 whitespace characters plus the Unicode space
404
+ * separators; it does NOT match ESC (`\x1b`), BEL (`\x07`), BS (`\x08`) or NUL.
405
+ * `formatViolation` output reaches `error()`, which in the default
406
+ * non-`--json-errors` mode writes `'Error: ' + message` to fd 2 VERBATIM — the
407
+ * mode a human operator reads in a terminal. A complete OSC-52 clipboard-write
408
+ * sequence fits inside `ECHO_MAX_CHARS`, and an UNTERMINATED OSC string consumes
409
+ * the report text after it and continues into the next violation's line. The
410
+ * `Built` cell is echoed on the `bad_built_status` path and has passed no
411
+ * grammar at that point, so it is exactly this surface. Identical to the
412
+ * research-track and screens constants, deliberately — the three chokepoints
413
+ * defend the same fd-2 surface and must not drift apart.
414
+ */
415
+ const CONTROL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
416
+ /**
417
+ * The single echo chokepoint. Strip the control planes, collapse whitespace,
418
+ * trim, then cap at `ECHO_MAX_CHARS`.
419
+ *
420
+ * Both the strip and the collapse run BEFORE the slice, and together they
421
+ * guarantee a cell can never inject a newline, carriage return, tab or terminal
422
+ * escape sequence into the one-line-per-violation report format, regardless of
423
+ * where the slice happens to land.
424
+ *
425
+ * A confidentiality control, not a formatting preference (D-24): matrix prose is
426
+ * written out of client documents, so an unbounded echo is a disclosure.
427
+ */
428
+ function sanitizeEcho(value) {
429
+ // A non-string is not author data this function can meaningfully bound, and
430
+ // default stringification would emit `[object Object]` — so it renders as
431
+ // nothing rather than as a placeholder that looks like content.
432
+ if (typeof value !== 'string')
433
+ return '';
434
+ const collapsed = value.replace(CONTROL_CHARS_RE, '').replace(/\s+/g, ' ').trim();
435
+ if (collapsed.length <= ECHO_MAX_CHARS)
436
+ return collapsed;
437
+ return `${collapsed.slice(0, ECHO_MAX_CHARS)}…`;
438
+ }
439
+ /**
440
+ * Split a comma-separated in-cell list into non-empty trimmed tokens. Lists
441
+ * WITHIN one cell are comma-separated across this whole surface; the ` · `
442
+ * middle dot stays reserved for compound-field separation (D-27/D-37/D-45). A
443
+ * second spelling of either would be a join that silently matches nothing.
444
+ */
445
+ function splitList(value) {
446
+ return value.split(',').map(t => t.trim()).filter(t => t !== '');
447
+ }
448
+ /**
449
+ * The content-free locator every fault raised INSIDE a round section is built
450
+ * from: the artifact, the 1-based source row, and the round number — but only
451
+ * when there is a round number that passed the grammar.
452
+ *
453
+ * The single place the round field is attached, so an anonymous section
454
+ * (WR-05) cannot leak a number into a report by one call site forgetting. The
455
+ * field is OMITTED rather than set to a sentinel: a sentinel is a value that
456
+ * can travel, and the content-free contract (T-05-01) turns on a violation
457
+ * never carrying anything that passed no validation.
458
+ */
459
+ function roundLocator(round, line) {
460
+ if (round === null)
461
+ return { artifact: REBASELINE_FILE, line };
462
+ return { artifact: REBASELINE_FILE, line, round };
463
+ }
464
+ /**
465
+ * Open a round for a section whose heading claimed the family and failed it
466
+ * (WR-05), located at that heading's row.
467
+ *
468
+ * `date` and `label` are empty strings rather than anything taken from the
469
+ * line: the heading matched no grammar, so it has no date and no label — it has
470
+ * only a row number. The caller is responsible for the three exclusions the
471
+ * `AnonymousRound` docblock states; this factory deliberately does NOT touch
472
+ * the rounds map or the sequence list, so neither can happen by accident here.
473
+ */
474
+ function openAnonymousRound(line) {
475
+ return {
476
+ round: null,
477
+ anonymous: true,
478
+ line,
479
+ date: '',
480
+ label: '',
481
+ accession: null,
482
+ denominator: null,
483
+ denominatorLine: null,
484
+ denominatorClaimLine: null,
485
+ metadataFaultReported: false,
486
+ rows: [],
487
+ };
488
+ }
489
+ /** True when a split row is the fixed six-column header. */
490
+ function isHeaderRow(cells) {
491
+ return (cells.length === MATRIX_HEADER_CELLS.length &&
492
+ MATRIX_HEADER_CELLS.every((name, i) => cells[i] === name));
493
+ }
494
+ // ─── Sequence ─────────────────────────────────────────────────────────────────
495
+ /**
496
+ * Report the round numbers missing from a contiguous `1..max` run (D-41/D-66).
497
+ * The locator names the MISSING number, so a deleted round cannot lapse
498
+ * unnoticed — a hole is information, it says a round was dropped.
499
+ *
500
+ * The maximum folds through `boundedSequenceMax` from the shared leaf and NEVER
501
+ * through a spread over the parsed numbers. Two hazards close there in one
502
+ * place: the spread throws `RangeError` on a large array, and an unbounded
503
+ * maximum turns
504
+ * one mistyped heading into one allocated violation per missing number and a
505
+ * heap abort that no try/catch can convert into a structured result. Past the
506
+ * bound this reports a SINGLE `sequence_gap` rather than enumerating.
507
+ *
508
+ * EXPORTED so the bounded-span floor can be falsified through THIS engine and
509
+ * not only through the shared leaf. `ROUND_HEADING_RE` is two digits, so the
510
+ * out-of-band arm is unreachable end to end and a test can only reach it by
511
+ * calling this validator with the numbers directly.
512
+ *
513
+ * The below-1 arm is an INDEPENDENT floor, not the primary guard: the round
514
+ * heading now rejects a number below 1 at the grammar, so nothing this parser
515
+ * produces can reach here out of range (WR-04). It is kept for the same two
516
+ * reasons the out-of-band arm above it is kept — the grammar is one edit away
517
+ * from being widened again, and the tests call this validator directly, so an
518
+ * exported validator that returned a CLEAN list for `[0, 1]` would be dishonest
519
+ * about a rule the family states in three places.
520
+ *
521
+ * The floor lives HERE and not in `boundedSequenceMax`. That leaf is shared with
522
+ * two sibling engines, it computes a MAXIMUM, and a family's low bound is not
523
+ * its business: widening it for one caller would reintroduce exactly the drift
524
+ * its extraction closed (ADR/04-PATTERNS, AR-04-02).
525
+ *
526
+ * Noted rather than silently changed: the out-of-band arm below returns
527
+ * `sequence_gap` where this engine's three siblings return `bad_id` for the same
528
+ * shape. That divergence is real and is recorded as an observation for plan
529
+ * 05-12; it is NOT changed here, because the case is unreachable through the
530
+ * grammar and moving its code would move a documented parser-contract row for a
531
+ * condition no artifact can produce.
532
+ */
533
+ function validateRoundSequence(numbers) {
534
+ const violations = [];
535
+ if (numbers.length === 0)
536
+ return violations;
537
+ if (numbers.some(n => n < 1)) {
538
+ return [{ artifact: REBASELINE_FILE, reason: 'bad_id' }];
539
+ }
540
+ const seen = new Set(numbers);
541
+ const max = boundedSequenceMax(numbers);
542
+ if (max === null) {
543
+ return [{ artifact: REBASELINE_FILE, reason: 'sequence_gap' }];
544
+ }
545
+ for (let n = 1; n <= max; n += 1) {
546
+ if (!seen.has(n)) {
547
+ violations.push({ artifact: REBASELINE_FILE, round: n, reason: 'sequence_gap' });
548
+ }
549
+ }
550
+ return violations;
551
+ }
552
+ // ─── Parsing ──────────────────────────────────────────────────────────────────
553
+ /**
554
+ * Parse `_rebaseline.md` into its rounds, its rows and the faults found WITHIN
555
+ * the artifact. Never throws; a non-string input yields an empty result.
556
+ *
557
+ * Lines are split on a carriage-return-optional newline so a Windows checkout
558
+ * parses identically. Fenced blocks are tracked and their contents skipped, so
559
+ * the bootstrap template pasted into the artifact — or any other fenced
560
+ * example — cannot be parsed as data. The contract that follows from those two
561
+ * halves in one sentence: a BALANCED fence is still data and its contents are
562
+ * still skipped, while an UNBALANCED fence is a fault, so the remainder it
563
+ * swallowed can never be skipped in silence.
564
+ *
565
+ * A FAULTED ROUND HEADING is reported AND its rows are still checked (WR-05),
566
+ * so an author sees every fault in the file on the first run rather than
567
+ * discovering a second wave after fixing the heading. A duplicated heading
568
+ * re-opens the round already registered under that number; a malformed or
569
+ * out-of-range one opens an anonymous round, which reaches the row walk and
570
+ * nothing else. Both arms used to leave no round open, so every line until the
571
+ * next heading fell through the `open === null` guard and the whole section's
572
+ * cells escaped every per-row check — the two-pass experience the
573
+ * collect-all-then-fail design (D-08) exists to avoid.
574
+ *
575
+ * `rounds` is a `Map` keyed by the validated integer round number and the
576
+ * per-round phase index is a `Set` (T-05-06 discipline: derived state is never
577
+ * keyed into a plain object literal, so a crafted key cannot reach it).
578
+ *
579
+ * Cross-artifact resolution — the resolves-to-nothing arm of
580
+ * `unknown_requirement_id` and the whole of `missing_phase_row` — is NOT done
581
+ * here. This function is handed a string and reads no filesystem, which is what
582
+ * lets every within-artifact fault be provoked by an in-memory fixture.
583
+ */
584
+ function parseRebaselineMatrix(content) {
585
+ const rounds = new Map();
586
+ const rows = [];
587
+ const violations = [];
588
+ if (typeof content !== 'string')
589
+ return { rounds, rows, violations };
590
+ const lines = content.split(/\r?\n/);
591
+ const seqs = [];
592
+ let open = null;
593
+ let seenPhases = new Set();
594
+ let sawRoundClaim = false;
595
+ let inFence = false;
596
+ // The 1-based row of the fence that OPENED the block currently being skipped,
597
+ // held so an unbalanced fence can be located at the line that opened it. The
598
+ // missing closer has no row of its own to name, and the opening line is the
599
+ // one the author has to go and look at.
600
+ let fenceOpenLine = null;
601
+ // A round that reaches its end — the next round heading, or end of file —
602
+ // without a denominator line is the D-76 fault, located at its HEADING row
603
+ // because the missing line has no row of its own.
604
+ //
605
+ // A round whose bullet CLAIMED the family and failed its grammar is exempt: it
606
+ // already carries a `bad_id` naming the condition it actually violated, and
607
+ // adding `missing_round_metadata` on top would restate one malformed line as
608
+ // two faults, the second of them the misreport WR-03 exists to remove. A round
609
+ // whose bullet PARSED but recorded no usable token is not exempt — that round
610
+ // genuinely records no denominator (WR-01).
611
+ //
612
+ // An ANONYMOUS round is exempt too, and for the same shape of reason (WR-05):
613
+ // its heading already faulted, so a second derived fault about a section
614
+ // nobody can name would have to be located by row alone and would say nothing
615
+ // the `bad_id` above it does not — noise the author cannot act on.
616
+ //
617
+ // `metadataFaultReported` is what keeps this one fault per ROUND rather than
618
+ // one per SECTION. A round whose number heads two sections is re-opened for
619
+ // the second one (WR-05), so this close runs twice over the same round object;
620
+ // the count of sections is not the count of faults.
621
+ const closeRound = () => {
622
+ if (open !== null &&
623
+ !open.anonymous &&
624
+ !open.metadataFaultReported &&
625
+ open.denominator === null &&
626
+ open.denominatorClaimLine === null) {
627
+ violations.push({
628
+ artifact: REBASELINE_FILE,
629
+ line: open.line,
630
+ round: open.round,
631
+ reason: 'missing_round_metadata',
632
+ });
633
+ open.metadataFaultReported = true;
634
+ }
635
+ };
636
+ for (let i = 0; i < lines.length; i += 1) {
637
+ const raw = lines[i];
638
+ const line = raw.trim();
639
+ const num = i + 1;
640
+ if (FENCE_RE.test(line)) {
641
+ inFence = !inFence;
642
+ fenceOpenLine = inFence ? num : null;
643
+ continue;
644
+ }
645
+ // The skip stays EXACTLY as it was. A round heading is not tested ahead of
646
+ // it, deliberately: a heading inside a BALANCED fence is the pasted
647
+ // bootstrap template the module header promises to tolerate, and parsing it
648
+ // unconditionally would fault that documented shape with `duplicate_id` —
649
+ // trading a fail-open for a false positive. The unbalanced fence is
650
+ // reported after the loop instead, so the silent-pass path closes without
651
+ // the balanced shape becoming a fault.
652
+ if (inFence)
653
+ continue;
654
+ if (ROUND_CLAIM_RE.test(line)) {
655
+ sawRoundClaim = true;
656
+ closeRound();
657
+ open = null;
658
+ seenPhases = new Set();
659
+ const match = ROUND_HEADING_RE.exec(line);
660
+ if (match === null) {
661
+ // Located by row number ONLY, carrying no echo: a heading that failed
662
+ // its grammar has passed no validation, so nothing about it is safe to
663
+ // quote (T-05-01).
664
+ violations.push({ artifact: REBASELINE_FILE, line: num, reason: 'bad_id' });
665
+ // …and the section beneath it is STILL parsed, under an anonymous round
666
+ // (WR-05). The heading fault used to cost the gate every cell below it.
667
+ // `seenPhases` was reset above and stays reset: this is a distinct
668
+ // section, not the second half of an existing round.
669
+ open = openAnonymousRound(num);
670
+ continue;
671
+ }
672
+ const round = Number(match[1]);
673
+ // The three heading outcomes read as one group: malformed above, OUT OF
674
+ // RANGE here, duplicate below.
675
+ //
676
+ // Below 1 is the SAME condition the high end already rejects through the
677
+ // two-digit capture — a round number outside the family's range — so it is
678
+ // rejected in the same place, at the grammar. `ROUND_HEADING_RE` captures
679
+ // one or two digits and therefore matches `0` and `00`; before WR-04 a
680
+ // lone `## Round 0` parsed successfully, yielded a maximum of zero from
681
+ // `boundedSequenceMax` (which seeds at 0), ran the contiguity loop zero
682
+ // times and reported nothing — while the module header, the reference and
683
+ // the bootstrap template's own preamble all promise "contiguous from 1".
684
+ // It also left `latest_round` as the falsy 0, which a downstream
685
+ // `if (payload.latest_round)` reads as "no rounds" for an artifact with
686
+ // one. Carrying no echo: the heading did not pass its grammar (T-05-01).
687
+ if (round < 1) {
688
+ violations.push({ artifact: REBASELINE_FILE, line: num, reason: 'bad_id' });
689
+ // Anonymous for the same reason the malformed arm is (WR-05): the number
690
+ // failed a range rule, so there is no validated number to name, but the
691
+ // rows below are still the author's audit trail and are still checked.
692
+ open = openAnonymousRound(num);
693
+ continue;
694
+ }
695
+ const registered = rounds.get(round);
696
+ if (registered !== undefined) {
697
+ // The round number has passed the grammar, so naming it is safe — and
698
+ // naming it is the only way an author finds the second section.
699
+ violations.push({
700
+ artifact: REBASELINE_FILE,
701
+ line: num,
702
+ round,
703
+ reason: 'duplicate_id',
704
+ });
705
+ // RE-OPEN the round already registered under that number rather than
706
+ // leaving none open (WR-05), so the second section's rows land on the
707
+ // round they CLAIM and are cell-checked normally. Its denominator bullet
708
+ // then reaches the WR-02 duplicate guard as well, which is correct: the
709
+ // round really does carry two denominator bullets.
710
+ open = registered;
711
+ // Rebuilt from the rows already collected, NOT started empty. A phase
712
+ // dispositioned in both halves of a duplicated round IS dispositioned
713
+ // twice inside one round, and a fresh index would hide exactly that —
714
+ // which is the shortcut that would keep this fixture's old violation
715
+ // count and lose the finding.
716
+ seenPhases = new Set(registered.rows.map(r => r.phase).filter(p => p !== ''));
717
+ continue;
718
+ }
719
+ const opened = {
720
+ round,
721
+ anonymous: false,
722
+ line: num,
723
+ date: match[2].trim(),
724
+ label: match[3].trim(),
725
+ accession: null,
726
+ denominator: null,
727
+ denominatorLine: null,
728
+ denominatorClaimLine: null,
729
+ metadataFaultReported: false,
730
+ rows: [],
731
+ };
732
+ open = opened;
733
+ rounds.set(round, opened);
734
+ seqs.push(round);
735
+ continue;
736
+ }
737
+ if (open === null)
738
+ continue;
739
+ // The claim arm comes FIRST, and it continues, so a bullet that claims the
740
+ // denominator family can no longer fall through the accession check, the
741
+ // separator check and the leading-pipe check to be dropped without comment
742
+ // (WR-03). Same shape as the round heading's `ROUND_CLAIM_RE` arm above.
743
+ if (DENOMINATOR_CLAIM_RE.test(line)) {
744
+ const denominator = DENOMINATOR_LINE_RE.exec(line);
745
+ if (denominator === null) {
746
+ // Located by row number ONLY, carrying no echo: a line that claimed a
747
+ // family and failed its grammar has passed no validation, so nothing
748
+ // about it is safe to quote (T-05-01). The round number IS safe when
749
+ // there is one — it passed the heading grammar — and it is how an author
750
+ // finds the round; under an anonymous section (WR-05) there is none, and
751
+ // `roundLocator` omits the field rather than inventing one.
752
+ violations.push({ ...roundLocator(open.round, num), reason: 'bad_id' });
753
+ open.denominatorClaimLine = num;
754
+ continue;
755
+ }
756
+ // A REPEATED bullet is the same duplication the round heading above and
757
+ // the phase cell below already report, and it gets the same policy: the
758
+ // first record keeps the slot and the later one is reported rather than
759
+ // dropped in silence, because it may contradict what is already recorded.
760
+ //
761
+ // The FIRST bullet wins rather than the last, and that direction is the
762
+ // whole point. A reader scanning the artifact sees the first line under
763
+ // the heading and takes it as the denominator; a last-write-wins parser
764
+ // would judge coverage against a line the reader never read. Before WR-02
765
+ // every match overwrote unconditionally, so `- Phases at this round: 02,
766
+ // 03, 04` followed by `- Phases at this round: 02` narrowed the coverage
767
+ // denominator from three phases to one and raised NOTHING anywhere — the
768
+ // fully-silent instance of the no-op this engine exists to close. D-73
769
+ // accepts a hand-edited denominator understating the phase list as a
770
+ // cost, but the accepted cost is ONE line an operator can read and check;
771
+ // this keeps it there rather than extending it to a second line that
772
+ // overrides the first while the first still sits above it.
773
+ //
774
+ // Keyed on "has RECORDED a denominator", not on "has SEEN a bullet". A
775
+ // bullet that already faulted and recorded nothing — the WR-01 all-invalid
776
+ // case, or the WR-03 claimed-and-malformed case — has reported its own
777
+ // condition at its own row; turning the round's only usable bullet into a
778
+ // second fault on top of that would be the derived misreport WR-03
779
+ // removed. Located through `roundLocator`: a named round's number came out
780
+ // of the heading grammar so naming it is safe and is how an author finds
781
+ // the round, while an anonymous section (WR-05) carries no number at all.
782
+ if (open.denominatorLine !== null) {
783
+ violations.push({ ...roundLocator(open.round, num), reason: 'duplicate_id' });
784
+ continue;
785
+ }
786
+ const tokens = splitList(denominator[1]);
787
+ const valid = [];
788
+ for (const token of tokens) {
789
+ if (PHASE_TOKEN_RE.test(token))
790
+ valid.push(token);
791
+ else {
792
+ violations.push({
793
+ ...roundLocator(open.round, num),
794
+ reason: 'bad_phase_token',
795
+ field: 'phase',
796
+ });
797
+ }
798
+ }
799
+ // A bullet that records no USABLE token records no denominator at all, so
800
+ // it stays the D-76 fault rather than becoming a coverage rule that
801
+ // trivially passes — that would be exactly the fail-open the code exists
802
+ // to close. The test is `valid`, not `tokens` (WR-01): keying it on the
803
+ // SPLIT count let an all-invalid bullet leave `denominator === []`, which
804
+ // is non-null, which is the only thing `enforcePhaseCoverage` tests — so
805
+ // the round reported `bad_phase_token`, skipped `missing_round_metadata`,
806
+ // and handed the coverage rule an empty list to iterate.
807
+ //
808
+ // The PARTIAL case is deliberately unchanged: a bullet holding one good
809
+ // token and one bad one still records the good one and still faults the
810
+ // bad one. Refusing the whole bullet there would trade a fail-open for a
811
+ // second, wrong fault on every partially-mistyped round.
812
+ if (valid.length > 0) {
813
+ open.denominator = valid;
814
+ open.denominatorLine = num;
815
+ }
816
+ continue;
817
+ }
818
+ const accession = ACCESSION_LINE_RE.exec(line);
819
+ if (accession !== null) {
820
+ open.accession = accession[1].trim();
821
+ continue;
822
+ }
823
+ if (SEPARATOR_ROW_RE.test(raw))
824
+ continue;
825
+ if (!line.startsWith('|'))
826
+ continue;
827
+ const cells = splitTableCells(raw);
828
+ if (isHeaderRow(cells))
829
+ continue;
830
+ if (cells.length !== MATRIX_HEADER_CELLS.length) {
831
+ // A pipe line inside a round that is neither header nor separator CLAIMS
832
+ // the matrix family without matching it. Reported, never skipped past.
833
+ violations.push({ ...roundLocator(open.round, num), reason: 'bad_id' });
834
+ continue;
835
+ }
836
+ const at = roundLocator(open.round, num);
837
+ const phaseCell = cells[MATRIX_COL.PHASE];
838
+ let phase = '';
839
+ if (!PHASE_TOKEN_RE.test(phaseCell)) {
840
+ violations.push({ ...at, reason: 'bad_phase_token', field: 'phase' });
841
+ }
842
+ else if (seenPhases.has(phaseCell)) {
843
+ // Echoing the phase is safe: it has passed its grammar. The FIRST row
844
+ // keeps the slot and this one is reported rather than dropped in silence,
845
+ // because its disposition may contradict the one already recorded.
846
+ violations.push({ ...at, phase: sanitizeEcho(phaseCell), reason: 'duplicate_id' });
847
+ phase = phaseCell;
848
+ }
849
+ else {
850
+ seenPhases.add(phaseCell);
851
+ phase = phaseCell;
852
+ }
853
+ const built = cells[MATRIX_COL.BUILT];
854
+ if (!BUILT_VALUES.includes(built)) {
855
+ violations.push({
856
+ ...at,
857
+ reason: 'bad_built_status',
858
+ field: 'built',
859
+ cell: sanitizeEcho(built),
860
+ });
861
+ }
862
+ // D-77a: provenance for the built unit, carrying MILESTONE requirement IDs
863
+ // from a different ID plane. Stored and never inspected again — it matches
864
+ // the same ID shape as the cell beside it, which is exactly why the two are
865
+ // separate columns rather than one.
866
+ const roadmapRequirements = cells[MATRIX_COL.ROADMAP_REQUIREMENTS];
867
+ const discoveryCell = cells[MATRIX_COL.DISCOVERY_REQUIREMENTS];
868
+ const discoveryRequirements = [];
869
+ if (discoveryCell !== NOT_APPLICABLE) {
870
+ const tokens = splitList(discoveryCell);
871
+ if (tokens.length === 0) {
872
+ // An empty cell is not in the grammar: "one or more IDs, or the em
873
+ // dash". No echo — an empty token has passed nothing.
874
+ violations.push({ ...at, reason: 'unknown_requirement_id', field: 'discovery_requirements' });
875
+ }
876
+ for (const token of tokens) {
877
+ if (REQUIREMENT_ID_RE.test(token))
878
+ discoveryRequirements.push(token);
879
+ // Wrong shape and resolves-to-nothing are the SAME code — both mean
880
+ // "this is not a requirement I can find" — but the wrong-shape arm
881
+ // carries no echo, because the token passed no grammar.
882
+ else
883
+ violations.push({ ...at, reason: 'unknown_requirement_id', field: 'discovery_requirements' });
884
+ }
885
+ }
886
+ const clausesCell = cells[MATRIX_COL.CLAUSES];
887
+ const clauses = [];
888
+ if (clausesCell !== NOT_APPLICABLE) {
889
+ const tokens = splitList(clausesCell);
890
+ if (tokens.length === 0) {
891
+ // The em dash is how "no clause bearing on this phase" is spelled, and
892
+ // it is exactly the `lapsed` shape stated in the cell. A blank cell is
893
+ // silence, which is the thing this artifact exists to remove.
894
+ violations.push({ ...at, reason: 'bad_clause_citation', field: 'clauses' });
895
+ }
896
+ for (const token of tokens) {
897
+ if (CLAUSE_CITE_RE.test(token))
898
+ clauses.push(token);
899
+ else
900
+ violations.push({ ...at, reason: 'bad_clause_citation', field: 'clauses' });
901
+ }
902
+ }
903
+ const dispositionCell = cells[MATRIX_COL.DISPOSITION];
904
+ const dm = DISPOSITION_CELL_RE.exec(dispositionCell);
905
+ let disposition = '';
906
+ let dispositionReason = null;
907
+ if (dm === null) {
908
+ violations.push({ ...at, reason: 'bad_disposition', field: 'disposition' });
909
+ }
910
+ else {
911
+ disposition = dm[1];
912
+ dispositionReason = dm[2] === undefined ? null : dm[2];
913
+ if (!SCOPE_VALUES.includes(disposition)) {
914
+ violations.push({
915
+ ...at,
916
+ reason: 'bad_disposition',
917
+ field: 'disposition',
918
+ cell: sanitizeEcho(disposition),
919
+ });
920
+ }
921
+ else if (disposition !== SCOPE_STATES.ACTIVE) {
922
+ if (dispositionReason === null || dispositionReason.trim() === '') {
923
+ // The mechanism that stops an agent proposing a lapse without
924
+ // arguing it (D-71). `disposition` is a validated enum value here.
925
+ violations.push({
926
+ ...at,
927
+ reason: 'disposition_without_reason',
928
+ field: 'disposition',
929
+ cell: disposition,
930
+ });
931
+ }
932
+ else if (disposition === SCOPE_STATES.VALIDATE &&
933
+ !CLAUSE_CITE_RE.test(dispositionReason.trim())) {
934
+ // A `validate` must name the clause that changed the expected
935
+ // behaviour; prose there is a citation fault, not a missing reason.
936
+ violations.push({ ...at, reason: 'bad_clause_citation', field: 'disposition' });
937
+ }
938
+ }
939
+ }
940
+ // Every row is kept, including one whose phase failed: dropping it here
941
+ // would make a second fault on the same row invisible to a later pass.
942
+ const row = {
943
+ round: open.round,
944
+ line: num,
945
+ phase,
946
+ built,
947
+ roadmapRequirements,
948
+ discoveryRequirements,
949
+ clauses,
950
+ disposition,
951
+ dispositionReason,
952
+ };
953
+ open.rows.push(row);
954
+ rows.push(row);
955
+ }
956
+ closeRound();
957
+ // A fence that opened and never closed swallowed every line after it. That is
958
+ // the largest blast radius any structural fault in this artifact has: the
959
+ // remaining round headings, metadata bullets and matrix rows are all skipped,
960
+ // and because an earlier round already set `sawRoundClaim` and the swallowed
961
+ // rounds never reach `seqs`, neither `missing_section` nor `sequence_gap`
962
+ // fires either — the parse returns clean and the gate exits 0 having verified
963
+ // nothing. Reported at the OPENING row, carrying no cell and no round: the
964
+ // fence belongs to the artifact rather than to a section, and nothing inside a
965
+ // block the parser could not delimit has passed any grammar.
966
+ if (inFence) {
967
+ violations.push({
968
+ artifact: REBASELINE_FILE,
969
+ line: fenceOpenLine ?? undefined,
970
+ reason: 'unterminated_fence',
971
+ });
972
+ }
973
+ // A file that exists with no round at all is a bootstrap that never got its
974
+ // rows, not a project with nothing to disposition. Keyed off whether a round
975
+ // was ever CLAIMED, so a malformed heading reports its own `bad_id` without
976
+ // also collecting a spurious `missing_section`.
977
+ //
978
+ // The distinction is THREE-way and not two-way, and each arm is a different
979
+ // statement about what the gate actually verified:
980
+ // - ABSENT is the D-42/D-66 graceful arm and is silent. Most engagements
981
+ // never re-baseline, and reddening on the absence would turn a variation
982
+ // nobody triggered into a red gate for everybody.
983
+ // - PRESENT-AND-EMPTY is `missing_section`. A file that exists with no round
984
+ // at all is a bootstrap that never got its rows, and a zero-byte or
985
+ // whitespace-only file is the purest instance of that description.
986
+ // - PRESENT-AND-UNREADABLE is `artifact_unreadable`, raised by the
987
+ // aggregator, and the parser's non-string early return keeps it standing
988
+ // alone with no derived cascade.
989
+ //
990
+ // There is deliberately NO emptiness exemption here. The `content.trim() !==
991
+ // ''` clause this replaces collapsed the middle arm into the first, and it did
992
+ // so while the aggregator was pushing the file into `artifacts_checked` — so
993
+ // the report claimed the artifact had been checked when nothing in it had
994
+ // been. That is exactly the second, quieter overstatement the unreadable arm
995
+ // one branch over was written to avoid.
996
+ if (!sawRoundClaim) {
997
+ violations.push({ artifact: REBASELINE_FILE, reason: 'missing_section' });
998
+ }
999
+ violations.push(...validateRoundSequence(seqs));
1000
+ return { rounds, rows, violations };
1001
+ }
1002
+ // ─── Cross-artifact resolution ────────────────────────────────────────────────
1003
+ /**
1004
+ * Resolve the cited discovery requirement IDs against the set `_requirements.md`
1005
+ * actually mints (D-77b).
1006
+ *
1007
+ * `knownIds === null` means resolution is SKIPPED — `_requirements.md` is absent
1008
+ * or the owning gate cannot parse it — and skipping returns an empty list rather
1009
+ * than a fault. A citation then passes on shape alone, which is the same
1010
+ * graceful arm the sibling gates take: a project that has not minted a
1011
+ * requirement set yet must not be reddened for citing into one, and a gate that
1012
+ * is already red elsewhere must not be reported twice here.
1013
+ *
1014
+ * ONLY the discovery-requirement column is resolved. The roadmap-provenance cell
1015
+ * beside it (D-77a) holds MILESTONE IDs from a different ID plane and matches the
1016
+ * same `[A-Z][A-Z0-9]*-\d+` shape, so resolving it would fault every
1017
+ * roadmap-pre-filled row with a false `unknown_requirement_id` — which is
1018
+ * precisely why the two planes are two columns rather than one.
1019
+ *
1020
+ * `row.discoveryRequirements` holds only tokens that already passed
1021
+ * `REQUIREMENT_ID_RE`; the wrong-SHAPE arm of this same code was raised by the
1022
+ * parser and carries no echo. Here the ID is safe to name precisely because it
1023
+ * passed its family regex — and naming it is the only way an author finds the
1024
+ * citation among six columns.
1025
+ */
1026
+ function resolveRequirements(rows, knownIds) {
1027
+ const violations = [];
1028
+ if (knownIds === null)
1029
+ return violations;
1030
+ for (const row of rows) {
1031
+ for (const id of row.discoveryRequirements) {
1032
+ if (!knownIds.has(id)) {
1033
+ // Through the same locator the parser uses, so a row that survived a
1034
+ // faulted heading (WR-05) reaches this rule — it is still a citation
1035
+ // somebody wrote — without the report inventing a round for it.
1036
+ violations.push({
1037
+ ...roundLocator(row.round, row.line),
1038
+ reason: 'unknown_requirement_id',
1039
+ field: 'discovery_requirements',
1040
+ id: sanitizeEcho(id),
1041
+ });
1042
+ }
1043
+ }
1044
+ }
1045
+ return violations;
1046
+ }
1047
+ /**
1048
+ * Every phase the LATEST round recorded in its own denominator must have a row
1049
+ * in that round (D-76). A phase with no row is functionality nobody
1050
+ * dispositioned, and silence is the thing this artifact exists to remove.
1051
+ *
1052
+ * THE DENOMINATOR COMES FROM THE ROUND'S OWN RECORDED METADATA. This engine
1053
+ * reads no roadmap — the rebaseline WORKFLOW enumerates the roadmap and writes
1054
+ * the phase list as it stood when the round was taken into the round's metadata
1055
+ * bullet. That is what keeps this engine's dependency footprint identical to its
1056
+ * four siblings (`.planning/discovery/` and nothing else) and what dissolves the
1057
+ * asymmetry that `.planning/discovery/` is hard-coded non-workstream-scoped while
1058
+ * a roadmap path would follow `--ws`. An engine that never opens a roadmap never
1059
+ * has to reconcile the two.
1060
+ *
1061
+ * The LATEST round only, and that is settled rather than pending (D-76): rounds
1062
+ * are independent snapshots, a later round may reverse an earlier verdict, and
1063
+ * there is no monotonicity rule. Re-evaluating an earlier round would make a
1064
+ * roadmap that grew between rounds retroactively redden a round that was closed
1065
+ * correctly at the time it was taken.
1066
+ *
1067
+ * `denominator` is non-null iff at least one token was recorded — an empty
1068
+ * bullet, and a bullet whose every token fails the phase grammar, both leave it
1069
+ * null and still fire `missing_round_metadata` — so there is no empty-list
1070
+ * branch here, and no path by which a hand-edited bullet silently turns this
1071
+ * rule into a no-op.
1072
+ *
1073
+ * That invariant is true BECAUSE of the `valid.length > 0` guard on the
1074
+ * denominator arm of `parseRebaselineMatrix`, not by luck. It was false until
1075
+ * WR-01: the arm keyed on the SPLIT token count, so an all-invalid bullet
1076
+ * recorded `[]` — non-null, and non-null is the only thing the guard below
1077
+ * tests. Anything that widens that arm back to "any token at all" breaks the
1078
+ * contract this comment states, and is a change to the coverage rule rather
1079
+ * than a change to the parser.
1080
+ *
1081
+ * The denominator tokens and the `Phase` cells
1082
+ * share ONE grammar, so coverage is a plain set difference with no normalisation
1083
+ * step to drift.
1084
+ */
1085
+ function enforcePhaseCoverage(rounds) {
1086
+ const violations = [];
1087
+ let latest = null;
1088
+ for (const round of rounds.values()) {
1089
+ if (latest === null || round.round > latest.round)
1090
+ latest = round;
1091
+ }
1092
+ if (latest === null || latest.denominator === null)
1093
+ return violations;
1094
+ // A `Set`, never an object literal keyed on parsed content (T-05-06).
1095
+ const covered = new Set();
1096
+ for (const row of latest.rows) {
1097
+ if (row.phase !== '')
1098
+ covered.add(row.phase);
1099
+ }
1100
+ for (const token of latest.denominator) {
1101
+ if (covered.has(token))
1102
+ continue;
1103
+ violations.push({
1104
+ artifact: REBASELINE_FILE,
1105
+ line: latest.denominatorLine ?? latest.line,
1106
+ round: latest.round,
1107
+ reason: 'missing_phase_row',
1108
+ phase: sanitizeEcho(token),
1109
+ });
1110
+ }
1111
+ return violations;
1112
+ }
1113
+ // ─── Aggregation ──────────────────────────────────────────────────────────────
1114
+ /**
1115
+ * Aggregate the full re-baselining check for a project root. Collect-all-then-
1116
+ * fail (D-08 carried forward): every fault class runs, all violations are
1117
+ * accumulated, and the result is returned once. This layer NEVER throws.
1118
+ *
1119
+ * Pure aggregation: the parser's within-artifact violations, then the
1120
+ * requirement resolution, then the phase coverage. It adds no rule of its own,
1121
+ * so every fault this gate can report is owned by exactly one function.
1122
+ */
1123
+ function checkRebaseline(cwd) {
1124
+ const violations = [];
1125
+ const dir = node_path_1.default.join(cwd, DISCOVERY_DIR);
1126
+ let names = [];
1127
+ try {
1128
+ names = node_fs_1.default.readdirSync(dir);
1129
+ }
1130
+ catch {
1131
+ names = []; // no discovery package at all — graceful arm (D-42)
1132
+ }
1133
+ const present = new Set(names);
1134
+ // The `present.has(file)` early return is what discriminates the two
1135
+ // conditions, and it must stay that way: ABSENT is the D-42/D-66 graceful arm
1136
+ // and is silent, while PRESENT-BUT-UNREADABLE (EACCES, EISDIR, a dangling
1137
+ // symlink) is a fault. Re-collapsing them would let the gate exit 0 having
1138
+ // validated nothing at all — a fail-open on a structural verifier.
1139
+ // The caller does not add the file to `artifactsChecked` either: an unreadable
1140
+ // artifact was not checked, and saying it was would be a second, quieter
1141
+ // overstatement.
1142
+ const readArtifact = (file) => {
1143
+ if (!present.has(file))
1144
+ return null;
1145
+ try {
1146
+ return node_fs_1.default.readFileSync(node_path_1.default.join(dir, file), 'utf-8');
1147
+ }
1148
+ catch {
1149
+ violations.push({ artifact: file, reason: 'artifact_unreadable' });
1150
+ return null;
1151
+ }
1152
+ };
1153
+ const artifactsChecked = [];
1154
+ const rebaselineContent = readArtifact(REBASELINE_FILE);
1155
+ // Absent — and ONLY absent, which is why the unreadable fault is tested for
1156
+ // rather than assumed away. A project that has never re-baselined is wholly
1157
+ // unaffected by this gate: most engagements never re-baseline, and a check
1158
+ // that reddened on the absence would turn a variation nobody triggered into a
1159
+ // red gate for everybody (D-42/D-66).
1160
+ if (rebaselineContent === null && violations.length === 0) {
1161
+ return {
1162
+ ok: true,
1163
+ artifacts_checked: artifactsChecked,
1164
+ rounds: 0,
1165
+ latest_round: null,
1166
+ requirements: 'absent',
1167
+ };
1168
+ }
1169
+ if (rebaselineContent !== null)
1170
+ artifactsChecked.push(REBASELINE_FILE);
1171
+ // A null here is the unreadable arm; the parser guards non-string input and
1172
+ // yields an empty result, so the `artifact_unreadable` fault stands alone
1173
+ // rather than collecting a cascade of derived faults about a file nobody read.
1174
+ const parsed = parseRebaselineMatrix(rebaselineContent);
1175
+ violations.push(...parsed.violations);
1176
+ // `_requirements.md` is owned by `discovery rollup`; this gate consumes its
1177
+ // single exported reader and NOTHING else. A grammar fault there is reported
1178
+ // ONCE, by name, pointing at the owning gate — never re-enumerated here.
1179
+ let knownIds = null;
1180
+ let requirements = 'absent';
1181
+ const requirementsContent = readArtifact(REQUIREMENTS_FILE);
1182
+ if (requirementsContent !== null) {
1183
+ artifactsChecked.push(REQUIREMENTS_FILE);
1184
+ const parsedRequirements = rollup.parseRequirementsArtifact(requirementsContent);
1185
+ if (parsedRequirements.ok) {
1186
+ knownIds = new Set(parsedRequirements.rows.map(r => r.id));
1187
+ requirements = 'ok';
1188
+ }
1189
+ else {
1190
+ requirements = 'unparseable';
1191
+ violations.push({ artifact: REQUIREMENTS_FILE, reason: 'requirements_unparseable' });
1192
+ }
1193
+ }
1194
+ violations.push(...resolveRequirements(parsed.rows, knownIds));
1195
+ violations.push(...enforcePhaseCoverage(parsed.rounds));
1196
+ // Folded, never spread: `Math.max(...keys)` throws `RangeError` on a large
1197
+ // array, and the round map is keyed off artifact content.
1198
+ let latestRound = null;
1199
+ for (const round of parsed.rounds.keys()) {
1200
+ if (latestRound === null || round > latestRound)
1201
+ latestRound = round;
1202
+ }
1203
+ const summary = {
1204
+ artifacts_checked: artifactsChecked,
1205
+ rounds: parsed.rounds.size,
1206
+ latest_round: latestRound,
1207
+ requirements,
1208
+ };
1209
+ if (violations.length > 0)
1210
+ return { ok: false, violations, ...summary };
1211
+ return { ok: true, ...summary };
1212
+ }
1213
+ // ─── CLI adapter ──────────────────────────────────────────────────────────────
1214
+ /**
1215
+ * One content-free line per violation, two-space indented (T-05-01).
1216
+ *
1217
+ * This is the SINGLE chokepoint at which a detail string is BUILT: every
1218
+ * author-supplied value below routes through `sanitizeEcho`, so a reason code
1219
+ * added to the tuple inherits the confidentiality bound by construction rather
1220
+ * than by the author of that code remembering it. The reason code and the enum
1221
+ * field name are deliberately NOT sanitized — both come from closed unions
1222
+ * declared in this module and are not author data.
1223
+ *
1224
+ * Eight of the fifteen codes emit NO detail at all. That is deliberate and it
1225
+ * is the interesting half: the cell behind each of them has passed no
1226
+ * validation, so there is nothing about it that is safe to quote, and the
1227
+ * `<artifact>:<row>` locator finds the row instead. The `default` arm emits
1228
+ * nothing for the same reason — a new code must opt IN to an echo rather than
1229
+ * inherit an unbounded one by falling through.
1230
+ */
1231
+ function formatViolation(v) {
1232
+ const where = v.artifact ?? '';
1233
+ const locator = v.line === undefined ? where : `${where}:${v.line}`;
1234
+ let detail = '';
1235
+ switch (v.reason) {
1236
+ case 'unknown_requirement_id':
1237
+ // Populated only on the resolves-to-nothing arm; the wrong-shape arm
1238
+ // leaves it undefined and therefore renders as locator-plus-reason.
1239
+ detail = sanitizeEcho(v.id);
1240
+ break;
1241
+ case 'missing_phase_row':
1242
+ // Both halves have passed their grammars: the round number came out of
1243
+ // `ROUND_HEADING_RE` and the phase token out of `PHASE_TOKEN_RE`.
1244
+ detail = `round ${v.round}: ${sanitizeEcho(v.phase)}`;
1245
+ break;
1246
+ case 'duplicate_id':
1247
+ // A duplicated phase names the phase; a duplicated round heading names the
1248
+ // round. Both are validated tokens.
1249
+ detail = v.phase === undefined ? `round ${v.round}` : sanitizeEcho(v.phase);
1250
+ break;
1251
+ case 'sequence_gap':
1252
+ // The locator names the MISSING round number, so a deleted round cannot
1253
+ // lapse unnoticed (D-41/D-66). Past the shared bound there is no single
1254
+ // missing number to name and the detail is correctly empty.
1255
+ detail = v.round === undefined ? '' : `round ${v.round}`;
1256
+ break;
1257
+ case 'bad_disposition':
1258
+ case 'bad_built_status':
1259
+ // The bounded value token of a CLOSED-ENUM cell: the operator needs to see
1260
+ // which value was rejected, and the alternative — naming the column and
1261
+ // nothing else — makes a typo unfindable in a six-column row.
1262
+ detail = sanitizeEcho(v.cell);
1263
+ break;
1264
+ case 'requirements_unparseable':
1265
+ // Names the owning gate and stops. This gate never re-enumerates that
1266
+ // gate's errors — one artifact, one owner, one report.
1267
+ detail = 'run: discovery rollup --check';
1268
+ break;
1269
+ case 'bad_id':
1270
+ case 'bad_phase_token':
1271
+ case 'bad_clause_citation':
1272
+ case 'disposition_without_reason':
1273
+ case 'missing_round_metadata':
1274
+ case 'missing_section':
1275
+ case 'artifact_unreadable':
1276
+ // The fence line is a LOCATOR and nothing more. Nothing inside or around a
1277
+ // block the parser could not delimit has passed a grammar, so there is
1278
+ // nothing about it that is safe to echo (T-05-01) — and echoing the fence
1279
+ // line itself would quote the first line of a block whose whole content is
1280
+ // unvalidated author text.
1281
+ case 'unterminated_fence':
1282
+ detail = '';
1283
+ break;
1284
+ default:
1285
+ detail = '';
1286
+ }
1287
+ return detail === '' ? ` ${locator}: ${v.reason}` : ` ${locator}: ${v.reason} (${detail})`;
1288
+ }
1289
+ /**
1290
+ * CLI adapter for `pp-tools discovery rebaseline-check` (dotted form
1291
+ * `pp-tools query discovery.rebaseline-check`).
1292
+ *
1293
+ * Exit semantics (D-72/D-73): any structural violation exits non-zero via
1294
+ * `DISCOVERY_REBASELINE_CHECK_FAILED`, naming each violation by artifact, row
1295
+ * and reason. An ABSENT `_rebaseline.md` exits 0 — the graceful arm — while a
1296
+ * present-but-unreadable one is a fault.
1297
+ *
1298
+ * The command accepts NO positional path argument at all: the convention paths
1299
+ * under `.planning/discovery/` resolve against cwd, which ELIMINATES the
1300
+ * traversal surface rather than guarding it (T-05-14). There is no path to
1301
+ * normalise, no symlink to resolve and no allow-list to keep in step with the
1302
+ * filesystem, because there is no caller-supplied path.
1303
+ */
1304
+ function cmdDiscoveryRebaselineCheck(cwd, args, raw) {
1305
+ // args arrive shaped ['discovery', 'rebaseline-check', ...]; global output and
1306
+ // workstream flags are consumed by the dispatcher before dispatch, so anything
1307
+ // left — a positional path or an unknown flag alike — is a usage error.
1308
+ if (args.slice(2).length > 0) {
1309
+ error('Usage: pp-tools discovery rebaseline-check', REBASELINE_CHECK_USAGE);
1310
+ return; // unreachable — error() exits
1311
+ }
1312
+ const result = checkRebaseline(cwd);
1313
+ if (!result.ok) {
1314
+ const total = result.violations.length;
1315
+ const rendered = result.violations.slice(0, MAX_REPORTED_VIOLATIONS).map(formatViolation);
1316
+ if (total > MAX_REPORTED_VIOLATIONS) {
1317
+ rendered.push(` … and ${total - MAX_REPORTED_VIOLATIONS} more`);
1318
+ }
1319
+ // Emitted on stdout BEFORE the envelope goes to stderr, so the ordering is
1320
+ // deterministic: `output` writes fd 1 synchronously and `error` exits. The
1321
+ // payload is a second output surface and inherits the content-free contract
1322
+ // (T-05-01) by construction: counts, a closed-vocabulary value and artifact
1323
+ // filenames only, never a matrix cell.
1324
+ output({
1325
+ ok: false,
1326
+ violations: total,
1327
+ artifacts_checked: result.artifacts_checked,
1328
+ rounds: result.rounds,
1329
+ latest_round: result.latest_round,
1330
+ requirements: result.requirements,
1331
+ }, raw);
1332
+ error(`discovery rebaseline-check: ${total} violation(s):\n${rendered.join('\n')}`, REBASELINE_CHECK_FAILED);
1333
+ return; // unreachable — error() exits
1334
+ }
1335
+ output({
1336
+ ok: true,
1337
+ artifacts_checked: result.artifacts_checked,
1338
+ rounds: result.rounds,
1339
+ latest_round: result.latest_round,
1340
+ requirements: result.requirements,
1341
+ }, raw);
1342
+ }
1343
+ module.exports = {
1344
+ SCOPE_STATES,
1345
+ BUILT_STATES,
1346
+ VIOLATION_REASONS,
1347
+ MATRIX_HEADER_CELLS,
1348
+ MATRIX_COL,
1349
+ ECHO_MAX_CHARS,
1350
+ // Exported so the tests derive their over-bound payload from the bound under
1351
+ // test rather than from a second copy of the number, and so the value the
1352
+ // reference states by number is pinnable against the engine (WR-03).
1353
+ DENOMINATOR_MAX_CHARS,
1354
+ MAX_REPORTED_VIOLATIONS,
1355
+ DISCOVERY_DIR,
1356
+ REBASELINE_FILE,
1357
+ REQUIREMENTS_FILE,
1358
+ NOT_APPLICABLE,
1359
+ REBASELINE_CHECK_FAILED,
1360
+ REBASELINE_CHECK_USAGE,
1361
+ sanitizeEcho,
1362
+ parseRebaselineMatrix,
1363
+ // Exported so the bounded-span floor can be falsified through THIS engine
1364
+ // rather than only through the shared leaf. The round grammar is two digits,
1365
+ // so the out-of-band arm is unreachable end to end and a test can only reach
1366
+ // it by calling the validator with the numbers directly.
1367
+ validateRoundSequence,
1368
+ resolveRequirements,
1369
+ enforcePhaseCoverage,
1370
+ checkRebaseline,
1371
+ cmdDiscoveryRebaselineCheck,
1372
+ };