@3f-consulting/spec-controller-core 0.1.0-alpha.8 → 0.1.0-alpha.81

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 +18 -53
  3. package/dist/assemble/evidenceReconciliationFromSources.d.ts.map +1 -1
  4. package/dist/assemble/evidenceReconciliationFromSources.js +31 -326
  5. package/dist/assemble/evidenceReconciliationFromSources.js.map +1 -1
  6. package/dist/assemble/renderInput.d.ts +15 -81
  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 +18 -52
  11. package/dist/census/evidenceObligations.d.ts.map +1 -1
  12. package/dist/census/evidenceObligations.js +22 -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 +45 -410
  19. package/dist/domain/evidenceReconciliation.d.ts.map +1 -1
  20. package/dist/domain/evidenceReconciliation.js +74 -388
  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 +27 -411
  27. package/dist/domain/journal.d.ts.map +1 -1
  28. package/dist/domain/journal.js +7 -118
  29. package/dist/domain/journal.js.map +1 -1
  30. package/dist/domain/ledger.d.ts +37 -357
  31. package/dist/domain/ledger.d.ts.map +1 -1
  32. package/dist/domain/ledger.js +43 -346
  33. package/dist/domain/ledger.js.map +1 -1
  34. package/dist/domain/messages.d.ts +224 -0
  35. package/dist/domain/messages.d.ts.map +1 -0
  36. package/dist/domain/messages.js +335 -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 +24 -121
  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 +4 -44
  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 +16 -288
  51. package/dist/domain/qualityCheckReconciliation.d.ts.map +1 -1
  52. package/dist/domain/qualityCheckReconciliation.js +84 -479
  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 +19 -216
  63. package/dist/domain/scenarioReconciliation.d.ts.map +1 -1
  64. package/dist/domain/scenarioReconciliation.js +29 -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 +6 -169
  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 +20 -227
  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 +13 -66
  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 +4 -61
  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 +5 -230
  134. package/dist/parse/scenarios.d.ts.map +1 -1
  135. package/dist/parse/scenarios.js +25 -467
  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 +3 -80
  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 +8 -68
  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 +30 -138
  162. package/dist/provenance/provenance.d.ts.map +1 -1
  163. package/dist/provenance/provenance.js +5 -65
  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 +8 -71
  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 +3 -37
  178. package/dist/render/evidenceGrid.d.ts.map +1 -1
  179. package/dist/render/evidenceGrid.js +23 -107
  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 +106 -487
  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,622 +1,298 @@
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
- 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";
30
- import { isScenario } from "../domain/scenarioReconciliation.js";
1
+ import { OUTCOME_DESCRIPTION, OUT_OF_BALANCE_OUTCOMES } from "../model.js";
2
+ import { isObservationOnly } from "../domain/qualityCheckReconciliation.js";
3
+ import { untaggedScenarioId, scenariosBehindAccountsOf, } from "../census/evidenceObligations.js";
4
+ import { brokenItemsOf, fitnessReconciliationsOf } from "../domain/ledger.js";
5
+ import { isScenario, isPhantomScenario, isSuspenseScenario, isOutOfBalance, outOfBalanceCountOf, carriesNoScnCode, } from "../domain/scenarioReconciliation.js";
31
6
  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
7
  import { hasBrokenEvidence } from "../domain/ledger.js";
39
8
  import { runVerdictSection } from "./runVerdict.js";
9
+ import { scenarioCodeErrorsSection } from "./scenarioCodeErrorsSection.js";
40
10
  import { provenanceSection } from "./provenanceSection.js";
11
+ import { configurationNotReadSection } from "./configurationNotRead.js";
41
12
  import { discoveryScopeSection } from "./discoveryScope.js";
42
- import { staticChecksSection } from "./staticChecks.js";
13
+ import { fitnessFunctionsSection } from "./fitnessFunctions.js";
43
14
  import { behaviouralRunnersSection } from "./behaviouralRunners.js";
44
15
  import { excludedStepsSection } from "./excludedSteps.js";
