@3f-consulting/spec-controller-core 0.1.0-alpha.7 → 0.1.0-alpha.71

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/README.md +3 -3
  2. package/dist/assemble/evidenceReconciliationFromSources.d.ts +5 -40
  3. package/dist/assemble/evidenceReconciliationFromSources.d.ts.map +1 -1
  4. package/dist/assemble/evidenceReconciliationFromSources.js +30 -325
  5. package/dist/assemble/evidenceReconciliationFromSources.js.map +1 -1
  6. package/dist/assemble/renderInput.d.ts +7 -73
  7. package/dist/assemble/renderInput.d.ts.map +1 -1
  8. package/dist/assemble/renderInput.js +3 -45
  9. package/dist/assemble/renderInput.js.map +1 -1
  10. package/dist/census/evidenceObligations.d.ts +11 -46
  11. package/dist/census/evidenceObligations.d.ts.map +1 -1
  12. package/dist/census/evidenceObligations.js +19 -25
  13. package/dist/census/evidenceObligations.js.map +1 -1
  14. package/dist/domain/evidenceOrder.d.ts +3 -0
  15. package/dist/domain/evidenceOrder.d.ts.map +1 -0
  16. package/dist/domain/evidenceOrder.js +15 -0
  17. package/dist/domain/evidenceOrder.js.map +1 -0
  18. package/dist/domain/evidenceReconciliation.d.ts +21 -391
  19. package/dist/domain/evidenceReconciliation.d.ts.map +1 -1
  20. package/dist/domain/evidenceReconciliation.js +87 -382
  21. package/dist/domain/evidenceReconciliation.js.map +1 -1
  22. package/dist/domain/featureCode.d.ts +4 -0
  23. package/dist/domain/featureCode.d.ts.map +1 -0
  24. package/dist/domain/featureCode.js +12 -0
  25. package/dist/domain/featureCode.js.map +1 -0
  26. package/dist/domain/journal.d.ts +8 -393
  27. package/dist/domain/journal.d.ts.map +1 -1
  28. package/dist/domain/journal.js +6 -118
  29. package/dist/domain/journal.js.map +1 -1
  30. package/dist/domain/ledger.d.ts +8 -328
  31. package/dist/domain/ledger.d.ts.map +1 -1
  32. package/dist/domain/ledger.js +23 -326
  33. package/dist/domain/ledger.js.map +1 -1
  34. package/dist/domain/messages.d.ts +213 -0
  35. package/dist/domain/messages.d.ts.map +1 -0
  36. package/dist/domain/messages.js +313 -0
  37. package/dist/domain/messages.js.map +1 -0
  38. package/dist/domain/outcome.d.ts +3 -0
  39. package/dist/domain/outcome.d.ts.map +1 -0
  40. package/dist/domain/outcome.js +2 -0
  41. package/dist/domain/outcome.js.map +1 -0
  42. package/dist/domain/posting.d.ts +8 -105
  43. package/dist/domain/posting.d.ts.map +1 -1
  44. package/dist/domain/posting.js +4 -56
  45. package/dist/domain/posting.js.map +1 -1
  46. package/dist/domain/qualityCheckFault.d.ts +3 -43
  47. package/dist/domain/qualityCheckFault.d.ts.map +1 -1
  48. package/dist/domain/qualityCheckFault.js +0 -24
  49. package/dist/domain/qualityCheckFault.js.map +1 -1
  50. package/dist/domain/qualityCheckReconciliation.d.ts +7 -280
  51. package/dist/domain/qualityCheckReconciliation.d.ts.map +1 -1
  52. package/dist/domain/qualityCheckReconciliation.js +63 -463
  53. package/dist/domain/qualityCheckReconciliation.js.map +1 -1
  54. package/dist/domain/qualityCheckReconciliationFault.d.ts +0 -19
  55. package/dist/domain/qualityCheckReconciliationFault.d.ts.map +1 -1
  56. package/dist/domain/qualityCheckReconciliationFault.js +1 -24
  57. package/dist/domain/qualityCheckReconciliationFault.js.map +1 -1
  58. package/dist/domain/scenarioCodeError.d.ts +16 -0
  59. package/dist/domain/scenarioCodeError.d.ts.map +1 -0
  60. package/dist/domain/scenarioCodeError.js +4 -0
  61. package/dist/domain/scenarioCodeError.js.map +1 -0
  62. package/dist/domain/scenarioReconciliation.d.ts +2 -207
  63. package/dist/domain/scenarioReconciliation.d.ts.map +1 -1
  64. package/dist/domain/scenarioReconciliation.js +4 -101
  65. package/dist/domain/scenarioReconciliation.js.map +1 -1
  66. package/dist/domain/scnAllocation.d.ts +27 -0
  67. package/dist/domain/scnAllocation.d.ts.map +1 -0
  68. package/dist/domain/scnAllocation.js +33 -0
  69. package/dist/domain/scnAllocation.js.map +1 -0
  70. package/dist/domain/verdictCode.d.ts +10 -0
  71. package/dist/domain/verdictCode.d.ts.map +1 -0
  72. package/dist/domain/verdictCode.js +16 -0
  73. package/dist/domain/verdictCode.js.map +1 -0
  74. package/dist/index.d.ts +14 -40
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +8 -88
  77. package/dist/index.js.map +1 -1
  78. package/dist/internal.d.ts +3 -27
  79. package/dist/internal.d.ts.map +1 -1
  80. package/dist/internal.js +3 -94
  81. package/dist/internal.js.map +1 -1
  82. package/dist/lib/bareTerm.d.ts +0 -39
  83. package/dist/lib/bareTerm.d.ts.map +1 -1
  84. package/dist/lib/bareTerm.js +0 -39
  85. package/dist/lib/bareTerm.js.map +1 -1
  86. package/dist/lib/portablePath.d.ts.map +1 -1
  87. package/dist/lib/portablePath.js +2 -16
  88. package/dist/lib/portablePath.js.map +1 -1
  89. package/dist/lib/scnCitation.d.ts +0 -32
  90. package/dist/lib/scnCitation.d.ts.map +1 -1
  91. package/dist/lib/scnCitation.js +2 -42
  92. package/dist/lib/scnCitation.js.map +1 -1
  93. package/dist/model.d.ts +0 -23
  94. package/dist/model.d.ts.map +1 -1
  95. package/dist/model.js +0 -23
  96. package/dist/model.js.map +1 -1
  97. package/dist/parse/checkScriptsRunInCI.d.ts +5 -168
  98. package/dist/parse/checkScriptsRunInCI.d.ts.map +1 -1
  99. package/dist/parse/checkScriptsRunInCI.js +11 -171
  100. package/dist/parse/checkScriptsRunInCI.js.map +1 -1
  101. package/dist/parse/ciStepRecord.d.ts +4 -118
  102. package/dist/parse/ciStepRecord.d.ts.map +1 -1
  103. package/dist/parse/ciStepRecord.js +18 -225
  104. package/dist/parse/ciStepRecord.js.map +1 -1
  105. package/dist/parse/commandRuns.d.ts +0 -39
  106. package/dist/parse/commandRuns.d.ts.map +1 -1
  107. package/dist/parse/commandRuns.js +6 -67
  108. package/dist/parse/commandRuns.js.map +1 -1
  109. package/dist/parse/cucumber.d.ts +0 -53
  110. package/dist/parse/cucumber.d.ts.map +1 -1
  111. package/dist/parse/cucumber.js +42 -146
  112. package/dist/parse/cucumber.js.map +1 -1
  113. package/dist/parse/discoveryScope.d.ts +1 -46
  114. package/dist/parse/discoveryScope.d.ts.map +1 -1
  115. package/dist/parse/discoveryScope.js +2 -57
  116. package/dist/parse/discoveryScope.js.map +1 -1
  117. package/dist/parse/fitnessCitations.d.ts +1 -72
  118. package/dist/parse/fitnessCitations.d.ts.map +1 -1
  119. package/dist/parse/fitnessCitations.js +3 -55
  120. package/dist/parse/fitnessCitations.js.map +1 -1
  121. package/dist/parse/reconcilerSteps.d.ts +0 -46
  122. package/dist/parse/reconcilerSteps.d.ts.map +1 -1
  123. package/dist/parse/reconcilerSteps.js +2 -64
  124. package/dist/parse/reconcilerSteps.js.map +1 -1
  125. package/dist/parse/runningFitnessFunctions.d.ts +9 -0
  126. package/dist/parse/runningFitnessFunctions.d.ts.map +1 -0
  127. package/dist/parse/runningFitnessFunctions.js +57 -0
  128. package/dist/parse/runningFitnessFunctions.js.map +1 -0
  129. package/dist/parse/scenarioCodeErrors.d.ts +11 -0
  130. package/dist/parse/scenarioCodeErrors.d.ts.map +1 -0
  131. package/dist/parse/scenarioCodeErrors.js +78 -0
  132. package/dist/parse/scenarioCodeErrors.js.map +1 -0
  133. package/dist/parse/scenarios.d.ts +3 -228
  134. package/dist/parse/scenarios.d.ts.map +1 -1
  135. package/dist/parse/scenarios.js +20 -457
  136. package/dist/parse/scenarios.js.map +1 -1
  137. package/dist/parse/stepAccounting.d.ts +0 -46
  138. package/dist/parse/stepAccounting.d.ts.map +1 -1
  139. package/dist/parse/stepAccounting.js +1 -55
  140. package/dist/parse/stepAccounting.js.map +1 -1
  141. package/dist/parse/stepExclusions.d.ts +0 -47
  142. package/dist/parse/stepExclusions.d.ts.map +1 -1
  143. package/dist/parse/stepExclusions.js +2 -50
  144. package/dist/parse/stepExclusions.js.map +1 -1
  145. package/dist/parse/stepNames.d.ts +0 -27
  146. package/dist/parse/stepNames.d.ts.map +1 -1
  147. package/dist/parse/stepNames.js +0 -27
  148. package/dist/parse/stepNames.js.map +1 -1
  149. package/dist/parse/targetCorpus.d.ts +1 -78
  150. package/dist/parse/targetCorpus.d.ts.map +1 -1
  151. package/dist/parse/targetCorpus.js +4 -46
  152. package/dist/parse/targetCorpus.js.map +1 -1
  153. package/dist/parse/targetManifest.d.ts +8 -146
  154. package/dist/parse/targetManifest.d.ts.map +1 -1
  155. package/dist/parse/targetManifest.js +48 -198
  156. package/dist/parse/targetManifest.js.map +1 -1
  157. package/dist/parse/vitest.d.ts +0 -60
  158. package/dist/parse/vitest.d.ts.map +1 -1
  159. package/dist/parse/vitest.js +4 -83
  160. package/dist/parse/vitest.js.map +1 -1
  161. package/dist/provenance/provenance.d.ts +2 -110
  162. package/dist/provenance/provenance.d.ts.map +1 -1
  163. package/dist/provenance/provenance.js +2 -58
  164. package/dist/provenance/provenance.js.map +1 -1
  165. package/dist/render/behaviouralRunners.d.ts +0 -29
  166. package/dist/render/behaviouralRunners.d.ts.map +1 -1
  167. package/dist/render/behaviouralRunners.js +6 -69
  168. package/dist/render/behaviouralRunners.js.map +1 -1
  169. package/dist/render/configurationNotRead.d.ts +3 -0
  170. package/dist/render/configurationNotRead.d.ts.map +1 -0
  171. package/dist/render/configurationNotRead.js +17 -0
  172. package/dist/render/configurationNotRead.js.map +1 -0
  173. package/dist/render/discoveryScope.d.ts +0 -37
  174. package/dist/render/discoveryScope.d.ts.map +1 -1
  175. package/dist/render/discoveryScope.js +3 -39
  176. package/dist/render/discoveryScope.js.map +1 -1
  177. package/dist/render/evidenceGrid.d.ts +1 -35
  178. package/dist/render/evidenceGrid.d.ts.map +1 -1
  179. package/dist/render/evidenceGrid.js +21 -103
  180. package/dist/render/evidenceGrid.js.map +1 -1
  181. package/dist/render/excludedSteps.d.ts +0 -26
  182. package/dist/render/excludedSteps.d.ts.map +1 -1
  183. package/dist/render/excludedSteps.js +4 -32
  184. package/dist/render/excludedSteps.js.map +1 -1
  185. package/dist/render/fitnessFunctions.d.ts +3 -0
  186. package/dist/render/fitnessFunctions.d.ts.map +1 -0
  187. package/dist/render/fitnessFunctions.js +12 -0
  188. package/dist/render/fitnessFunctions.js.map +1 -0
  189. package/dist/render/json.d.ts +0 -11
  190. package/dist/render/json.d.ts.map +1 -1
  191. package/dist/render/json.js +0 -11
  192. package/dist/render/json.js.map +1 -1
  193. package/dist/render/markdown.d.ts +1 -55
  194. package/dist/render/markdown.d.ts.map +1 -1
  195. package/dist/render/markdown.js +128 -467
  196. package/dist/render/markdown.js.map +1 -1
  197. package/dist/render/provenanceSection.d.ts +0 -22
  198. package/dist/render/provenanceSection.d.ts.map +1 -1
  199. package/dist/render/provenanceSection.js +10 -53
  200. package/dist/render/provenanceSection.js.map +1 -1
  201. package/dist/render/reconcilerSteps.d.ts +0 -25
  202. package/dist/render/reconcilerSteps.d.ts.map +1 -1
  203. package/dist/render/reconcilerSteps.js +5 -30
  204. package/dist/render/reconcilerSteps.js.map +1 -1
  205. package/dist/render/runVerdict.d.ts +0 -38
  206. package/dist/render/runVerdict.d.ts.map +1 -1
  207. package/dist/render/runVerdict.js +62 -129
  208. package/dist/render/runVerdict.js.map +1 -1
  209. package/dist/render/scenarioCodeErrorsSection.d.ts +3 -0
  210. package/dist/render/scenarioCodeErrorsSection.d.ts.map +1 -0
  211. package/dist/render/scenarioCodeErrorsSection.js +45 -0
  212. package/dist/render/scenarioCodeErrorsSection.js.map +1 -0
  213. package/dist/render/stepAccounting.d.ts +0 -36
  214. package/dist/render/stepAccounting.d.ts.map +1 -1
  215. package/dist/render/stepAccounting.js +5 -49
  216. package/dist/render/stepAccounting.js.map +1 -1
  217. package/dist/render/testVolume.d.ts +0 -25
  218. package/dist/render/testVolume.d.ts.map +1 -1
  219. package/dist/render/testVolume.js +4 -35
  220. package/dist/render/testVolume.js.map +1 -1
  221. package/package.json +4 -1
  222. package/dist/domain/checkCharacteristics.d.ts +0 -88
  223. package/dist/domain/checkCharacteristics.d.ts.map +0 -1
  224. package/dist/domain/checkCharacteristics.js +0 -109
  225. package/dist/domain/checkCharacteristics.js.map +0 -1
  226. package/dist/domain/fitnessChecks.d.ts +0 -79
  227. package/dist/domain/fitnessChecks.d.ts.map +0 -1
  228. package/dist/domain/fitnessChecks.js +0 -81
  229. package/dist/domain/fitnessChecks.js.map +0 -1
  230. package/dist/domain/qualityCheckFaultMessages.d.ts +0 -43
  231. package/dist/domain/qualityCheckFaultMessages.d.ts.map +0 -1
  232. package/dist/domain/qualityCheckFaultMessages.js +0 -38
  233. package/dist/domain/qualityCheckFaultMessages.js.map +0 -1
  234. package/dist/lib/featureCode.d.ts +0 -21
  235. package/dist/lib/featureCode.d.ts.map +0 -1
  236. package/dist/lib/featureCode.js +0 -24
  237. package/dist/lib/featureCode.js.map +0 -1
  238. package/dist/parse/corpusCodeFaults.d.ts +0 -76
  239. package/dist/parse/corpusCodeFaults.d.ts.map +0 -1
  240. package/dist/parse/corpusCodeFaults.js +0 -121
  241. package/dist/parse/corpusCodeFaults.js.map +0 -1
  242. package/dist/parse/corpusFaults.d.ts +0 -124
  243. package/dist/parse/corpusFaults.d.ts.map +0 -1
  244. package/dist/parse/corpusFaults.js +0 -72
  245. package/dist/parse/corpusFaults.js.map +0 -1
  246. package/dist/parse/corpusGrammarFaults.d.ts +0 -71
  247. package/dist/parse/corpusGrammarFaults.d.ts.map +0 -1
  248. package/dist/parse/corpusGrammarFaults.js +0 -292
  249. package/dist/parse/corpusGrammarFaults.js.map +0 -1
  250. package/dist/parse/runningStaticChecks.d.ts +0 -125
  251. package/dist/parse/runningStaticChecks.d.ts.map +0 -1
  252. package/dist/parse/runningStaticChecks.js +0 -316
  253. package/dist/parse/runningStaticChecks.js.map +0 -1
  254. package/dist/render/staticChecks.d.ts +0 -23
  255. package/dist/render/staticChecks.d.ts.map +0 -1
  256. package/dist/render/staticChecks.js +0 -69
  257. package/dist/render/staticChecks.js.map +0 -1