45
16
  import { reconcilerSection } from "./reconcilerSteps.js";
46
17
  import { stepAccountingSection } from "./stepAccounting.js";
47
18
  import { testVolumeSection } from "./testVolume.js";
48
- import { featureCodeOf } from "../lib/featureCode.js";
19
+ import { featureCodeOf } from "../domain/featureCode.js";
49
20
  import { evidenceColumns, columnHeader, evidenceCell } from "./evidenceGrid.js";
50
- /** A short status glyph per reconciling-item outcome — a visual cue alongside the word. */
21
+ import { CORE_MESSAGES, CoreMessage } from "../domain/messages.js";
51
22
  const ITEM_GLYPH = {
52
23
  satisfied: "✓",
53
24
  "failing": "✗",
54
- // The watermelon: green on the surface (a static check exists), red within (not wired
55
- // into CI over the real source).
56
25
  "watermelon": "🍉",
57
26
  "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
27
  "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).
28
+ "duplicate-scn-code": "⧉",
65
29
  "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
30
  "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
31
  "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
32
  "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
33
  "both-missing": "⊘",
79
34
  };
80
- /** A short glyph per scenario verdict. */
81
35
  const VERDICT_GLYPH = {
82
36
  balanced: "✓",
83
37
  "out-of-balance": "✗",
84
38
  };
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
39
  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
40
  if (item.outcome === "both-missing") {
99
41
  return `${ITEM_GLYPH["both-missing"]} ${OUTCOME_DESCRIPTION["both-missing"]}`;
100
42
  }
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
43
  if (item.outcome === "no-scn-code") {
105
44
  return `${ITEM_GLYPH["no-scn-code"]} ${OUTCOME_DESCRIPTION["no-scn-code"]}`;
106
45
  }
46
+ if (item.qualityCheck === undefined) {
47
+ return `${ITEM_GLYPH[item.outcome]} ${OUTCOME_DESCRIPTION[item.outcome]}`;
48
+ }
107
49
  const subject = item.qualityCheck;
108
- const suffix = creditSuffix(item) ?? "";
50
+ const suffix = observationSuffix(item) ?? "";
109
51
  return `${subject} ${ITEM_GLYPH[item.outcome]} ${OUTCOME_DESCRIPTION[item.outcome]}${suffix}`;