@@ -1,589 +1,307 @@
1
- /**
2
- * The markdown renderer — the human-readable spec reconciliation: summary views up
3
- * top (the derived cuts OF the reconciliation), the per-scenario Scenario Reconciliations last.
4
- * Each Scenario Reconciliation row carries its binary VERDICT (balanced | out-of-balance) and the
5
- * LIST of EVERY reconciling item that holds it out of balance (no single
6
- * picked winner; an out-of-balance scenario lists all its non-balanced items, typed).
7
- *
8
- * A PURE PRESENTATION of the shared whole-document `EvidenceReconciliation` model: the JSON
9
- * renderer serialises that model, and this renderer projects the SAME model to markdown,
10
- * so the two outputs are one model in two renderings and cannot diverge. `renderMarkdown`
11
- * keeps its `(balance, ctx, …)` signature — it builds `assembleRenderInput(...)` internally
12
- * (mirroring `json.ts`) then delegates to `renderReport(report, featureNames)`, the
13
- * from-model pipeline (exposed for slice 3's render-from-saved-JSON path). Every section
14
- * emitter reads MATERIALISED fields off the report rather than recomputing them.
15
- *
16
- * The report IS the reconciliation; the per-scenario Evidence reconciliation is its core, and the
17
- * summary views — the Evidence reconciliation aggregate (the balance-check that verifies the accounts
18
- * agree), lint evidence obligations, out-of-balance by feature — are cuts OF it. Layout
19
- * reorganised summary-first / ledger-last.
20
- *
21
- * Parent: (@SCN-LDG-001).
22
- */
23
- // The single source for the human-readable outcome gloss — rendered in place of the bare
24
- // handle wherever an outcome surfaces to a reader (3F-1626) — and the DERIVED leaf list the
25
- // decomposition walks (3F-1806): both live in the model, so the renderer cannot drift from the
26
- // union. Internal core imports (NOT on the frozen v1 barrel).
27
1
  import { OUTCOME_DESCRIPTION, OUT_OF_BALANCE_OUTCOMES, OUTCOME_SIDE_PROFILE } from "../model.js";
28
- // THE MODEL DERIVATIONS THE RENDERER READS (3F-2203) — it projects these; it does not compute them.
29
- import { brokenItemsOf, lintReconciliationsOf } from "../domain/ledger.js";
2
+ import { untaggedScenarioId, } from "../census/evidenceObligations.js";
3
+ import { brokenItemsOf, fitnessReconciliationsOf } from "../domain/ledger.js";
30
4
  import { isScenario } from "../domain/scenarioReconciliation.js";
31
5
  import { assembleRenderInput } from "../assemble/renderInput.js";
32
- // The run-integrity predicate (@SCN-LDG-018) — the existential scan for a `broken` reconciling
33
- // item. The renderer asks the predicate and prints the HEADLINE word: predicate-name ≠
34
- // headline-word (`hasBrokenEvidence` → "unsound"), exactly as `isBalanced` → "balanced".
35
- // The three WHOLE-RUN predicates the verdict headline (@SCN-RPT-022) asks — the SAME three the
36
- // CLI asks to set its exit code, in the SAME dominance order. The renderer never re-scans the
37
- // reconciliation for a verdict of its own, so the headline cannot drift from the code it names.
38
6
  import { hasBrokenEvidence } from "../domain/ledger.js";
39
7
  import { runVerdictSection } from "./runVerdict.js";
8
+ import { scenarioCodeErrorsSection } from "./scenarioCodeErrorsSection.js";
40
9
  import { provenanceSection } from "./provenanceSection.js";
10
+ import { configurationNotReadSection } from "./configurationNotRead.js";
41
11
  import { discoveryScopeSection } from "./discoveryScope.js";
42
- import { staticChecksSection } from "./staticChecks.js";
12
+ import { fitnessFunctionsSection } from "./fitnessFunctions.js";
43
13
  import { behaviouralRunnersSection } from "./behaviouralRunners.js";
44
14
  import { excludedStepsSection } from "./excludedSteps.js";
45
15
  import { reconcilerSection } from "./reconcilerSteps.js";
46
16
  import { stepAccountingSection } from "./stepAccounting.js";
47
17
  import { testVolumeSection } from "./testVolume.js";
48
- import { featureCodeOf } from "../lib/featureCode.js";
18
+ import { featureCodeOf } from "../domain/featureCode.js";
49
19
  import { evidenceColumns, columnHeader, evidenceCell } from "./evidenceGrid.js";
50
- /** A short status glyph per reconciling-item outcome — a visual cue alongside the word. */
20
+ import { CORE_MESSAGES, CoreMessage } from "../domain/messages.js";
51
21
  const ITEM_GLYPH = {
52
22
  satisfied: "✓",
53
23
  "failing": "✗",
54
- // The watermelon: green on the surface (a static check exists), red within (not wired
55
- // into CI over the real source).
56
24
  "watermelon": "🍉",
57
25
  "missing": "∅",
58
- // DEBIT-SIDE: a real, ordinary scenario carrying no @SCN, so its Evidence Obligation has no code
59
- // for an Evidence Observation to LINK to (@SCN-LDG-021). Not phantom, not suspense — the scenario
60
- // exists and cucumber runs it. The debit twin of `cites-no-scenario`: same missing link, opposite
61
- // side. It can never be proven, so its row is always out-of-balance.
62
26
  "no-scn-code": "🔗",
63
- // CREDIT-SIDE: a credit citing an @SCN no scenario matches — the
64
- // reconciling item on a phantom scenario (@SCN-LDG-006).
27
+ "duplicate-scn-code": "⧉",
65
28
  "cites-unknown-scenario": "👻",
66
- // CREDIT-SIDE: a credit citing NO @SCN at all — the reconciling item on the
67
- // suspense scenario (@SCN-LDG-007).
68
29
  "cites-no-scenario": "❓",
69
- // CREDIT-SIDE: evidence that NEVER RAN — a vitest suite that failed to collect before naming a
70
- // test (@SCN-LDG-017), or a cucumber step two or more step definitions match (@SCN-LDG-019).
71
- // Either way it posts to the suspense row: the credit can never discharge an obligation.
72
30
  "broken": "💥",
73
- // CREDIT-SIDE: a credit citing a REAL scenario at a quality-check it never
74
- // named — the no-evidence-obligation reconciling item on the real scenario's row (@SCN-LDG-008).
75
31
  "no-evidence-obligation": "◇",
76
- // NEITHER-SIDE: a scenario reconciling to zero rows on both sides — the
77
- // degenerate both-missing reconciling item, blank on both sides (@SCN-LDG-009).
78
32
  "both-missing": "⊘",
79
33
  };
80
- /** A short glyph per scenario verdict. */
81
34
  const VERDICT_GLYPH = {
82
35
  balanced: "✓",
83
36
  "out-of-balance": "✗",
84
37
  };
85
- /**
86
- * Render one reconciling item, labelled by its flat quality-check (the
87
- * label-=-tag rule): the `qualityCheck` value IS the tag word — a runtime item
88
- * is its bare level (`unit ✗ Evidence Observation is failing`,
89
- * `integration ∅ Evidence Observation is missing`, …); a lint item is
90
- * `lint 🍉 Evidence Observation is passing but fabricated`. The outcome renders as its
91
- * CANON DESCRIPTION (the single-sourced `OUTCOME_DESCRIPTION`, 3F-1626) — never the bare
92
- * handle, which is meaningless to a human reading a CI log. The glyph is retained.
93
- */
94
38
  function renderItem(item) {
95
- // The NEITHER-side both-missing item is blank on both sides — it names no
96
- // quality-check (neither a debit evidence obligation nor a credit evidence observation), so it renders with
97
- // its glyph + description alone, no leading subject.
98
39
  if (item.outcome === "both-missing") {
99
40
  return `${ITEM_GLYPH["both-missing"]} ${OUTCOME_DESCRIPTION["both-missing"]}`;
100
41
  }
101
- // The `no-scn-code` item (@SCN-LDG-021) is SCENARIO-scope, like both-missing: without an @SCN
102
- // code no observation can link at ANY tier, so it names no quality-check and renders with its
103
- // glyph + description alone. (Debit-side, but subject-less — the missing thing is the LINK.)
104
42
  if (item.outcome === "no-scn-code") {
105
43
  return `${ITEM_GLYPH["no-scn-code"]} ${OUTCOME_DESCRIPTION["no-scn-code"]}`;
106
44
  }
45
+ if (item.qualityCheck === undefined) {
46
+ return `${ITEM_GLYPH[item.outcome]} ${OUTCOME_DESCRIPTION[item.outcome]}`;
47
+ }
107
48
  const subject = item.qualityCheck;
108
49
  const suffix = creditSuffix(item) ?? "";
109
50
  return `${subject} ${ITEM_GLYPH[item.outcome]} ${OUTCOME_DESCRIPTION[item.outcome]}${suffix}`;
110
51
  }