110
52
  }
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
- 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
- const profile = OUTCOME_SIDE_PROFILE[item.outcome];
128
- 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.
53
+ function observationSuffix(item) {
54
+ const observationOnly = isObservationOnly(item.outcome);
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})`;
143
- if (isCredit && item.locator !== undefined)
61
+ if (observationOnly && item.fitnessFunction)
62
+ return ` (${item.fitnessFunction})`;
63
+ if (observationOnly && 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 only when no Scenario Reconciliation of ANY kind is out of balance; the total line
447
- * names the out-of-balance phantom/suspense rows the table cannot group (@SCN-RPT-030). A pure derive over
448
- * `report.ledger.scenarioReconciliations` (+ the model's `metadata.featureNames` title lookup);
449
- * markdown-only.
450
- */
451
180
  const byFeatureSection = (report) => {
452
- // The TABLE groups scenario rows only (ruling Q3) — a phantom/suspense row names no feature file,
453
- // so it has no group to sit in. The SENTENCE still counts it (@SCN-RPT-030, 3F-3297).
454
181
  const rows = report.ledger.scenarioReconciliations;
455
182
  const scenarios = rows.filter(isScenario);
456
- const groups = outOfBalanceByFeature(scenarios);
183
+ const groups = outOfBalanceByFeature(scenarios, report.evidenceObligations?.untaggedScenarios ?? [], report.metadata?.featureNames ?? {});
457
184
  const ungrouped = ungroupedOutOfBalance(report.summary.scenarioReconciliations);
458
185
  if (groups.size === 0 && ungrouped.length === 0)
459
186
  return [];
460
- const outOfBalance = scenarios.filter((s) => s.verdict === "out-of-balance").length;
461
- // Rows, not "out-of-balance scenarios": the old head read as M rows out of balance (3F-3297).
462
- const head = `${outOfBalance} of ${scenarios.length} scenario rows out of balance`;
187
+ const head = CORE_MESSAGES[CoreMessage.ScenarioRowsOutOfBalance](outOfBalanceCountOf(scenarios), scenarios.length);
463
188
  return [
464
- "### Out-of-balance by feature",
189
+ CORE_MESSAGES[CoreMessage.OutOfBalanceByFeatureHeading],
465
190
  "",
466
- `${head}${groups.size > 0 ? ", grouped by feature" : ""}${ungroupedTail(ungrouped)}.`,
191
+ `${head}${groups.size > 0 ? CORE_MESSAGES[CoreMessage.GroupedByFeature] : ""}${ungroupedTail(ungrouped)}.`,
467
192
  "",
468
193
  ...byFeatureTableLines(groups, report.metadata?.featureNames),
469
194
  ];
470
195
  };
471
- /** The out-of-balance scenario rows' ids under their feature code, in first-appearance order. */
472
- function outOfBalanceByFeature(scenarios) {
196
+ function outOfBalanceByFeature(scenarios, untagged, featureNames) {
197
+ const codeOfFile = new Map(Object.entries(featureNames).map(([code, slug]) => [`${slug}.feature`, code]));
198
+ const featureOf = new Map(untagged.map((u) => [untaggedScenarioId(u), codeOfFile.get(u.feature) ?? u.feature]));
473
199
  const groups = new Map();
474
200
  for (const s of scenarios) {
475
- if (s.verdict !== "out-of-balance")
201
+ if (!isOutOfBalance(s))
476
202
  continue;
477
- const code = featureCodeOf(s.scnId) ?? s.scnId;
478
- const ids = groups.get(code) ?? [];
479
- ids.push(s.scnId);
480
- groups.set(code, ids);
203
+ const noScnCode = carriesNoScnCode(s);
204
+ const key = (noScnCode ? featureOf.get(s.scnId) : featureCodeOf(s.scnId)) ?? s.scnId;
205
+ const group = groups.get(key) ?? { ids: [], noScnCode: 0 };
206
+ groups.set(key, noScnCode ? { ...group, noScnCode: group.noScnCode + 1 } : { ...group, ids: [...group.ids, s.scnId] });
481
207
  }
482
208
  return groups;
483
209
  }
484
- /** The by-feature table, or no lines at all when no scenario row is out of balance. */
485
210
  function byFeatureTableLines(groups, featureNames) {
486
211
  if (groups.size === 0)
487
212
  return [];
488
- const lines = ["| Feature | Count | Out-of-balance `@SCN` ids |", "| --- | --- | --- |"];
489
- for (const [code, ids] of groups) {
490
- const slug = featureNames?.[code];
491
- const label = slug ? `${slug} (${code})` : code;
492
- lines.push(`| ${label} | ${ids.length} | ${ids.join(", ")} |`);
213
+ const lines = [CORE_MESSAGES[CoreMessage.ByFeatureTableHeader], "| --- | --- | --- |"];
214
+ for (const [key, group] of groups) {
215
+ const slug = featureNames?.[key];
216
+ const label = slug ? `${slug} (${key})` : key;
217
+ lines.push(`| ${label} | ${group.ids.length + group.noScnCode} | ${idsCell(group)} |`);
493
218
  }
494
219
  lines.push("");
495
220
  return lines;
496
221
  }
497
- /**
498
- * The rows no feature can group, ASKED of the model's own sub-counts rather than re-counted here: the
499
- * phantom and suspense rows are always out of balance (`ScenarioReconciliationTotals`), and these are
500
- * the same sub-counts the header's Scenario Reconciliation row is made of — so the sentence adds up
501
- * to the header by construction. Zeros dropped; the ledger's own order.
502
- */
222
+ function idsCell(group) {
223
+ const ids = group.ids.join(", ");
224
+ if (group.noScnCode === 0)
225
+ return ids;
226
+ const noScnCode = CORE_MESSAGES[CoreMessage.NoScnCodeScenarios](group.noScnCode);
227
+ return ids === "" ? noScnCode : `${ids}${CORE_MESSAGES[CoreMessage.AndJoiner]}${noScnCode}`;
228
+ }
503
229
  function ungroupedOutOfBalance(totals) {
504
230
  return [
505
- ...(totals.phantom > 0 ? [`${totals.phantom} phantom-scenario row(s)`] : []),
506
- ...(totals.suspense > 0 ? [`${totals.suspense} suspense-scenario row(s)`] : []),
231
+ ...(totals.phantom > 0 ? [CORE_MESSAGES[CoreMessage.PhantomScenarioRows](totals.phantom)] : []),
232
+ ...(totals.suspense > 0 ? [CORE_MESSAGES[CoreMessage.SuspenseScenarioRows](totals.suspense)] : []),
507
233
  ];
508
234
  }
509
- /** The sentence's tail naming the ungrouped rows, or nothing when every out-of-balance row is grouped. */
510
235
  function ungroupedTail(ungrouped) {
511
236
  return ungrouped.length > 0
512
- ? `; ${ungrouped.join(" and ")} out of balance, not grouped by feature (no feature file names them)`
237
+ ? `; ${CORE_MESSAGES[CoreMessage.NotGroupedByFeature](ungrouped.join(CORE_MESSAGES[CoreMessage.AndJoiner]))}`
513
238
  : "";
514
239
  }
515
- /**
516
- * The ledger (moved to the END) — the per-scenario table, with its summary lines on top: the
517
- * verdict tally bullet, and (when an evidence-obligation census is threaded) the
518
- * excluded-occurrence bullet relocated here from the dissolved corpus-census section (Q1a). When
519
- * the evidence-observation aggregate is threaded, the row gains corpus-driven evidence columns
520
- * between Scenario and Verdict, each a four-way obligation×observation cell; without the aggregate
521
- * the original three-column ledger is preserved (the 2-/3-arg callers stay green). Markdown-only.
522
- * The verdict-tally bullet names the collection "Scenario Reconciliation(s)" — canon's noun for
523
- * the reconciled OUTPUT collection (scenario + phantom + suspense), never "scenario(s)"
524
- * (@SCN-RPT-024, 3F-1919; "scenarios" is a Rejected alias).
525
- */
526
240
  const ledgerSection = (report) => {
527
- // Group the scenario rows first, then the PHANTOM rows, then the SUSPENSE row as
528
- // distinct blocks at the END (ruling Q3) — a phantom/suspense is not a
529
- // scenario, so it never mingles with the scenario verdict rows.
530
241
  const scenarioRows = report.ledger.scenarioReconciliations.filter(isScenario);
531
- const phantomRows = report.ledger.scenarioReconciliations.filter((s) => s.kind === "phantom-scenario");
532
- const suspenseRows = report.ledger.scenarioReconciliations.filter((s) => s.kind === "suspense-scenario");
242
+ const phantomRows = report.ledger.scenarioReconciliations.filter(isPhantomScenario);
243
+ const suspenseRows = report.ledger.scenarioReconciliations.filter(isSuspenseScenario);
533
244
  const scenarios = [...scenarioRows, ...phantomRows, ...suspenseRows];
534
- // The runtime-observation axis is IN PLAY iff the summary carries the test-volume fold
535
- // (@SCN-RPT-010 / 3F-2103 — the gate that was the retired `evidenceObservations` section's
536
- // presence). When it is, the ledger gains the corpus-driven evidence columns.
537
245
  const withObservations = report.summary.testVolume !== undefined;
538
246
  return [
539
- // The reconciled per-scenario OUTPUT table — "Scenario Reconciliations", not "Ledger"
540
- // ("Ledger" is the INPUT concept in canon; double-entry-manifesto).
541
- "## Scenario Reconciliations",
247
+ CORE_MESSAGES[CoreMessage.ScenarioReconciliationsHeading],
542
248
  "",
543
249
  ...ledgerTallyLines(report.summary.scenarioReconciliations),
544
- ...(report.evidenceObligations ? censusLines(report.evidenceObligations) : []),
250
+ ...(report.evidenceObligations ? censusLines(report.evidenceObligations, scenarioRows.length) : []),
545
251
  "",
546
252
  ...ledgerTableLines(scenarios, withObservations),
547
253
  "",
548
254
  ];
549
255
  };
550
- /** The ledger's verdict tally bullet, and the phantom / suspense sub-counts broken out of it. */
551
256
  function ledgerTallyLines(totals) {
552
257
  const lines = [
553
- // The headline verdict tally counts EVERY Scenario Reconciliation — scenario, phantom and
554
- // suspense alike (@SCN-RMD-001, 3F-1724; the scenario-only "ruling Q3" is retracted).
555
- `- ${totals.total} Scenario Reconciliation(s): ${totals.balanced} balanced, ` +
556
- `${totals.outOfBalance} out-of-balance.`,
258
+ `- ${CORE_MESSAGES[CoreMessage.ScenarioReconciliationTally](totals.total, totals.balanced, totals.outOfBalance)}`,
557
259
  ];
558
- // The phantom (P) count is broken out as a SUB-COUNT of the tally above — it is counted
559
- // INSIDE total / out-of-balance (a phantom reconciliation is always out-of-balance), and
560
- // named here so a reader sees what makes up the total.
561
260
  if (totals.phantom > 0) {
562
- lines.push(`- ${totals.phantom} phantom-scenario(s): cited by an evidence observation, named by no feature.`);
261
+ lines.push(`- ${CORE_MESSAGES[CoreMessage.PhantomScenarioTally](totals.phantom)}`);
563
262
  }
564
- // The suspense (S) count is broken out the same way — credits citing no scenario at all
565
- // (uncited evidence observations + uncited static-checks), aggregated into one row,
566
- // counted INSIDE the tally above.
567
263
  if (totals.suspense > 0) {
568
- lines.push(`- ${totals.suspense} suspense-scenario(s): credits citing no scenario, aggregated into "(no scenario)".`);
264
+ lines.push(`- ${CORE_MESSAGES[CoreMessage.SuspenseScenarioTally](totals.suspense)}`);
569
265
  }
570
266
  return lines;
571
267
  }
572
- /**
573
- * The scenario-corpus CENSUS — obligation-side facts on report.evidenceObligations.
574
- * These re-homed here from the dissolved `## Warnings` section: they are census
575
- * facts, NOT warnings and NOT reconciling items, so they ride in the ledger summary co-located
576
- * with the excluded-occurrence bullet (the evidenceObligations census view). Facts only —
577
- * count + locator.
578
- */
579
- function censusLines(census) {
580
- const lines = [`- ${census.excludedOccurrences} @SCN ids seen in non-tag context, excluded`];
581
- // Untagged scenarios (@SCN-RPT-014) — Gherkin scenarios with no @SCN tag, located by
582
- // `feature : scenario`. Sorted defensively (a hand-built corpus fixture may be unsorted).
268
+ function censusLines(census, accounts) {
269
+ const lines = [`- ${CORE_MESSAGES[CoreMessage.ExcludedScnOccurrences](census.excludedOccurrences)}`];
583
270
  const untagged = census.untaggedScenarios;
584
271
  if (untagged.length > 0) {
585
272
  const sorted = [...untagged].sort((a, b) => a.feature.localeCompare(b.feature) || a.scenario.localeCompare(b.scenario));
586
- lines.push(`- ${untagged.length} scenario(s) carry no @SCN tag:`);
273
+ lines.push(`- ${CORE_MESSAGES[CoreMessage.UntaggedScenarios](untagged.length)}`);
587
274
  for (const { feature, scenario } of sorted) {
588
275
  lines.push(` - ${feature} : ${scenario}`);
589
276
  }
590
277
  }
591
- // Prose-only codes (@SCN-RPT-015) — @SCN ids mentioned in prose but never tagged anywhere
592
- // (the dangling references). The bare code is its own locator. Sorted, after untagged.
593
- const proseOnly = census.proseOnlyCodes;
594
- if (proseOnly.length > 0) {
595
- lines.push(`- ${proseOnly.length} @SCN code(s) appear only in prose, never as a tag:`);
596
- for (const code of [...proseOnly].sort()) {
597
- lines.push(` - ${code}`);
598
- }
599
- }
278
+ lines.push(...sharedScnCodeLines(census.sharedScnCodes ?? [], accounts));
600
279
  return lines;
601
280
  }
602
- /**
603
- * The per-scenario table: with the corpus-driven evidence columns between Scenario and Verdict when
604
- * the runtime-observation axis is in play and any column appears, else the original three columns.
605
- */
281
+ function sharedScnCodeLines(shared, accounts) {
282
+ if (shared.length === 0)
283
+ return [];
284
+ return [
285
+ `- ${CORE_MESSAGES[CoreMessage.SharedScnCodes](scenariosBehindAccountsOf(accounts, shared), accounts, shared.length)}`,
286
+ ...shared.map((code) => ` - ${code.scnId}: ${code.scenarios.map((s) => `${s.feature} : ${s.scenario}`).join("; ")}`),
287
+ ];
288
+ }
606
289
  function ledgerTableLines(scenarios, withObservations) {
607
290
  const columns = withObservations ? evidenceColumns(scenarios) : [];
608
- if (!(withObservations && columns.length > 0)) {
609
- return [
610
- "| Scenario | Verdict | Reconciling items |",
611
- "| --- | --- | --- |",
612
- ...scenarios.map(renderScenario),
613
- ];
614
- }
615
291
  const headers = [
616
- "Scenario",
292
+ CORE_MESSAGES[CoreMessage.ScenarioColumn],
617
293
  ...columns.map(columnHeader),
618
- "Verdict",
619
- "Reconciling items",
294
+ CORE_MESSAGES[CoreMessage.VerdictColumn],
295
+ CORE_MESSAGES[CoreMessage.ReconcilingItemsColumn],
620
296
  ];
621
297
  const lines = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
622
298
  for (const s of scenarios) {
@@ -626,89 +302,32 @@ function ledgerTableLines(scenarios, withObservations) {
626
302
  }
627
303
  return lines;
628
304
  }
629
- /**
630
- * The ordered section list (project design §2; reorganised summary-first / ledger-last,
631
- * test volume added): title → provenance → the run-integrity stamp (only when the run is
632
- * unsound) → Summary views
633
- * (Evidence reconciliation, test volume, lint evidence obligations, out-of-balance by feature)
634
- * → the ledger. The `## Warnings` section was RETIRED entirely: the
635
- * no-evidence scenario (the earlier zero-evidence warning) is now a `both-missing` ledger
636
- * row, and the scenario-corpus
637
- * census facts (untagged scenarios / prose-only codes) re-homed into the ledger summary.
638
- *
639
- * The run-integrity stamp (@SCN-RPT-021) precedes the summary views because it is a caveat ON
640
- * them, not another cut OF them — an unsound run's `missing` counts cannot be taken at face
641
- * value. It omits itself entirely on a sound run, so the ordering above is unchanged for
642
- * every report that carries no broken evidence.
643
- */
644
305
  const SECTIONS = [
645
306
  titleSection,
646
307
  provenanceSection,
308
+ configurationNotReadSection,
647
309
  runIntegritySection,
310
+ scenarioCodeErrorsSection,
648
311
  summaryViewsSection,
649
- // The verdict OPENS the summary (@SCN-RPT-022) — before any cut of the reconciliation, so a
650
- // reader meets the bottom line first and a failing run can never LOOK like a passing one.
651
312
  runVerdictSection,
652
313
  evidenceReconciliationSection,
653
314
  testVolumeSection,
654
- // The scope discovery ranged over (@SCN-USG-020), ahead of the static-check census rather than
655
- // among the dispositions below it: it is not one of the things a step became, it is the pipeline
656
- // every figure under it was read from. A reader meets what was read before what was found.
657
315
  discoveryScopeSection,
658
- staticChecksSection,
659
- // The steps recognised as behavioural runners (@SCN-USG-011), beside the static-check counts
660
- // rather than among them: a runner is what a step was recognised as INSTEAD of reaching that
661
- // census, so it belongs next to it and never inside it.
316
+ fitnessFunctionsSection,
662
317
  behaviouralRunnersSection,
663
- // The steps that are the reconciler (@SCN-USG-014), between the two: it is the disposition
664
- // nobody configured, so it reads after what the target named its runners and before the list the
665
- // consumer wrote — the tool accounting for itself before it accounts for their instructions.
666
318
  reconcilerSection,
667
- // The steps the target named non-checks (@SCN-USG-015), after the runners for the same reason
668
- // those follow the census: all three are dispositions a step got INSTEAD of reaching the books,
669
- // and they read in the order the reader meets them — what ran the suites, what this tool took
670
- // out of its own books, then what the consumer took out.
671
319
  excludedStepsSection,
672
- // What became of every step the run read (@SCN-USG-018), last of the pipeline cuts because it
673
- // is the one that counts what the four above it named. A reader meets the dispositions and then
674
- // the figure saying they are all of them — which is the axis's first non-negotiable, stated as
675
- // something checkable rather than as a sentence taken on trust.
676
320
  stepAccountingSection,
677
- lintSection,
321
+ fitnessSection,
678
322
  byFeatureSection,
679
323
  ledgerSection,
680
324
  ];
681
- /**
682
- * Render the whole-document report model to markdown — the from-model pipeline,
683
- * a PURE PRESENTATION over the same `EvidenceReconciliation` the JSON serialises. Exposed for slice 3's
684
- * render-from-saved-JSON path: a consumer rebuilds a `EvidenceReconciliation` from banked JSON and
685
- * renders the human view off it, with no `RunContext` in hand. Needs NO external map — the
686
- * feature titles are read off `report.metadata.featureNames` (@SCN-RMD-006; nested under
687
- * `metadata`), so the by-feature cut names each feature in full from the model alone.
688
- */
689
325
  export function renderReport(report) {
690
326
  return SECTIONS.flatMap((section) => section(report)).join("\n");
691
327
  }
692
- /**
693
- * Regenerate the markdown from a banked `spec-reconciliation.json` ALONE (@SCN-RMD-007)
694
- * — the render-from-saved-JSON seam. `JSON.parse` the stored whole-document report back into a
695
- * `EvidenceReconciliation` and render it via `renderReport`: no `RunContext`, no live inputs, the JSON is the
696
- * whole document. Drift-equality is the acceptance — the markdown from the saved JSON equals the
697
- * markdown rendered directly from the same run (the by-feature titles included, which is why the
698
- * feature-title model-completion @SCN-RMD-006 is the enabling dependency). The load-bearing proof
699
- * that the JSON is the model, not a lossy projection.
700
- */
701
328
  export function renderReportFromJson(json) {
702
329
  return renderReport(JSON.parse(json));
703
330
  }
704
- /**
705
- * Render the reconciliation to markdown. Keeps its `(balance, ctx, …)` signature (mirroring
706
- * `json.ts`): it assembles the shared whole-document report from the reconcile pieces —
707
- * threading the live `featureNames` title source INTO `assembleRenderInput`, so the titles
708
- * land on the model — then delegates to `renderReport`. So `writer.ts` and the unit tests are
709
- * untouched, the markdown stays byte-identical (same slugs, now model-sourced), and it is
710
- * provably a presentation of the same model the JSON serialises.
711
- */
712
331
  export function renderMarkdown(evidenceReconciliation, ctx, evidenceObligations, featureNames) {
713
332
  return renderReport(assembleRenderInput(evidenceReconciliation, ctx, evidenceObligations, featureNames));
714
333
  }