111
- /**
112
- * The credit arms of a reconciling item's where-to-fix, or `undefined` when none applies and the
113
- * item renders bare.
114
- */
115
52
  function creditSuffix(item) {
116
- // A CREDIT-SIDE item surfaces its WHERE-TO-FIX in the reconciliation cell (ruling
117
- // Q5): a lint credit (the uncited static-check folded onto the suspense row, ruling
118
- // Q4) shows its static check handle; a runtime credit (phantom `cites-unknown-scenario` or
119
- // suspense `cites-no-scenario`) shows its evidence observation locator. A DEBIT-side evidence obligation
120
- // (satisfied / missing / failing / watermelon) carries neither and renders bare, as before.
121
- //
122
- // READ OFF THE CANON TABLE rather than listed here. A credit-side outcome is exactly one canon
123
- // calls (Obligation None, Observation Exists) — `cites-no-scenario`, `cites-unknown-scenario`,
124
- // `no-evidence-obligation` — and a hand-written list of the three would agree with
125
- // `OUTCOME_SIDE_PROFILE` by copy rather than by construction, free to drift the day a fourth
126
- // joins. (`broken` and `both-missing` are (None, None), so both terms are read.)
127
53
  const profile = OUTCOME_SIDE_PROFILE[item.outcome];
128
54
  const isCredit = profile.obligation === "None" && profile.observation === "Exists";
129
- // The `broken` credit (@SCN-RPT-021) carries BOTH halves of its where-to-fix: the locator of the
130
- // evidence that never ran (a test file, or a feature file) AND the message it carries. The
131
- // message's HEADLINE LINE only — a vitest collection message is multi-line (message + stack) and
132
- // a stack in a table cell would destroy the table; the full message is printed in the
133
- // `## Run integrity` section above, which is where a reader diagnoses. Here the row stays
134
- // legible without leaving the table.
135
55
  if (item.outcome === "broken") {
136
56
  const where = [item.locator, item.message === undefined ? undefined : headlineOf(item.message)]
137
57
  .filter((part) => part !== undefined && part.length > 0)
138
58
  .join(": ");
139
59
  return where.length > 0 ? ` (${cellSafe(where)})` : undefined;
140
60
  }
141
- if (isCredit && item.staticCheck)
142
- return ` (${item.staticCheck})`;
61
+ if (isCredit && item.fitnessFunction)
62
+ return ` (${item.fitnessFunction})`;
143
63
  if (isCredit && item.locator !== undefined)
144
64
  return ` (${item.locator})`;
145
65
  return undefined;
146
66
  }
147
- /**
148
- * The first non-empty line of a runner message — its HEADLINE (`Error: Cannot find package
149
- * '@cucumber/tag-expressions' imported from …`), the line that names what broke. The rest is
150
- * the stack, which belongs in the fenced verbatim print, not in a table cell.
151
- */
152
67
  function headlineOf(message) {
153
68
  return message.split("\n").map((line) => line.trim()).find((line) => line.length > 0) ?? "";
154
69
  }
155
- /**
156
- * Make an arbitrary runner-supplied string safe to sit INSIDE a markdown table cell: collapse
157
- * every run of whitespace to a single space, and escape the pipe — an unescaped `|` in a cell
158
- * silently splits it into two columns and corrupts the table from that row on. Defensive: the
159
- * text comes from the runner, not from us.
160
- */
161
70
  function cellSafe(text) {
162
71
  return text.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
163
72
  }
164
- /**
165
- * Render a Pending Item (@SCN-PND-010) — the one-sided IOU a deliberately-deferred
166
- * `<quality-check>pending` obligation raises (evidence-manifesto §11B). Labelled DISTINCT from
167
- * a reconciling item: the ⏳ marker + the explicit "pending" word read as neither
168
- * balanced-clean (the `—` placeholder) nor an error (a reconciling item's ✗/∅/🍉 outcome). It
169
- * is NEVER run through `renderItem` and never enters the reconciling-item list — a Pending
170
- * Item is a structurally distinct category, never one of the out-of-balance leaf outcomes.
171
- */
172
73
  function renderPendingItem(item) {
173
- // A `qualityCheck`-less Pending Item (@SCN-PND-022) is SCENARIO-SCOPE — a whole-scenario
174
- // deferral (a bare `@wip` raising no obligations) owing NO specific check. Render `⏳ pending`
175
- // alone; an obligation-scope Pending Item names its owed check (`⏳ pending: <check>`).
176
- return item.qualityCheck === undefined ? "⏳ pending" : `⏳ pending: ${item.qualityCheck}`;
74
+ return item.qualityCheck === undefined
75
+ ? CORE_MESSAGES[CoreMessage.PendingItem]
76
+ : CORE_MESSAGES[CoreMessage.PendingItemFor](item.qualityCheck);
177
77
  }
178
- /**
179
- * The exceptions cell for a Scenario Reconciliation row (@SCN-PND-010) — its reconciling items
180
- * (the errors an out-of-balance verdict carries) and its Pending Items (the deferred IOUs),
181
- * each rendered as its OWN distinctly-labelled group and then joined. The two groups are built
182
- * separately, so a Pending Item is never folded into the reconciling-item list nor mistaken for
183
- * an error. A row with neither renders the em-dash placeholder; a balanced-with-pending row
184
- * shows its ⏳ pending group alone (distinct from the clean-balanced `—`). A row with no
185
- * `pendingItems` renders byte-identically to before (reconciling items only, or `—`).
186
- */
187
78
  function renderRowExceptions(s) {
188
79
  const reconciling = s.reconcilingItems.map(renderItem).join("; ");
189
- const pending = (s.pendingItems ?? []).map(renderPendingItem).join("; ");
80
+ const pending = s.pendingItems.map(renderPendingItem).join("; ");
190
81
  const groups = [reconciling, pending].filter((g) => g.length > 0);
191
82
  return groups.length === 0 ? "—" : groups.join("; ");
192
83
  }
193
- /**
194
- * Render one scenario row: its id, its binary verdict, and its exceptions cell — EVERY
195
- * reconciling item followed by any Pending Item (labelled distinct). A balanced scenario with
196
- * neither is rendered as an em-dash placeholder.
197
- */
198
- function renderScenario(s) {
199
- return `| ${s.scnId} | ${VERDICT_GLYPH[s.verdict]} ${s.verdict} | ${renderRowExceptions(s)} |`;
200
- }
201
- /** The report title — the report IS the reconciliation (the Evidence reconciliation is its core). */
202
- // The report IS the reconciliation. "Ledger" names only the INPUT in canon (evidence
203
- // entries by scenario); the rendered document is the reconciled OUTPUT, never "Ledger"
204
- // (double-entry-manifesto). Renamed from "# Ledger" → "# Spec reconciliation" → here.
205
- const titleSection = () => ["# Evidence Reconciliation", ""];
206
- /**
207
- * The RUN-INTEGRITY stamp (@SCN-RPT-021) — the customer-visible payoff of the `broken`
208
- * reconciling item (3F-1642), and the ONLY place the word **"unsound"** appears. The renderer
209
- * asks `hasBrokenEvidence` and prints the headline word: predicate-name ≠ headline-word, the
210
- * `isBalanced` / "balanced" precedent.
211
- *
212
- * WHY IT SITS HERE — after provenance, BEFORE the summary views. The stamp is not one more cut
213
- * of the reconciliation; it is a caveat ON all of them. A behaviour that was never exercised
214
- * reconciles `missing` whether its proof is ABSENT or merely BROKEN, so a `missing` row in an
215
- * unsound run cannot be read as "write the test" — the reader must know that before reading a
216
- * single count. Every other section keeps its @SCN-RPT-009 summary-first / ledger-last position.
217
- *
218
- * WHAT IT PRINTS. The broken evidence's locator (the WHERE) and its message in a fenced block
219
- * (the WHAT) — multi-line, stack included where there is one, because that is what the reader
220
- * diagnoses from and a table cell cannot hold it.
221
- *
222
- * TWO PRODUCERS, ONE SHAPE, NO BRANCH (3F-1654). `broken` now has two instances — a vitest suite
223
- * that failed to COLLECT, and a cucumber step two or more step definitions match (@SCN-LDG-019) —
224
- * so the copy here is GENERIC: an ambiguous step is neither a "suite" nor a "collection error",
225
- * and a renderer that called it one would be the tool lying about what broke, in the one place the
226
- * reader will ever see it. The renderer needs no per-reader branch: it prints what the ITEM
227
- * CARRIES. Where the runner gives its own words (vitest's collection error) the message IS those
228
- * words, passed through VERBATIM; where the runner gives none (cucumber emits no `error_message`
229
- * for an ambiguous step) the READER synthesised the diagnosis upstream. The difference is resolved
230
- * before the renderer sees it.
231
- *
232
- * THE BOUNDED SOFTENING of 3F-1642's "print verbatim — the tool does not interpret" stance
233
- * (operator-mandated; exactly three clauses, no further): pass the runner's words through where it
234
- * gives them; STATE the diagnosis — WHAT BROKE — where it does not; PRESCRIBE NO CURE, either way.
235
- * The renderer may name WHAT failed and WHERE. It may never say how to fix it. The gloss stays
236
- * REMEDY-NEUTRAL: what broke is equally a missing dependency, a syntax error, a bad import, a
237
- * top-level throw, or two colliding step definitions, so the report never guesses the cure (never
238
- * `pnpm install`, and never "delete one of the step definitions"). The one remedy that always
239
- * holds — fix the harness and regenerate — is the one it gives, carrying the parent's fuller
240
- * `OUTCOME_DESCRIPTION` gloss through to the reader; it POINTS, it does not diagnose a remedy.
241
- *
242
- * OMIT-WHEN-EMPTY (the Pending Items tally precedent): a run carrying no broken evidence
243
- * returns `[]`, so a SOUND report is byte-identical to the pre-slice output — no stray empty
244
- * section. Markdown-only: a machine consumer reads soundness as `summary.byOutcome.broken > 0`,
245
- * already type-forced into the JSON, so no new JSON field is owed.
246
- */
84
+ const titleSection = () => [CORE_MESSAGES[CoreMessage.ReportTitle], ""];
247
85
  const runIntegritySection = (report) => {
248
- // The predicate is the single source of the verdict — the renderer never re-derives
249
- // "is this run sound?" with its own scan, so the stamp cannot drift from the CLI's exit 4
250
- // (@SCN-CLI-012), which asks the same predicate.
251
86
  if (!hasBrokenEvidence({ scenarioReconciliations: report.ledger.scenarioReconciliations }))
252
87
  return [];
253
- // ASKED, not derived (3F-2203) — which items are broken is a model query, and it is the same
254
- // scan `hasBrokenEvidence` above is now derived from, so the stamp and the list cannot disagree.
255
- // Taking `.length` of a list you were handed is projection, not derivation.
256
88
  const brokenItems = brokenItemsOf({
257
89
  scenarioReconciliations: report.ledger.scenarioReconciliations,
258
90
  });
259
91
  const count = brokenItems.length;
260
- // GENERIC over both producers — the count-phrase names what a collection-failed vitest suite and
261
- // an ambiguous cucumber step have in common (the proof did not run), never one kind's idiom.
262
92
  const proofs = count === 1
263
- ? "1 proof never ran, so the behaviour it proves was never exercised."
264
- : `${count} proofs never ran, so the behaviours they prove were never exercised.`;
93
+ ? CORE_MESSAGES[CoreMessage.OneProofNeverRan]
94
+ : CORE_MESSAGES[CoreMessage.ProofsNeverRan](count);
265
95
  const lines = [
266
- "## Run integrity: unsound",
96
+ CORE_MESSAGES[CoreMessage.RunIntegrityHeading],
267
97
  "",
268
- `⚠ **This run is unsound.** ${proofs} A \`missing\` outcome ` +
269
- "below may therefore be a BROKEN proof rather than an absent one — it cannot be read as " +
270
- `"write the test", because the test may already be there. Fix the harness and regenerate.`,
98
+ CORE_MESSAGES[CoreMessage.RunUnsound](proofs),
271
99
  "",
272
100
  ];
273
101
  for (const item of brokenItems) {
274
- // WHERE the evidence broke — a test file (vitest) or a feature file (cucumber). A `broken`
275
- // observation always carries its locator (the reader's WHERE-to-fix); the fallback keeps the
276
- // section honest if one ever does not.
277
- lines.push(`- \`${item.locator ?? "(evidence not named by the runner)"}\` — what broke:`);
102
+ const locator = item.locator ?? CORE_MESSAGES[CoreMessage.BrokenEvidenceNotNamed];
103
+ lines.push(`- ${CORE_MESSAGES[CoreMessage.BrokenEvidenceWhatBroke](`\`${locator}\``)}`);
278
104
  lines.push("");
279
- // WHAT broke, exactly as the item carries it: the runner's own words VERBATIM where the runner
280
- // gave them (vitest — every line, stack included, unedited: the tool does not interpret the
281
- // error), and the reader's synthesised diagnosis where it gave none (cucumber's ambiguous step
282
- // has no `error_message` to quote). No branch — one shape, resolved upstream. Either way the
283
- // line names what failed; it never prescribes the cure.
284
105
  lines.push("```text");
285
- lines.push(...(item.message ?? "(no message reported by the runner)").split("\n"));
106
+ lines.push(...(item.message ?? CORE_MESSAGES[CoreMessage.BrokenEvidenceNoMessage]).split("\n"));
286
107
  lines.push("```");
287
108
  lines.push("");
288
109
  }
289
110
  return lines;
290
111
  };
291
- /**
292
- * The Summary-views parent heading — the derived cuts OF the reconciliation
293
- * (Evidence reconciliation, lint evidence obligations, out-of-balance by feature)
294
- * read as cuts of the reconciliation, not a separate reconciliation. Always rendered (the
295
- * Evidence reconciliation beneath it are always present).
296
- */
297
- const summaryViewsSection = () => ["## Summary views", ""];
298
- /**
299
- * Evidence reconciliation (@SCN-RPT-004 Update — supersedes the `Axis |
300
- * Count` shape) — a SUBJECTS-AS-ROWS table: each subject (Scenario Reconciliation, Evidence
301
- * obligation) on its own row with its own Total, and balanced / out-of-balance as COLUMNS (both
302
- * subjects report into the same binary, so the "Verdict"/"outcome"/"Axis" words retire).
303
- *
304
- * The first subject is the SCENARIO RECONCILIATION — the manifesto's collection, ALL three row
305
- * kinds (scenario, phantom, suspense) — not `Scenario`, which names only the real thing
306
- * (3F-1726). Because the row is a pure presentation of `summary.scenarioReconciliations`, whose
307
- * totals count every row (@SCN-RMD-001, 3F-1724 — a phantom / suspense reconciliation is always
308
- * out-of-balance), a run whose only out-of-balance row is the suspense reconciliation renders
309
- * `N+1 | N | 1`, never `N | N | 0`. The subject's out-of-balance column therefore agrees with
310
- * `isBalanced` and with the exit code — the three views that disagreed in the AWTY 17-36-50Z run
311
- * (3F-1703), where a failing run's tally read `141 | 141 | 0` and looked like a clean board.
312
- * The evidence-obligation out-of-balance is decomposed IN PLACE into its reconciling-item
313
- * causes — three nested (`↳`) rows in canonical order (missing,
314
- * failing, the watermelon),
315
- * populating the out-of-balance column only and ALWAYS shown (a zero cause is itself a
316
- * fact). The missing row carries the bare-level split as Detail — missing
317
- * evidenceObligations grouped by subject, counting evidence obligations not scenarios.
318
- *
319
- * The scenario-verdict and evidence-obligation out-of-balance counts diverge ON PURPOSE:
320
- * one out-of-balance scenario can carry several missing evidenceObligations (SCN-PST-035's double
321
- * arm — the live 43 scenarios vs 44 evidenceObligations). The reshape presents that as a
322
- * granularity fact, not a contradiction. The nested causes reconcile to the evidenceObligation
323
- * out-of-balance. No bold. A pure presentation of the report's materialised
324
- * `summary.scenarioReconciliations` / `summary.evidenceObligations` totals; markdown-only.
325
- */
112
+ const summaryViewsSection = () => [CORE_MESSAGES[CoreMessage.SummaryViewsHeading], ""];
113
+ const EVIDENCE_RECONCILIATION_COLUMNS = [
114
+ CORE_MESSAGES[CoreMessage.SubjectColumn],
115
+ CORE_MESSAGES[CoreMessage.TotalColumn],
116
+ CORE_MESSAGES[CoreMessage.BalancedColumn],
117
+ CORE_MESSAGES[CoreMessage.OutOfBalanceColumn],
118
+ CORE_MESSAGES[CoreMessage.DetailColumn],
119
+ ];
326
120
  const evidenceReconciliationSection = (report) => {
327
121
  const scenarioReconciliations = report.summary.scenarioReconciliations;
328
122
  const evidenceObligations = report.summary.evidenceObligations;
329
123
  const totalEvidenceObligations = evidenceObligations.total;
330
- // The evidenceObligation out-of-balance is the sum of the three non-balanced outcomes — the
331
- // total the nested causes reconcile to.
332
124
  const evidenceObligationsOutOfBalance = evidenceObligations.outOfBalance;
333
- // A five-cell row; an empty string renders as an empty cell (the Total/Balanced cells
334
- // of a nested cause row, and the Detail cell of a subject row).
335
125
  const row = (cells) => `| ${cells.join(" | ")} |`;
336
- // The bare-level split of the missing evidenceObligations, materialised on
337
- // the report as `{ subject, count }[]` in canonical order — rendered `43 unit + 1 integration`.
338
126
  const missingSplit = evidenceObligations.missingByQualityCheck
339
127
  .map(({ subject, count }) => `${count} ${subject}`)
340
128
  .join(" + ");
341
129
  const lines = [
342
- "### Evidence reconciliation",
130
+ CORE_MESSAGES[CoreMessage.EvidenceReconciliationHeading],
343
131
  "",
344
- row(["Subject", "Total", "Balanced", "Out-of-balance", "Detail"]),
132
+ row(EVIDENCE_RECONCILIATION_COLUMNS),
345
133
  row(["---", "---", "---", "---", "---"]),
346
134
  row([
347
- // The subject is the SCENARIO RECONCILIATION — the manifesto's COLLECTION (scenario,
348
- // phantom and suspense rows alike), not `Scenario`, which names only the real thing
349
- // (@SCN-RPT-004, 3F-1726; `scenarios` is in the manifesto's retired column).
350
- "Scenario Reconciliation",
135
+ CORE_MESSAGES[CoreMessage.ScenarioReconciliationSubject],
351
136
  String(scenarioReconciliations.total),
352
137
  String(scenarioReconciliations.balanced),
353
138
  String(scenarioReconciliations.outOfBalance),
354
139
  "",
355
140
  ]),
356
141
  row([
357
- "Evidence obligation",
142
+ CORE_MESSAGES[CoreMessage.EvidenceObligationSubject],
358
143
  String(totalEvidenceObligations),
359
144
  String(evidenceObligations.byOutcome.satisfied),
360
145
  String(evidenceObligationsOutOfBalance),
361
146
  "",
362
147
  ]),
363
148
  ];
364
- // Decompose the evidence-obligation out-of-balance into its causes — a FLAT list of the
365
- // nine out-of-balance leaf outcomes in canonical order (@SCN-RPT-019, 3F-1453; the ninth,
366
- // `no-scn-code`, added by @SCN-LDG-021, 3F-1774). The list is DERIVED from the type-forced
367
- // OUTCOME_DESCRIPTION (3F-1806), so a new Outcome renders here with no edit to this file;
368
- // its ROW ORDER is that map's key order, which is therefore load-bearing. Each a
369
- // counted `↳ <outcome>` row (only the out-of-balance column populated) reconciling to the
370
- // total exactly once. No debit/credit/neither grouping: the grouping emitted a subtotal
371
- // `↳ <group>-side` row AND its leaf rows at the same `↳` indent, so a naive column-sum
372
- // double-counted (the live 170 vs 85). `missing` carries the bare-level split as Detail.
373
- // Every outcome is ALWAYS rendered, including a zero count (a zero cause is itself a fact).
374
149
  for (const outcome of OUT_OF_BALANCE_OUTCOMES) {
375
150
  const detail = outcome === "missing" ? missingSplit : "";
376
- // The Subject cell shows the outcome's CANON DESCRIPTION, not the bare handle (3F-1626)
377
- // — single-sourced from OUTCOME_DESCRIPTION so it cannot drift from the outcome it names.
378
151
  lines.push(row([`↳ ${OUTCOME_DESCRIPTION[outcome]}`, "", "", String(evidenceObligations.byOutcome[outcome]), detail]));
379
152
  }
380
- // The Pending Items tally (@SCN-PND-011) — a SEPARATE line, NEVER an out-of-balance leaf of
381
- // its own, at any leaf count (3F-1453 line, endorsed; re-endorsed at eight leaves, 3F-1649;
382
- // the count is now nine, 3F-1774 — the constraint is categorical, not arithmetic): a Pending
383
- // Item is deferred, not out-of-balance, so it is
384
- // tallied apart from BOTH the balanced and out-of-balance counts and the `↳` leaf
385
- // decomposition above — its own line, never a `↳` row under the out-of-balance table. Read
386
- // off the materialised `summary.pending`; rendered only when the run genuinely carries Pending
387
- // Items (the omit-when-empty precedent), so a zero-pending report stays byte-identical.
388
153
  lines.push(...pendingTally(report.summary.pending));
389
154
  lines.push("");
390
155
  return lines;
391
156
  };
392
- /** The Pending Items tally line, preceded by its blank — or nothing at all when none is pending. */
393
157
  function pendingTally(pending) {
394
158
  return pending > 0
395
- ? ["", `⏳ ${pending} Pending Item(s) — deferred, tallied apart from balanced and out-of-balance.`]
159
+ ? ["", CORE_MESSAGES[CoreMessage.PendingTally](pending)]
396
160
  : [];
397
161
  }
398
- /**
399
- * Lint evidence obligations (@SCN-RPT-005; re-cut under Summary views with a total line, labelled
400
- * by the @lint TAG) — lifts every `@lint` evidence obligation out of the per-scenario trial
401
- * balance into its OWN `### Lint evidence obligations` table `| Scenario | Static check | Outcome |`,
402
- * one row per obligation in document order, each `outcome` carrying its `ITEM_GLYPH` glyph (so a
403
- * 🍉 watermelon reads at a glance). The total line STATES how many of them reconcile balanced —
404
- * read off the report's materialised `summary.lint` tally. Omitted entirely (returns `[]`) when no
405
- * scenario names a lint obligation. A pure derive over the ledger rows; markdown-only.
406
- */
407
- const lintSection = (report) => {
408
- // Scenario rows only — a lint evidence obligation is an obligation-side thing. The suspense row's
409
- // lint credit (an uncited static-check, cites-no-scenario) is NOT an evidence obligation, so
410
- // it stays out of this table + tally; it surfaces on the suspense row's reconciling items.
411
- // ASKED, not derived (3F-2203) — the obligation-side lint sub-rows are a model list, and it is the SAME
412
- // list `summary.lint` is tallied from, so the table and its total line cannot disagree. The
413
- // scenario-rows-only cut (a suspense lint CREDIT is an observation, not an obligation) lives with
414
- // the rule; it stood here and in `lintTally`, character-identical, agreeing by copy.
415
- const rows = lintReconciliationsOf({
162
+ const fitnessSection = (report) => {
163
+ const rows = fitnessReconciliationsOf({
416
164
  scenarioReconciliations: report.ledger.scenarioReconciliations,
417
- }).map(
418
- // The Outcome cell shows the canon DESCRIPTION, not the bare handle (3F-1626,
419
- // single-sourced from OUTCOME_DESCRIPTION); the ITEM_GLYPH glyph is retained.
420
- ({ scnId, reconciliation: r }) => `| ${scnId} | ${r.staticCheck} | ${ITEM_GLYPH[r.outcome]} ${OUTCOME_DESCRIPTION[r.outcome]} |`);
165
+ }).map(({ scnId, reconciliation: r }) => `| ${scnId} | ${ITEM_GLYPH[r.outcome]} ${OUTCOME_DESCRIPTION[r.outcome]} |`);
421
166
  if (rows.length === 0)
422
167
  return [];
423
- const { satisfied, total } = report.summary.lint;
168
+ const { satisfied, total } = report.summary.fitness;
424
169
  return [
425
- "### Lint evidence obligations",
170
+ CORE_MESSAGES[CoreMessage.FitnessHeading],
426
171
  "",
427
- `${satisfied} of ${total} lint evidence obligations reconcile satisfied.`,
172
+ CORE_MESSAGES[CoreMessage.FitnessSatisfiedOf](satisfied, total),
428
173
  "",
429
- "| Scenario | Static check | Outcome |",
430
- "| --- | --- | --- |",
174
+ CORE_MESSAGES[CoreMessage.FitnessTableHeader],
175
+ "| --- | --- |",
431
176
  ...rows,
432
177
  "",
433
178
  ];
434
179
  };
435
- /**
436
- * Out-of-balance by feature (@SCN-RPT-006; re-cut under Summary views with a
437
- * total line, rendered as a TABLE) — re-cuts the `verdict === "out-of-balance"`
438
- * scenarios by their feature code (the `<FFF>` parsed from each `scnId` via the shared
439
- * `featureCodeOf` helper), so a reader sees the discrepancy CONCENTRATION by feature.
440
- * One row per feature in first-appearance (document) order, with a per-feature count and
441
- * the out-of-balance ids. The `FFF` prefix only — the full feature names are added separately.
442
- * A malformed/unparseable scnId (`featureCodeOf` → null) falls back to its raw id as the
443
- * group key, so no discrepancy is dropped. The Feature cell reads `<slug> (<FFF>)` when a
444
- * `featureNames` map provides the slug for the code, else the bare `FFF` — the
445
- * fallback for an uncoded id (no file registers the code) or the no-map back-compat path.
446
- * Omitted entirely when no scenario is out of balance. A pure derive over
447
- * `report.ledger.scenarioReconciliations` (+ the model's `metadata.featureNames` title lookup);
448
- * markdown-only.
449
- */
450
180
  const byFeatureSection = (report) => {
451
- // Scenario rows only (ruling Q3) — phantom/suspense rows are grouped at
452
- // the end of the Ledger, not folded into the scenario out-of-balance-by-feature cut.
453
- const scenarios = report.ledger.scenarioReconciliations.filter(isScenario);
181
+ const rows = report.ledger.scenarioReconciliations;
182
+ const scenarios = rows.filter(isScenario);
183
+ const groups = outOfBalanceByFeature(scenarios, report.evidenceObligations?.untaggedScenarios ?? [], report.metadata?.featureNames ?? {});
184
+ const ungrouped = ungroupedOutOfBalance(report.summary.scenarioReconciliations);
185
+ if (groups.size === 0 && ungrouped.length === 0)
186
+ return [];
187
+ const outOfBalance = scenarios.filter((s) => s.verdict === "out-of-balance").length;
188
+ const head = CORE_MESSAGES[CoreMessage.ScenarioRowsOutOfBalance](outOfBalance, scenarios.length);
189
+ return [
190
+ CORE_MESSAGES[CoreMessage.OutOfBalanceByFeatureHeading],
191
+ "",
192
+ `${head}${groups.size > 0 ? CORE_MESSAGES[CoreMessage.GroupedByFeature] : ""}${ungroupedTail(ungrouped)}.`,
193
+ "",
194
+ ...byFeatureTableLines(groups, report.metadata?.featureNames),
195
+ ];
196
+ };
197
+ function outOfBalanceByFeature(scenarios, untagged, featureNames) {
198
+ const codeOfFile = new Map(Object.entries(featureNames).map(([code, slug]) => [`${slug}.feature`, code]));
199
+ const featureOf = new Map(untagged.map((u) => [untaggedScenarioId(u), codeOfFile.get(u.feature) ?? u.feature]));
454
200
  const groups = new Map();
455
201
  for (const s of scenarios) {
456
202
  if (s.verdict !== "out-of-balance")
457
203
  continue;
458
- const code = featureCodeOf(s.scnId) ?? s.scnId;
459
- const ids = groups.get(code) ?? [];
460
- ids.push(s.scnId);
461
- groups.set(code, ids);
204
+ const noScnCode = carriesNoScnCode(s);
205
+ const key = (noScnCode ? featureOf.get(s.scnId) : featureCodeOf(s.scnId)) ?? s.scnId;
206
+ const group = groups.get(key) ?? { ids: [], noScnCode: 0 };
207
+ if (noScnCode)
208
+ group.noScnCode += 1;
209
+ else
210
+ group.ids.push(s.scnId);
211
+ groups.set(key, group);
462
212
  }
213
+ return groups;
214
+ }
215
+ function carriesNoScnCode(row) {
216
+ return row.reconcilingItems.some((item) => item.outcome === "no-scn-code");
217
+ }
218
+ function byFeatureTableLines(groups, featureNames) {
463
219
  if (groups.size === 0)
464
220
  return [];
465
- const outOfBalance = scenarios.filter((s) => s.verdict === "out-of-balance").length;
466
- const lines = [
467
- "### Out-of-balance by feature",
468
- "",
469
- `The ${outOfBalance} of ${scenarios.length} out-of-balance scenarios, grouped by feature:`,
470
- "",
471
- "| Feature | Count | Out-of-balance `@SCN` ids |",
472
- "| --- | --- | --- |",
473
- ];
474
- for (const [code, ids] of groups) {
475
- const slug = report.metadata?.featureNames?.[code];
476
- const label = slug ? `${slug} (${code})` : code;
477
- lines.push(`| ${label} | ${ids.length} | ${ids.join(", ")} |`);
221
+ const lines = [CORE_MESSAGES[CoreMessage.ByFeatureTableHeader], "| --- | --- | --- |"];
222
+ for (const [key, group] of groups) {
223
+ const slug = featureNames?.[key];
224
+ const label = slug ? `${slug} (${key})` : key;
225
+ lines.push(`| ${label} | ${group.ids.length + group.noScnCode} | ${idsCell(group)} |`);
478
226
  }
479
227
  lines.push("");
480
228
  return lines;
481
- };
482
- /**
483
- * The ledger (moved to the END) — the per-scenario table, with its summary lines on top: the
484
- * verdict tally bullet, and (when an evidence-obligation census is threaded) the
485
- * excluded-occurrence bullet relocated here from the dissolved corpus-census section (Q1a). When
486
- * the evidence-observation aggregate is threaded, the row gains corpus-driven evidence columns
487
- * between Scenario and Verdict, each a four-way obligation×observation cell; without the aggregate
488
- * the original three-column ledger is preserved (the 2-/3-arg callers stay green). Markdown-only.
489
- * The verdict-tally bullet names the collection "Scenario Reconciliation(s)" — canon's noun for
490
- * the reconciled OUTPUT collection (scenario + phantom + suspense), never "scenario(s)"
491
- * (@SCN-RPT-024, 3F-1919; "scenarios" is a Rejected alias).
492
- */
229
+ }
230
+ function idsCell(group) {
231
+ const ids = group.ids.join(", ");
232
+ if (group.noScnCode === 0)
233
+ return ids;
234
+ const noScnCode = CORE_MESSAGES[CoreMessage.NoScnCodeScenarios](group.noScnCode);
235
+ return ids === "" ? noScnCode : `${ids}${CORE_MESSAGES[CoreMessage.AndJoiner]}${noScnCode}`;
236
+ }
237
+ function ungroupedOutOfBalance(totals) {
238
+ return [
239
+ ...(totals.phantom > 0 ? [CORE_MESSAGES[CoreMessage.PhantomScenarioRows](totals.phantom)] : []),
240
+ ...(totals.suspense > 0 ? [CORE_MESSAGES[CoreMessage.SuspenseScenarioRows](totals.suspense)] : []),
241
+ ];
242
+ }
243
+ function ungroupedTail(ungrouped) {
244
+ return ungrouped.length > 0
245
+ ? `; ${CORE_MESSAGES[CoreMessage.NotGroupedByFeature](ungrouped.join(CORE_MESSAGES[CoreMessage.AndJoiner]))}`
246
+ : "";
247
+ }
493
248
  const ledgerSection = (report) => {
494
- // Group the scenario rows first, then the PHANTOM rows, then the SUSPENSE row as
495
- // distinct blocks at the END (ruling Q3) — a phantom/suspense is not a
496
- // scenario, so it never mingles with the scenario verdict rows.
497
249
  const scenarioRows = report.ledger.scenarioReconciliations.filter(isScenario);
498
250
  const phantomRows = report.ledger.scenarioReconciliations.filter((s) => s.kind === "phantom-scenario");
499
251
  const suspenseRows = report.ledger.scenarioReconciliations.filter((s) => s.kind === "suspense-scenario");
500
252
  const scenarios = [...scenarioRows, ...phantomRows, ...suspenseRows];
501
- // The runtime-observation axis is IN PLAY iff the summary carries the test-volume fold
502
- // (@SCN-RPT-010 / 3F-2103 — the gate that was the retired `evidenceObservations` section's
503
- // presence). When it is, the ledger gains the corpus-driven evidence columns.
504
253
  const withObservations = report.summary.testVolume !== undefined;
505
254
  return [
506
- // The reconciled per-scenario OUTPUT table — "Scenario Reconciliations", not "Ledger"
507
- // ("Ledger" is the INPUT concept in canon; double-entry-manifesto).
508
- "## Scenario Reconciliations",
255
+ CORE_MESSAGES[CoreMessage.ScenarioReconciliationsHeading],
509
256
  "",
510
257
  ...ledgerTallyLines(report.summary.scenarioReconciliations),
511
- ...(report.evidenceObligations ? censusLines(report.evidenceObligations) : []),
258
+ ...(report.evidenceObligations ? censusLines(report.evidenceObligations, scenarioRows.length) : []),
512
259
  "",
513
260
  ...ledgerTableLines(scenarios, withObservations),
514
261
  "",
515
262
  ];
516
263
  };
517
- /** The ledger's verdict tally bullet, and the phantom / suspense sub-counts broken out of it. */
518
264
  function ledgerTallyLines(totals) {
519
265
  const lines = [
520
- // The headline verdict tally counts EVERY Scenario Reconciliation — scenario, phantom and
521
- // suspense alike (@SCN-RMD-001, 3F-1724; the scenario-only "ruling Q3" is retracted).
522
- `- ${totals.total} Scenario Reconciliation(s): ${totals.balanced} balanced, ` +
523
- `${totals.outOfBalance} out-of-balance.`,
266
+ `- ${CORE_MESSAGES[CoreMessage.ScenarioReconciliationTally](totals.total, totals.balanced, totals.outOfBalance)}`,
524
267
  ];
525
- // The phantom (P) count is broken out as a SUB-COUNT of the tally above — it is counted
526
- // INSIDE total / out-of-balance (a phantom reconciliation is always out-of-balance), and
527
- // named here so a reader sees what makes up the total.
528
268
  if (totals.phantom > 0) {
529
- lines.push(`- ${totals.phantom} phantom-scenario(s): cited by an evidence observation, named by no feature.`);
269
+ lines.push(`- ${CORE_MESSAGES[CoreMessage.PhantomScenarioTally](totals.phantom)}`);
530
270
  }
531
- // The suspense (S) count is broken out the same way — credits citing no scenario at all
532
- // (uncited evidence observations + uncited static-checks), aggregated into one row,
533
- // counted INSIDE the tally above.
534
271
  if (totals.suspense > 0) {
535
- lines.push(`- ${totals.suspense} suspense-scenario(s): credits citing no scenario, aggregated into "(no scenario)".`);
272
+ lines.push(`- ${CORE_MESSAGES[CoreMessage.SuspenseScenarioTally](totals.suspense)}`);
536
273
  }
537
274
  return lines;
538
275
  }
539
- /**
540
- * The scenario-corpus CENSUS — obligation-side facts on report.evidenceObligations.
541
- * These re-homed here from the dissolved `## Warnings` section: they are census
542
- * facts, NOT warnings and NOT reconciling items, so they ride in the ledger summary co-located
543
- * with the excluded-occurrence bullet (the evidenceObligations census view). Facts only —
544
- * count + locator.
545
- */
546
- function censusLines(census) {
547
- const lines = [`- ${census.excludedOccurrences} @SCN ids seen in non-tag context, excluded`];
548
- // Untagged scenarios (@SCN-RPT-014) — Gherkin scenarios with no @SCN tag, located by
549
- // `feature : scenario`. Sorted defensively (a hand-built corpus fixture may be unsorted).
276
+ function censusLines(census, accounts) {
277
+ const lines = [`- ${CORE_MESSAGES[CoreMessage.ExcludedScnOccurrences](census.excludedOccurrences)}`];
550
278
  const untagged = census.untaggedScenarios;
551
279
  if (untagged.length > 0) {
552
280
  const sorted = [...untagged].sort((a, b) => a.feature.localeCompare(b.feature) || a.scenario.localeCompare(b.scenario));
553
- lines.push(`- ${untagged.length} scenario(s) carry no @SCN tag:`);
281
+ lines.push(`- ${CORE_MESSAGES[CoreMessage.UntaggedScenarios](untagged.length)}`);
554
282
  for (const { feature, scenario } of sorted) {
555
283
  lines.push(` - ${feature} : ${scenario}`);
556
284
  }
557
285
  }
558
- // Prose-only codes (@SCN-RPT-015) — @SCN ids mentioned in prose but never tagged anywhere
559
- // (the dangling references). The bare code is its own locator. Sorted, after untagged.
560
- const proseOnly = census.proseOnlyCodes;
561
- if (proseOnly.length > 0) {
562
- lines.push(`- ${proseOnly.length} @SCN code(s) appear only in prose, never as a tag:`);
563
- for (const code of [...proseOnly].sort()) {
564
- lines.push(` - ${code}`);
565
- }
566
- }
286
+ lines.push(...sharedScnCodeLines(census.sharedScnCodes ?? [], accounts));
567
287
  return lines;
568
288
  }
569
- /**
570
- * The per-scenario table: with the corpus-driven evidence columns between Scenario and Verdict when
571
- * the runtime-observation axis is in play and any column appears, else the original three columns.
572
- */
289
+ function sharedScnCodeLines(shared, accounts) {
290
+ if (shared.length === 0)
291
+ return [];
292
+ const scenarios = accounts + shared.reduce((extra, code) => extra + code.scenarios.length - 1, 0);
293
+ return [
294
+ `- ${CORE_MESSAGES[CoreMessage.SharedScnCodes](scenarios, accounts, shared.length)}`,
295
+ ...shared.map((code) => ` - ${code.scnId}: ${code.scenarios.map((s) => `${s.feature} : ${s.scenario}`).join("; ")}`),
296
+ ];
297
+ }
573
298
  function ledgerTableLines(scenarios, withObservations) {
574
299
  const columns = withObservations ? evidenceColumns(scenarios) : [];
575
- if (!(withObservations && columns.length > 0)) {
576
- return [
577
- "| Scenario | Verdict | Reconciling items |",
578
- "| --- | --- | --- |",
579
- ...scenarios.map(renderScenario),
580
- ];
581
- }
582
300
  const headers = [
583
- "Scenario",
301
+ CORE_MESSAGES[CoreMessage.ScenarioColumn],
584
302
  ...columns.map(columnHeader),
585
- "Verdict",
586
- "Reconciling items",
303
+ CORE_MESSAGES[CoreMessage.VerdictColumn],
304
+ CORE_MESSAGES[CoreMessage.ReconcilingItemsColumn],
587
305
  ];
588
306
  const lines = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
589
307
  for (const s of scenarios) {
@@ -593,89 +311,32 @@ function ledgerTableLines(scenarios, withObservations) {
593
311
  }
594
312
  return lines;
595
313
  }
596
- /**
597
- * The ordered section list (project design §2; reorganised summary-first / ledger-last,
598
- * test volume added): title → provenance → the run-integrity stamp (only when the run is
599
- * unsound) → Summary views
600
- * (Evidence reconciliation, test volume, lint evidence obligations, out-of-balance by feature)
601
- * → the ledger. The `## Warnings` section was RETIRED entirely: the
602
- * no-evidence scenario (the earlier zero-evidence warning) is now a `both-missing` ledger
603
- * row, and the scenario-corpus
604
- * census facts (untagged scenarios / prose-only codes) re-homed into the ledger summary.
605
- *
606
- * The run-integrity stamp (@SCN-RPT-021) precedes the summary views because it is a caveat ON
607
- * them, not another cut OF them — an unsound run's `missing` counts cannot be taken at face
608
- * value. It omits itself entirely on a sound run, so the ordering above is unchanged for
609
- * every report that carries no broken evidence.
610
- */
611
314
  const SECTIONS = [
612
315
  titleSection,
613
316
  provenanceSection,
317
+ configurationNotReadSection,
614
318
  runIntegritySection,
319
+ scenarioCodeErrorsSection,
615
320
  summaryViewsSection,
616
- // The verdict OPENS the summary (@SCN-RPT-022) — before any cut of the reconciliation, so a
617
- // reader meets the bottom line first and a failing run can never LOOK like a passing one.
618
321
  runVerdictSection,
619
322
  evidenceReconciliationSection,
620
323
  testVolumeSection,
621
- // The scope discovery ranged over (@SCN-USG-020), ahead of the static-check census rather than
622
- // among the dispositions below it: it is not one of the things a step became, it is the pipeline
623
- // every figure under it was read from. A reader meets what was read before what was found.
624
324
  discoveryScopeSection,
625
- staticChecksSection,
626
- // The steps recognised as behavioural runners (@SCN-USG-011), beside the static-check counts
627
- // rather than among them: a runner is what a step was recognised as INSTEAD of reaching that
628
- // census, so it belongs next to it and never inside it.
325
+ fitnessFunctionsSection,
629
326
  behaviouralRunnersSection,
630
- // The steps that are the reconciler (@SCN-USG-014), between the two: it is the disposition
631
- // nobody configured, so it reads after what the target named its runners and before the list the
632
- // consumer wrote — the tool accounting for itself before it accounts for their instructions.
633
327
  reconcilerSection,
634
- // The steps the target named non-checks (@SCN-USG-015), after the runners for the same reason
635
- // those follow the census: all three are dispositions a step got INSTEAD of reaching the books,
636
- // and they read in the order the reader meets them — what ran the suites, what this tool took
637
- // out of its own books, then what the consumer took out.
638
328
  excludedStepsSection,
639
- // What became of every step the run read (@SCN-USG-018), last of the pipeline cuts because it
640
- // is the one that counts what the four above it named. A reader meets the dispositions and then
641
- // the figure saying they are all of them — which is the axis's first non-negotiable, stated as
642
- // something checkable rather than as a sentence taken on trust.
643
329
  stepAccountingSection,
644
- lintSection,
330
+ fitnessSection,
645
331
  byFeatureSection,
646
332
  ledgerSection,
647
333
  ];
648
- /**
649
- * Render the whole-document report model to markdown — the from-model pipeline,
650
- * a PURE PRESENTATION over the same `EvidenceReconciliation` the JSON serialises. Exposed for slice 3's
651
- * render-from-saved-JSON path: a consumer rebuilds a `EvidenceReconciliation` from banked JSON and
652
- * renders the human view off it, with no `RunContext` in hand. Needs NO external map — the
653
- * feature titles are read off `report.metadata.featureNames` (@SCN-RMD-006; nested under
654
- * `metadata`), so the by-feature cut names each feature in full from the model alone.
655
- */
656
334
  export function renderReport(report) {
657
335
  return SECTIONS.flatMap((section) => section(report)).join("\n");
658
336
  }
659
- /**
660
- * Regenerate the markdown from a banked `spec-reconciliation.json` ALONE (@SCN-RMD-007)
661
- * — the render-from-saved-JSON seam. `JSON.parse` the stored whole-document report back into a
662
- * `EvidenceReconciliation` and render it via `renderReport`: no `RunContext`, no live inputs, the JSON is the
663
- * whole document. Drift-equality is the acceptance — the markdown from the saved JSON equals the
664
- * markdown rendered directly from the same run (the by-feature titles included, which is why the
665
- * feature-title model-completion @SCN-RMD-006 is the enabling dependency). The load-bearing proof
666
- * that the JSON is the model, not a lossy projection.
667
- */
668
337
  export function renderReportFromJson(json) {
669
338
  return renderReport(JSON.parse(json));
670
339
  }
671
- /**
672
- * Render the reconciliation to markdown. Keeps its `(balance, ctx, …)` signature (mirroring
673
- * `json.ts`): it assembles the shared whole-document report from the reconcile pieces —
674
- * threading the live `featureNames` title source INTO `assembleRenderInput`, so the titles
675
- * land on the model — then delegates to `renderReport`. So `writer.ts` and the unit tests are
676
- * untouched, the markdown stays byte-identical (same slugs, now model-sourced), and it is
677
- * provably a presentation of the same model the JSON serialises.
678
- */
679
340
  export function renderMarkdown(evidenceReconciliation, ctx, evidenceObligations, featureNames) {
680
341
  return renderReport(assembleRenderInput(evidenceReconciliation, ctx, evidenceObligations, featureNames));
681
342
  }