lazycodex-ai 5.1.45 → 5.1.46

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 (236) hide show
  1. package/README.de.md +0 -5
  2. package/README.es.md +0 -5
  3. package/README.fr.md +0 -5
  4. package/README.it.md +0 -5
  5. package/README.ja.md +0 -5
  6. package/README.ko.md +0 -5
  7. package/README.md +4 -5
  8. package/README.pt-br.md +0 -5
  9. package/README.ru.md +0 -6
  10. package/README.zh-cn.md +0 -5
  11. package/dist/cli/index.js +76 -156
  12. package/dist/cli-node/index.js +76 -156
  13. package/package.json +1 -1
  14. package/packages/lsp-daemon/dist/cli.js +3 -3
  15. package/packages/lsp-daemon/dist/client.js +3 -3
  16. package/packages/lsp-daemon/dist/index.js +3 -3
  17. package/packages/lsp-tools-mcp/dist/cli.js +3 -3
  18. package/packages/lsp-tools-mcp/dist/mcp.js +3 -3
  19. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  20. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +22 -12
  21. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  22. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  23. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  24. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  25. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  26. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  27. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  28. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  29. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  30. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  31. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  33. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  34. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  35. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  36. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  37. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  38. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  39. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  40. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  41. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  42. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  43. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  44. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  45. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  46. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  47. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  48. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  49. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  65. package/packages/omo-codex/plugin/package-lock.json +12 -12
  66. package/packages/omo-codex/plugin/package.json +1 -1
  67. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +8 -3
  68. package/packages/omo-codex/plugin/shared/test/config-loader.test.ts +0 -2
  69. package/packages/omo-codex/plugin/skills/ast-grep/AGENTS.md +1 -1
  70. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  71. package/packages/omo-codex/plugin/skills/frontend/ATTRIBUTION.md +2 -2
  72. package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/AGENTS.md +1 -1
  73. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +14 -2
  74. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +36 -11
  75. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +73 -13
  76. package/packages/omo-codex/plugin/skills/ulw-research/references/fact-ledger-grammar.md +19 -1
  77. package/packages/omo-codex/plugin/skills/ulw-research/references/fact-ledger.md +95 -0
  78. package/packages/omo-codex/plugin/skills/ulw-research/references/fidelity-claim-kinds.md +19 -0
  79. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +21 -4
  80. package/packages/omo-codex/plugin/skills/ulw-research/references/report-visualization.md +2 -0
  81. package/packages/omo-codex/plugin/skills/ulw-research/references/unit-rules.md +157 -0
  82. package/packages/omo-codex/plugin/skills/ulw-research/scripts/claim-kinds.mjs +116 -0
  83. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +19 -5
  84. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +27 -6
  85. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +191 -0
  86. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +164 -0
  87. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +239 -0
  88. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-derive.mjs +66 -0
  89. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-expr.mjs +108 -0
  90. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-fill.mjs +194 -0
  91. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger-kinds.mjs +127 -13
  92. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger-schema.mjs +33 -16
  93. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-ledger.mjs +29 -9
  94. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-locate.mjs +231 -0
  95. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-pairs.mjs +187 -0
  96. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-recompute.mjs +89 -0
  97. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-binding.mjs +130 -0
  98. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-brief.mjs +5 -0
  99. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-claims.mjs +89 -0
  100. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-lexicon.mjs +145 -0
  101. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-records.mjs +163 -0
  102. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-state.mjs +2 -1
  103. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-trace.mjs +85 -0
  104. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-values.mjs +229 -0
  105. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-fidelity.mjs +113 -0
  106. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +28 -8
  107. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +2 -2
  108. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-markup.mjs +182 -0
  109. package/packages/omo-codex/plugin/skills/ulw-research/scripts/md-lite.mjs +193 -0
  110. package/packages/omo-codex/plugin/skills/ulw-research/scripts/meaning-check.mjs +196 -0
  111. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +6 -5
  112. package/packages/omo-codex/plugin/skills/ulw-research/scripts/placeholder.mjs +184 -0
  113. package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-apply.mjs +115 -0
  114. package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-diff.mjs +31 -0
  115. package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-project.mjs +125 -0
  116. package/packages/omo-codex/plugin/skills/ulw-research/scripts/polish-rules.mjs +170 -0
  117. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +19 -8
  118. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +15 -0
  119. package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-commands.mjs +140 -0
  120. package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-snapshot.mjs +176 -0
  121. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-algebra.mjs +101 -0
  122. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-errors.mjs +23 -0
  123. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-metadata.mjs +31 -0
  124. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-pairs.mjs +72 -0
  125. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-precision.mjs +44 -0
  126. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-rules-shapes.mjs +64 -0
  127. package/packages/omo-codex/plugin/skills/ulw-research/scripts/unit-rules.mjs +173 -0
  128. package/packages/omo-codex/plugin/skills/ulw-research/scripts/units.mjs +4 -4
  129. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +19 -0
  130. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +7 -3
  131. package/packages/omo-codex/scripts/install-dist/install-local.mjs +26 -16
  132. package/packages/shared-skills/skills/ast-grep/AGENTS.md +1 -1
  133. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
  134. package/packages/shared-skills/skills/frontend/ATTRIBUTION.md +2 -2
  135. package/packages/shared-skills/skills/ultimate-browsing/engine/AGENTS.md +1 -1
  136. package/packages/shared-skills/skills/ulw-research/AGENTS.md +14 -2
  137. package/packages/shared-skills/skills/ulw-research/SKILL.md +37 -12
  138. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +73 -13
  139. package/packages/shared-skills/skills/ulw-research/references/fact-ledger-grammar.md +19 -1
  140. package/packages/shared-skills/skills/ulw-research/references/fact-ledger.md +95 -0
  141. package/packages/shared-skills/skills/ulw-research/references/fidelity-claim-kinds.md +19 -0
  142. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +21 -4
  143. package/packages/shared-skills/skills/ulw-research/references/report-visualization.md +2 -0
  144. package/packages/shared-skills/skills/ulw-research/references/unit-rules.md +157 -0
  145. package/packages/shared-skills/skills/ulw-research/scripts/claim-kinds.mjs +116 -0
  146. package/packages/shared-skills/skills/ulw-research/scripts/claim-kinds.test.ts +193 -0
  147. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +19 -5
  148. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +13 -2
  149. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +27 -6
  150. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +3 -0
  151. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns.test.ts +183 -0
  152. package/packages/shared-skills/skills/ulw-research/scripts/delivery-barrier.test.ts +245 -0
  153. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +191 -0
  154. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +164 -0
  155. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +239 -0
  156. package/packages/shared-skills/skills/ulw-research/scripts/fact-derive.mjs +66 -0
  157. package/packages/shared-skills/skills/ulw-research/scripts/fact-derive.test.ts +375 -0
  158. package/packages/shared-skills/skills/ulw-research/scripts/fact-expr.mjs +108 -0
  159. package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.mjs +194 -0
  160. package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.test.ts +221 -0
  161. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-grammar.test.ts +9 -2
  162. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-kinds.mjs +127 -13
  163. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-kinds.test.ts +18 -3
  164. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-legacy.test.ts +160 -0
  165. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger-schema.mjs +33 -16
  166. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger.mjs +29 -9
  167. package/packages/shared-skills/skills/ulw-research/scripts/fact-ledger.test.ts +2 -0
  168. package/packages/shared-skills/skills/ulw-research/scripts/fact-locate.mjs +231 -0
  169. package/packages/shared-skills/skills/ulw-research/scripts/fact-locate.test.ts +118 -0
  170. package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.mjs +187 -0
  171. package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.test.ts +167 -0
  172. package/packages/shared-skills/skills/ulw-research/scripts/fact-recompute.mjs +89 -0
  173. package/packages/shared-skills/skills/ulw-research/scripts/fact-unit-dimensions.test.ts +409 -0
  174. package/packages/shared-skills/skills/ulw-research/scripts/fact-unit-rules.test.ts +388 -0
  175. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-binding.mjs +130 -0
  176. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-binding.test.ts +81 -0
  177. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-brief.mjs +5 -0
  178. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-claims.mjs +89 -0
  179. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-lexicon.mjs +145 -0
  180. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-lexicon.test.ts +117 -0
  181. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-records.mjs +163 -0
  182. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-state.mjs +2 -1
  183. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-trace.mjs +85 -0
  184. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-trace.test.ts +58 -0
  185. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-values.mjs +229 -0
  186. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-values.test.ts +270 -0
  187. package/packages/shared-skills/skills/ulw-research/scripts/gates-fidelity.mjs +113 -0
  188. package/packages/shared-skills/skills/ulw-research/scripts/gates-fidelity.test.ts +73 -0
  189. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +28 -8
  190. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +2 -2
  191. package/packages/shared-skills/skills/ulw-research/scripts/html-markup.mjs +182 -0
  192. package/packages/shared-skills/skills/ulw-research/scripts/md-lite.mjs +193 -0
  193. package/packages/shared-skills/skills/ulw-research/scripts/md-lite.test.ts +271 -0
  194. package/packages/shared-skills/skills/ulw-research/scripts/meaning-check.mjs +196 -0
  195. package/packages/shared-skills/skills/ulw-research/scripts/meaning-check.test.ts +228 -0
  196. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +6 -5
  197. package/packages/shared-skills/skills/ulw-research/scripts/placeholder.mjs +184 -0
  198. package/packages/shared-skills/skills/ulw-research/scripts/placeholder.test.ts +77 -0
  199. package/packages/shared-skills/skills/ulw-research/scripts/polish-apply.mjs +115 -0
  200. package/packages/shared-skills/skills/ulw-research/scripts/polish-apply.test.ts +363 -0
  201. package/packages/shared-skills/skills/ulw-research/scripts/polish-diff.mjs +31 -0
  202. package/packages/shared-skills/skills/ulw-research/scripts/polish-diff.test.ts +168 -0
  203. package/packages/shared-skills/skills/ulw-research/scripts/polish-markup.test.ts +187 -0
  204. package/packages/shared-skills/skills/ulw-research/scripts/polish-project.mjs +125 -0
  205. package/packages/shared-skills/skills/ulw-research/scripts/polish-rules.mjs +170 -0
  206. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +19 -8
  207. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +15 -0
  208. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +458 -2
  209. package/packages/shared-skills/skills/ulw-research/scripts/source-commands.mjs +140 -0
  210. package/packages/shared-skills/skills/ulw-research/scripts/source-snapshot.mjs +176 -0
  211. package/packages/shared-skills/skills/ulw-research/scripts/source-snapshot.test.ts +113 -0
  212. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/assets/share.csv +3 -0
  213. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/gen-legacy-dev.mjs +89 -0
  214. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-basic.json +223 -0
  215. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-legacy-dev.json +859 -0
  216. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/ledger-verify.json +420 -0
  217. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/report-bad.html +19 -0
  218. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/report-good.html +39 -0
  219. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/acronyms.json +3 -0
  220. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/meta.json +9 -0
  221. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/sentences.jsonl +7 -0
  222. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4/text.txt +7 -0
  223. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S4.html +19 -0
  224. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/acronyms.json +1 -0
  225. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/meta.json +9 -0
  226. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/sentences.jsonl +1 -0
  227. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S5/text.txt +1 -0
  228. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/sources/S9.txt +1 -0
  229. package/packages/shared-skills/skills/ulw-research/scripts/unit-algebra.mjs +101 -0
  230. package/packages/shared-skills/skills/ulw-research/scripts/unit-errors.mjs +23 -0
  231. package/packages/shared-skills/skills/ulw-research/scripts/unit-metadata.mjs +31 -0
  232. package/packages/shared-skills/skills/ulw-research/scripts/unit-pairs.mjs +72 -0
  233. package/packages/shared-skills/skills/ulw-research/scripts/unit-precision.mjs +44 -0
  234. package/packages/shared-skills/skills/ulw-research/scripts/unit-rules-shapes.mjs +64 -0
  235. package/packages/shared-skills/skills/ulw-research/scripts/unit-rules.mjs +173 -0
  236. package/packages/shared-skills/skills/ulw-research/scripts/units.mjs +4 -4
@@ -0,0 +1,228 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+
6
+ import { DRIFT_KINDS, ingestVerdicts } from "./meaning-check.mjs"
7
+
8
+ type Verdict = Record<string, unknown>
9
+ type Result = {
10
+ defects: { code: string; severity: string; integrity: boolean; pair_id: string; drift_kind: string; fragment: string; source_fragment: string }[]
11
+ dropped: { pair_id: string | null; reason: string; model_tier: string | null; file: string }[]
12
+ unverified: { pair_id: string; reason: string }[]
13
+ calibrationLines: string[]
14
+ }
15
+
16
+ const PAIRS = [
17
+ { pair_id: "P1", kind: "quote", sentence_key: "findings#Q1", fact_ids: ["Q1"], quote: "The pilot covered North America.", sentence: "The rollout was worldwide.", force_strong: true },
18
+ { pair_id: "P2", kind: "claim", claim_id: "C2", statement: "Revenue may grow in 2026.", sentence: "Revenue will grow in 2026.", force_strong: true },
19
+ ]
20
+
21
+ const base = (tier: string, extra: Verdict = {}): Verdict => ({
22
+ pair_id: "P1",
23
+ verdict: "drift",
24
+ drift_kind: "scope_widened",
25
+ fragment: "worldwide",
26
+ source_fragment: "North America",
27
+ explanation: "The assertion broadens the reported region.",
28
+ model_tier: tier,
29
+ ...extra,
30
+ })
31
+
32
+ let dir = ""
33
+ beforeEach(() => {
34
+ dir = mkdtempSync(join(tmpdir(), "meaning-check-"))
35
+ })
36
+ afterEach(() => {
37
+ rmSync(dir, { recursive: true, force: true })
38
+ })
39
+
40
+ function file(name: string, verdicts: Verdict[]): string {
41
+ const path = join(dir, name)
42
+ writeFileSync(path, JSON.stringify(verdicts))
43
+ return path
44
+ }
45
+
46
+ function run(cheap: Verdict[], strong: Verdict[] = [], options: object = {}): Result {
47
+ return ingestVerdicts(PAIRS, [file("cheap.json", cheap), file("strong.json", strong)], options) as Result
48
+ }
49
+
50
+ describe("ingestVerdicts: tier agreement", () => {
51
+ test("#given cheap and strong agree on scope_widened #when ingested #then meaning_drift_confirmed (major, non-integrity)", () => {
52
+ const result = run([base("cheap")], [base("strong")])
53
+ expect(result.defects).toHaveLength(1)
54
+ expect(result.defects[0]).toMatchObject({ code: "meaning_drift_confirmed", severity: "major", integrity: false, pair_id: "P1", drift_kind: "scope_widened" })
55
+ expect(result.dropped).toEqual([])
56
+ })
57
+
58
+ test("#given only the cheap tier flags #when ingested #then meaning_drift_warn (minor)", () => {
59
+ const result = run([base("cheap")])
60
+ expect(result.defects.map((d) => [d.code, d.severity])).toEqual([["meaning_drift_warn", "minor"]])
61
+ })
62
+
63
+ test("#given both tiers agree on other #when ingested #then warn only, never confirmed", () => {
64
+ const result = run([base("cheap", { drift_kind: "other" })], [base("strong", { drift_kind: "other" })])
65
+ expect(result.defects.map((d) => d.code)).toEqual(["meaning_drift_warn"])
66
+ })
67
+
68
+ test("#given the tiers name different drift kinds #when ingested #then warn only", () => {
69
+ const result = run([base("cheap")], [base("strong", { drift_kind: "subject_changed" })])
70
+ expect(result.defects.map((d) => d.code)).toEqual(["meaning_drift_warn"])
71
+ })
72
+
73
+ test("#given an ok verdict #when ingested #then no defect and no calibration line", () => {
74
+ const result = run([{ pair_id: "P1", verdict: "ok", model_tier: "cheap" }])
75
+ expect(result.defects).toEqual([])
76
+ expect(result.calibrationLines).toEqual([])
77
+ })
78
+
79
+ test("#given a claim pair #when both tiers agree on evidence_grade_raised #then confirmed against the statement", () => {
80
+ const verdict = { pair_id: "P2", verdict: "drift", drift_kind: "evidence_grade_raised", fragment: "will grow", source_fragment: "may grow", explanation: "certainty raised" }
81
+ const result = run([{ ...verdict, model_tier: "cheap" }], [{ ...verdict, model_tier: "strong" }])
82
+ expect(result.defects.map((d) => [d.code, d.pair_id])).toEqual([["meaning_drift_confirmed", "P2"]])
83
+ })
84
+ })
85
+
86
+ describe("ingestVerdicts: grounding drops", () => {
87
+ test("#given a fragment that is not in the sentence #when ingested #then dropped and counted, no defect", () => {
88
+ const result = run([base("cheap", { fragment: "Europe" })])
89
+ expect(result.defects).toEqual([])
90
+ expect(result.dropped).toHaveLength(1)
91
+ expect(result.dropped[0]).toMatchObject({ pair_id: "P1", reason: "fragment_not_found", model_tier: "cheap" })
92
+ })
93
+
94
+ test("#given a source fragment that is not in the quote #when ingested #then dropped", () => {
95
+ const result = run([base("cheap", { source_fragment: "Asia" })])
96
+ expect(result.defects).toEqual([])
97
+ expect(result.dropped.map((d) => d.reason)).toEqual(["source_fragment_not_found"])
98
+ })
99
+
100
+ test("#given empty or blank fragments #when ingested #then dropped as empty", () => {
101
+ const result = run([base("cheap", { fragment: "" })], [base("strong", { source_fragment: " " })])
102
+ expect(result.defects).toEqual([])
103
+ expect(result.dropped.map((d) => d.reason)).toEqual(["empty_fragment", "empty_fragment"])
104
+ })
105
+
106
+ test("#given an unknown drift_kind #when ingested #then dropped", () => {
107
+ const result = run([base("cheap", { drift_kind: "tone_shifted" })])
108
+ expect(result.defects).toEqual([])
109
+ expect(result.dropped.map((d) => d.reason)).toEqual(["unknown_drift_kind"])
110
+ })
111
+
112
+ test("#given an unknown pair id or tier #when ingested #then dropped", () => {
113
+ const result = run([base("cheap", { pair_id: "P99" }), base("medium")])
114
+ expect(result.dropped.map((d) => d.reason)).toEqual(["unknown_pair", "invalid_model_tier"])
115
+ })
116
+
117
+ test("#given extra whitespace in a fragment #when ingested #then it still grounds", () => {
118
+ const result = run([base("cheap", { fragment: "rollout \n was", source_fragment: " North America " })])
119
+ expect(result.dropped).toEqual([])
120
+ expect(result.defects.map((d) => d.code)).toEqual(["meaning_drift_warn"])
121
+ })
122
+
123
+ test("#given the closed drift_kind set #when read #then it holds exactly the nine kinds", () => {
124
+ expect([...DRIFT_KINDS]).toEqual([
125
+ "subject_changed", "scope_widened", "hedge_dropped", "certainty_added", "causality_added",
126
+ "direction_flipped", "population_changed", "evidence_grade_raised", "other",
127
+ ])
128
+ })
129
+ })
130
+
131
+ describe("ingestVerdicts: calibration log", () => {
132
+ test("#given a dispute on a confirmed pair #when ingested #then the defect stays and the line records disputed:true", () => {
133
+ const result = run([base("cheap")], [base("strong")], { dispute: { pair_id: "P1", reason: "Scope is justified by a second observation." } })
134
+ expect(result.defects.map((d) => d.code)).toEqual(["meaning_drift_confirmed"])
135
+ expect(result.calibrationLines.map((line) => JSON.parse(line))).toEqual([
136
+ { kind: "quote", pair_id: "P1", tiers: ["cheap", "strong"], drift_kind: "scope_widened", confirmed: true, disputed: true, reason: "Scope is justified by a second observation." },
137
+ ])
138
+ })
139
+
140
+ test("#given two flagged pairs and one ok verdict #when ingested with a calibration path #then exactly one line per flagged pair is appended", () => {
141
+ const calibrationPath = join(dir, "fidelity-calibration.jsonl")
142
+ const claimDrift = { pair_id: "P2", verdict: "drift", drift_kind: "certainty_added", fragment: "will", source_fragment: "may", explanation: "x", model_tier: "cheap" }
143
+ const cheap = [base("cheap"), claimDrift, { pair_id: "P1", verdict: "ok", model_tier: "cheap" }]
144
+ const first = run(cheap, [], { calibrationPath })
145
+ expect(first.calibrationLines).toHaveLength(2)
146
+ const lines = () => readFileSync(calibrationPath, "utf8").split("\n").filter(Boolean)
147
+ expect(lines()).toHaveLength(2)
148
+ expect(JSON.parse(lines()[1])).toEqual({ kind: "claim", pair_id: "P2", tiers: ["cheap"], drift_kind: "certainty_added", confirmed: false, disputed: false, reason: null })
149
+ run(cheap, [], { calibrationPath })
150
+ expect(lines()).toHaveLength(4)
151
+ })
152
+ })
153
+
154
+ describe("ingestVerdicts: coverage", () => {
155
+ test("#given a pair no reader judged #when ingested #then it is reported as unverified, never as clean", () => {
156
+ const result = run([base("cheap")], [base("strong")])
157
+ expect(result.unverified).toEqual([{ pair_id: "P2", reason: "no_verdict" }])
158
+ })
159
+
160
+ test("#given a force_strong pair with only a cheap verdict #when ingested #then unverified says no_strong_verdict", () => {
161
+ const result = run([base("cheap"), { pair_id: "P2", verdict: "ok", model_tier: "cheap" }])
162
+ expect(result.unverified).toEqual([
163
+ { pair_id: "P1", reason: "no_strong_verdict" },
164
+ { pair_id: "P2", reason: "no_strong_verdict" },
165
+ ])
166
+ })
167
+
168
+ test("#given every pair read by both tiers #when ingested #then unverified is empty", () => {
169
+ const ok = (pair_id: string, tier: string): Verdict => ({ pair_id, verdict: "ok", model_tier: tier })
170
+ const result = run([ok("P1", "cheap"), ok("P2", "cheap")], [ok("P1", "strong"), ok("P2", "strong")])
171
+ expect(result.unverified).toEqual([])
172
+ expect(result.defects).toEqual([])
173
+ })
174
+
175
+ test("#given only dropped verdicts on a pair #when ingested #then the pair counts as unread", () => {
176
+ const result = run([base("cheap", { fragment: "Europe" })], [base("strong", { fragment: "Europe" })])
177
+ expect(result.unverified.map((entry) => entry.pair_id)).toEqual(["P1", "P2"])
178
+ })
179
+ })
180
+
181
+ describe("ingestVerdicts: the reported fragment", () => {
182
+ test("#given an earlier other verdict #when the tiers agree on a later kind #then the fragment comes from the agreed verdict", () => {
183
+ const other = base("cheap", { drift_kind: "other", fragment: "The rollout", source_fragment: "The pilot" })
184
+ const result = run([other, base("cheap")], [base("strong")])
185
+ expect(result.defects).toHaveLength(1)
186
+ expect(result.defects[0]).toMatchObject({ code: "meaning_drift_confirmed", drift_kind: "scope_widened", fragment: "worldwide", source_fragment: "North America" })
187
+ })
188
+ })
189
+
190
+ describe("ingestVerdicts: determinism", () => {
191
+ test("#given the same verdicts twice #when ingested #then both results are byte-for-byte equal", () => {
192
+ const cheap = [base("cheap"), { pair_id: "P2", verdict: "drift", drift_kind: "certainty_added", fragment: "will grow", source_fragment: "may grow", model_tier: "cheap" }, base("medium")]
193
+ const strong = [base("strong")]
194
+ const first = JSON.stringify(run(cheap, strong))
195
+ const second = JSON.stringify(run(cheap, strong))
196
+ expect(second).toBe(first)
197
+ })
198
+ })
199
+
200
+ describe("ingestVerdicts: unreadable verdict files", () => {
201
+ test("#given a missing verdict file #when ingested #then a short typed error names the file", () => {
202
+ const missing = join(dir, "absent.json")
203
+ expect(() => ingestVerdicts(PAIRS, [missing])).toThrow(TypeError)
204
+ expect(() => ingestVerdicts(PAIRS, [missing])).toThrow(/absent\.json: cannot read the verdict file/u)
205
+ })
206
+
207
+ test("#given invalid JSON #when ingested #then a short typed error names the file", () => {
208
+ const path = join(dir, "broken.json")
209
+ writeFileSync(path, "{ not json")
210
+ expect(() => ingestVerdicts(PAIRS, [path])).toThrow(/broken\.json: the verdict file is not valid JSON/u)
211
+ })
212
+ })
213
+
214
+ describe("ingestVerdicts: malformed pairs input", () => {
215
+ test("#given a missing or malformed pairs input #when ingested #then it refuses instead of reporting a clean result", () => {
216
+ for (const bad of [undefined, null, {}, { pairs: "P1" }, "pairs.json", 7]) {
217
+ expect(() => ingestVerdicts(bad as never, []), JSON.stringify(bad)).toThrow(TypeError)
218
+ }
219
+ expect(ingestVerdicts({ pairs: [] }, []).unverified).toEqual([])
220
+ })
221
+ })
222
+
223
+ describe("ingestVerdicts: the no-model contract", () => {
224
+ test("#given the module source #when read #then it never fetches or imports a network or model client", () => {
225
+ const text = readFileSync(join(import.meta.dir, "meaning-check.mjs"), "utf8")
226
+ expect(text).not.toMatch(/\bfetch\s*\(|node:https?|node:net|from\s+"(?!\.\/|node:)/u)
227
+ })
228
+ })
@@ -1,6 +1,5 @@
1
- // outcome.mjs - the per-run outcome manifest (`outcome.json`) and the closing briefing.
2
- // Manifest functions are pure (they return a new manifest); the briefing reads only the
3
- // manifest, the sources-ledger text, and an injected `now`. node: builtins only.
1
+ // outcome.mjs - the per-run outcome manifest (`outcome.json`) and the closing briefing. Manifest functions are pure (they return a new manifest).
2
+ // The briefing reads only the manifest, the sources-ledger text, and an injected `now`. node: builtins only.
4
3
 
5
4
  import * as nodeFs from "node:fs"
6
5
  import { basename, dirname, join } from "node:path"
@@ -86,7 +85,7 @@ export function setDeliverable(manifest, format, status, detail = {}) {
86
85
  const next = clone(manifest)
87
86
  const index = next.deliverables.findIndex((row) => row.format === format)
88
87
  const row = { format, status, promised: index >= 0 ? next.deliverables[index].promised : false, updatedAt: stamp(detail.now) }
89
- for (const key of ["path", "reason", "pages", "bytes"]) {
88
+ for (const key of ["path", "reason", "pages", "bytes", "verifiedBy", "renderedFrom"]) {
90
89
  if (detail[key] !== undefined) row[key] = detail[key]
91
90
  }
92
91
  if (index >= 0) next.deliverables[index] = row
@@ -94,11 +93,13 @@ export function setDeliverable(manifest, format, status, detail = {}) {
94
93
  return next
95
94
  }
96
95
 
97
- export function setGate(manifest, gate, status) {
96
+ export function setGate(manifest, gate, status, detail = {}) {
98
97
  assertOneOf("unknown_gate", "gate", gate, GATE_NAMES)
99
98
  assertOneOf("unknown_gate_status", "gate status", status, GATE_STATUSES)
99
+ if (gate === "fidelity" && status === "not_run" && (typeof detail.reason !== "string" || !detail.reason.trim())) throw new OutcomeError("gate_reason_required", "fidelity not_run requires a non-empty --reason")
100
100
  const next = clone(manifest)
101
101
  next.gates[gate] = status
102
+ next.gateHistory = [...(next.gateHistory ?? []), { gate, status, ...(detail.reason !== undefined ? { reason: detail.reason.trim() } : {}), at: stamp(detail.now) }]
102
103
  return next
103
104
  }
104
105
 
@@ -0,0 +1,184 @@
1
+ // placeholder.mjs - the fact pointer grammar of the fill step and its render helpers.
2
+ // Value pointers: {{F12:label}}, {{F12:label:value}}, {{F12:label:unit}}, {{F12:label:q}}.
3
+ // Quote pointers: {{Q7:label}}, {{Q7:label:lead=18}}, {{Q7:label:from="x":to="y"}} (anchors are
4
+ // JSON strings, so they may hold a colon or an escaped quote). Labels are compared exactly by the
5
+ // caller; this module never guesses an id. Pure functions; node: builtins only.
6
+
7
+ import { parseNumeral } from "./numerals.mjs"
8
+
9
+ export const ELLIPSIS = "\u2026"
10
+
11
+ const ID = /^([FQ])\d+$/
12
+ const LABEL = /^[^\s:{}"]+$/
13
+ const VALUE_MODES = new Set(["value", "unit", "q"])
14
+ const LEAD = /^lead=([1-9]\d*)$/
15
+ const LINEAGE = Object.freeze({ measured: "MEASURED", assumed: "ASSUMED", derived: "DERIVED", converted: "DERIVED" })
16
+
17
+ /** End index (exclusive) of a JSON string starting at `at`, or -1 when it is not closed on its line. */
18
+ function jsonStringEnd(text, at) {
19
+ let i = at + 1
20
+ while (i < text.length && text[i] !== '"' && text[i] !== "\n") i += text[i] === "\\" ? 2 : 1
21
+ return text[i] === '"' ? i + 1 : -1
22
+ }
23
+
24
+ function readJsonString(text, at) {
25
+ if (text[at] !== '"') return null
26
+ const end = jsonStringEnd(text, at)
27
+ if (end === -1) return null
28
+ try {
29
+ return { value: JSON.parse(text.slice(at, end)), end }
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ function parseAnchors(tail) {
36
+ if (!tail.startsWith("from=")) return null
37
+ const from = readJsonString(tail, 5)
38
+ if (from === null || !tail.startsWith(":to=", from.end)) return null
39
+ const to = readJsonString(tail, from.end + 4)
40
+ if (to === null || to.end !== tail.length || from.value === "" || to.value === "") return null
41
+ return { from: from.value, to: to.value }
42
+ }
43
+
44
+ /** @returns the parsed pointer, or { error } for a malformed one. */
45
+ export function parsePlaceholder(token) {
46
+ if (!token.startsWith("{{") || !token.endsWith("}}")) return { error: "a placeholder is {{ID:label...}}" }
47
+ const body = token.slice(2, -2)
48
+ const colon = body.indexOf(":")
49
+ const id = colon === -1 ? body : body.slice(0, colon)
50
+ const idMatch = ID.exec(id)
51
+ if (idMatch === null) return { error: `"${id}" is not an F<n> or Q<n> id` }
52
+ if (colon === -1) return { error: `${id} has no label; write {{${id}:label}}` }
53
+ const rest = body.slice(colon + 1)
54
+ const second = rest.indexOf(":")
55
+ const label = second === -1 ? rest : rest.slice(0, second)
56
+ if (!LABEL.test(label)) return { error: `"${label}" is not a label` }
57
+ const tail = second === -1 ? null : rest.slice(second + 1)
58
+ const base = { id, kind: idMatch[1], label }
59
+ if (idMatch[1] === "F") {
60
+ if (tail === null) return { ...base, mode: "full" }
61
+ return VALUE_MODES.has(tail) ? { ...base, mode: tail } : { error: `"${tail}" is not a value mode (value, unit, q)` }
62
+ }
63
+ if (tail === null) return { ...base, mode: "full", selector: "full" }
64
+ const lead = LEAD.exec(tail)
65
+ if (lead !== null) return { ...base, mode: "lead", lead: Number(lead[1]), selector: `lead=${lead[1]}` }
66
+ const anchors = parseAnchors(tail)
67
+ if (anchors === null) return { error: `"${tail}" is not a quote selector (lead=N or from="..":to="..")` }
68
+ return { ...base, mode: "span", ...anchors, selector: `from=${anchors.from};to=${anchors.to}` }
69
+ }
70
+
71
+ /** End index (exclusive) of the pointer opened at `at`, or -1 when it is not closed on its line. */
72
+ function placeholderEnd(text, at) {
73
+ let i = at + 2
74
+ while (i < text.length && text[i] !== "\n") {
75
+ if (text[i] === '"') {
76
+ const end = jsonStringEnd(text, i)
77
+ if (end === -1) return -1
78
+ i = end
79
+ } else if (text.startsWith("}}", i)) return i + 2
80
+ else i++
81
+ }
82
+ return -1
83
+ }
84
+
85
+ /** Every `{{...}}` pointer in source order: [{ token, start, end, parsed }]. */
86
+ export function findPlaceholders(text) {
87
+ const found = []
88
+ let at = text.indexOf("{{")
89
+ while (at !== -1) {
90
+ const end = placeholderEnd(text, at)
91
+ if (end === -1) {
92
+ found.push({ token: text.slice(at, text.indexOf("\n", at) === -1 ? text.length : text.indexOf("\n", at)), start: at, end: at + 2, parsed: { error: "the placeholder is not closed on its line" } })
93
+ at = text.indexOf("{{", at + 2)
94
+ continue
95
+ }
96
+ const token = text.slice(at, end)
97
+ found.push({ token, start: at, end, parsed: parsePlaceholder(token) })
98
+ at = text.indexOf("{{", end)
99
+ }
100
+ return found
101
+ }
102
+
103
+ function occurrences(text, needle) {
104
+ let count = 0
105
+ for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + 1)) count++
106
+ return count
107
+ }
108
+
109
+ function cut(quote, start, end) {
110
+ return { text: `${start > 0 ? ELLIPSIS : ""}${quote.slice(start, end)}${end < quote.length ? ELLIPSIS : ""}`, start, end }
111
+ }
112
+
113
+ /** The contiguous span from `from` through `to`; each anchor must occur exactly once, in order. */
114
+ export function subSpan(quote, from, to) {
115
+ if (occurrences(quote, from) !== 1 || occurrences(quote, to) !== 1) return null
116
+ const start = quote.indexOf(from)
117
+ const toAt = quote.indexOf(to)
118
+ if (toAt < start || toAt + to.length < start + from.length) return null
119
+ return cut(quote, start, toAt + to.length)
120
+ }
121
+
122
+ /** The first `n` whitespace-delimited words, with an ellipsis when words were cut. */
123
+ export function lead(quote, n) {
124
+ const words = [...quote.matchAll(/\S+/g)]
125
+ if (words.length <= n) return { text: quote, start: 0, end: quote.length }
126
+ const last = words[n - 1]
127
+ return cut(quote, 0, last.index + last[0].length)
128
+ }
129
+
130
+ /** The value and unit parts of a row's raw text ("4.2 eok won" -> value "4.2 eok", unit "won"). */
131
+ export function valueParts(row) {
132
+ const unitText = parseNumeral(row.raw)?.unitRaw ?? row.unit
133
+ const valueText = unitText && row.raw.endsWith(unitText) ? row.raw.slice(0, -unitText.length).trimEnd() : (row.value ?? row.raw)
134
+ return { valueText, unitText: unitText ?? "" }
135
+ }
136
+
137
+ /** The quote interval holding the row's qualifier and its raw quantity, or null if absent. */
138
+ export function qualifiedText(row) {
139
+ if (row.qualifier === null) return row.raw
140
+ for (let numeral = parseNumeral(row.quote); numeral !== null; numeral = parseNumeral(row.quote, numeral.span[1])) {
141
+ if (numeral.raw !== row.raw || numeral.qualifierRaw === null) continue
142
+ const [start, end] = numeral.span
143
+ const before = row.quote.lastIndexOf(numeral.qualifierRaw, start)
144
+ const between = before === -1 ? [] : row.quote.slice(before + numeral.qualifierRaw.length, start).split(/\s+/u).filter(Boolean)
145
+ if (before !== -1 && between.length <= 1) return row.quote.slice(before, end)
146
+ const after = row.quote.indexOf(numeral.qualifierRaw, end)
147
+ if (after !== -1) return row.quote.slice(start, after + numeral.qualifierRaw.length)
148
+ }
149
+ return null
150
+ }
151
+
152
+ export function escapeHtml(text) {
153
+ return String(text).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;")
154
+ }
155
+
156
+ /** The elements the renderers below emit as fact spans; recognizers build their patterns from this. */
157
+ export const FACT_ELEMENT_TAGS = Object.freeze(["data", "q", "blockquote"])
158
+
159
+ export function renderValueHtml(row, text) {
160
+ const unit = row.unit === null ? "" : ` data-unit="${escapeHtml(row.unit)}"`
161
+ return `<data class="fact" value="${escapeHtml(row.value)}" data-fact="${row.fact_id}"${unit} data-lineage="${LINEAGE[row.kind]}">${escapeHtml(text)}</data>`
162
+ }
163
+
164
+ export function renderQuote(row, selector, text, format) {
165
+ const marker = `[${row.source_id}]`
166
+ if (format === "md") return `"${text}" ${marker}`
167
+ return `<q data-fact="${row.fact_id}" data-quote-sel="${escapeHtml(selector)}">${escapeHtml(text)}</q> ${marker}`
168
+ }
169
+
170
+ const RENDERED_MD_QUOTE = /^"([\s\S]*)" (\[S\d+\])$/u
171
+
172
+ /** The quote text and source marker of bytes renderQuote wrote for md, or null for any other layout. */
173
+ export function parseRenderedQuote(body) {
174
+ const match = RENDERED_MD_QUOTE.exec(body)
175
+ return match ? { text: match[1], marker: match[2] } : null
176
+ }
177
+
178
+ /** The attributed block that replaces a twice-failed sentence; entity and valid_at stay outside the quote. */
179
+ export function renderBlockquote(row, key, format) {
180
+ const attribution = row.entity === null ? "" : `, on ${row.entity}${row.valid_at === null ? "" : ` (${row.valid_at})`}`
181
+ const footer = `[${row.source_id}]${attribution}`
182
+ if (format === "md") return `> "${row.quote}"\n> ${footer}`
183
+ return `<blockquote data-fact="${row.fact_id}" data-replaced="${escapeHtml(key)}">"${escapeHtml(row.quote)}" <footer>${escapeHtml(footer)}</footer></blockquote>`
184
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import { findPlaceholders, lead, parsePlaceholder, subSpan } from "./placeholder.mjs"
4
+
5
+ const QUOTE = "we estimate completion no earlier than 2027"
6
+
7
+ describe("parsePlaceholder", () => {
8
+ test("value pointers: full, value, unit, q", () => {
9
+ expect(parsePlaceholder("{{F12:span_len}}")).toEqual({ id: "F12", kind: "F", label: "span_len", mode: "full" })
10
+ expect(parsePlaceholder("{{F12:span_len:value}}")).toEqual({ id: "F12", kind: "F", label: "span_len", mode: "value" })
11
+ expect(parsePlaceholder("{{F12:span_len:unit}}")).toEqual({ id: "F12", kind: "F", label: "span_len", mode: "unit" })
12
+ expect(parsePlaceholder("{{F12:span_len:q}}")).toEqual({ id: "F12", kind: "F", label: "span_len", mode: "q" })
13
+ })
14
+
15
+ test("quote pointers: full, lead, from/to with JSON anchors", () => {
16
+ expect(parsePlaceholder("{{Q7:completion}}")).toEqual({ id: "Q7", kind: "Q", label: "completion", mode: "full", selector: "full" })
17
+ expect(parsePlaceholder("{{Q7:completion:lead=18}}")).toEqual({ id: "Q7", kind: "Q", label: "completion", mode: "lead", lead: 18, selector: "lead=18" })
18
+ expect(parsePlaceholder('{{Q7:completion:from="no earlier":to="2027"}}')).toEqual({
19
+ id: "Q7",
20
+ kind: "Q",
21
+ label: "completion",
22
+ mode: "span",
23
+ from: "no earlier",
24
+ to: "2027",
25
+ selector: "from=no earlier;to=2027",
26
+ })
27
+ })
28
+
29
+ test("an anchor may contain a colon or an escaped quote", () => {
30
+ const parsed = parsePlaceholder('{{Q7:c:from="a:b":to="say \\"x\\""}}')
31
+ expect(parsed).toMatchObject({ mode: "span", from: "a:b", to: 'say "x"' })
32
+ })
33
+
34
+ test("malformed pointers carry an error and no id guess", () => {
35
+ for (const token of ["{{F12}}", "{{X1:a}}", "{{F12:a:lead=3}}", "{{Q7:a:value}}", "{{Q7:a:lead=0}}", '{{Q7:a:from="":to="b"}}', '{{Q7:a:from="a"}}', "{{F12:a:b:c}}"]) {
36
+ expect(parsePlaceholder(token)).toHaveProperty("error")
37
+ }
38
+ })
39
+ })
40
+
41
+ describe("findPlaceholders", () => {
42
+ test("returns every token with offsets that slice back to it", () => {
43
+ const text = 'Span {{F12:span_len}} and {{Q7:completion:from="a}}b":to="c"}} end'
44
+ const found = findPlaceholders(text)
45
+ expect(found.map((item) => item.token)).toEqual(["{{F12:span_len}}", '{{Q7:completion:from="a}}b":to="c"}}'])
46
+ for (const item of found) expect(text.slice(item.start, item.end)).toBe(item.token)
47
+ expect(found[1].parsed).toMatchObject({ from: "a}}b", to: "c" })
48
+ })
49
+
50
+ test("an unterminated pointer is reported, not skipped", () => {
51
+ const found = findPlaceholders("a {{F12:span_len\nb")
52
+ expect(found).toHaveLength(1)
53
+ expect(found[0].parsed).toHaveProperty("error")
54
+ })
55
+ })
56
+
57
+ describe("subSpan", () => {
58
+ test("inclusive sub-span with an ellipsis only at cut ends", () => {
59
+ expect(subSpan(QUOTE, "no earlier", "2027")).toEqual({ text: "\u2026no earlier than 2027", start: 23, end: 43 })
60
+ expect(subSpan(QUOTE, "we", "completion")).toEqual({ text: "we estimate completion\u2026", start: 0, end: 22 })
61
+ expect(subSpan(QUOTE, "estimate", "earlier").text).toBe("\u2026estimate completion no earlier\u2026")
62
+ })
63
+
64
+ test("an anchor that is missing, repeated, or reversed is null", () => {
65
+ expect(subSpan(QUOTE, "missing", "2027")).toBeNull()
66
+ expect(subSpan(QUOTE, "ti", "2027")).toBeNull()
67
+ expect(subSpan(QUOTE, "2027", "estimate")).toBeNull()
68
+ })
69
+ })
70
+
71
+ describe("lead", () => {
72
+ test("first n words plus an ellipsis, never cutting a word", () => {
73
+ expect(lead(QUOTE, 3)).toEqual({ text: "we estimate completion\u2026", start: 0, end: 22 })
74
+ expect(lead(QUOTE, 7)).toEqual({ text: QUOTE, start: 0, end: QUOTE.length })
75
+ expect(lead(QUOTE, 40)).toEqual({ text: QUOTE, start: 0, end: QUOTE.length })
76
+ })
77
+ })
@@ -0,0 +1,115 @@
1
+ // polish-apply.mjs - checked, per-edit application of a proofread lane's polish edits.
2
+ //
3
+ // An edit whose before or after holds <, > or & is polish_edit_rejected (POLISH_MARKUP_REASON)
4
+ // before anything else, so no edit can create, alter, close or escape a tag, comment,
5
+ // bogus comment, CDATA section, entity or script, whatever the tokenizer models; a located region
6
+ // that overlaps a character reference or opens one (it starts inside `&name`) is rejected with
7
+ // POLISH_REFERENCE_REASON, so an edit can neither change nor complete an existing reference.
8
+ // Those two guards are the early, actionable refusals; the guarantee itself is the whole-document
9
+ // skeleton comparison (html-markup.skeletonChange) that polish-rules runs in both gates: every
10
+ // markup token and every character reference stays byte-identical, and an accepted edit's span
11
+ // holds no skeleton token, so any difference refuses the edit and reports polish_diff_fail.
12
+ // applyEdits(html, edits, ledger) applies each {before, after, reason} edit in edits-file order,
13
+ // against the output of the edits accepted before it. `before` must occur exactly once (runs of
14
+ // whitespace match any whitespace run) and must not cut through a fact span; otherwise the edit is
15
+ // polish_edit_unlocated. A located edit is applied whole or not at all: it is polish_edit_rejected
16
+ // when polish-rules.polishDefects(..., {ordered: true}) finds any defect between the current
17
+ // document and the document with the edit (per edit), or between the ORIGINAL input and the
18
+ // document with the edit (whole document: the baseline polish-diff uses). Ordered is stricter than
19
+ // polish-diff's multiset comparison and the last accepted candidate is the output, so a clean run
20
+ // is always polish-diff clean: no cross-edit case is left to the backstop. The rules see the
21
+ // text-node projection and freeze every markup byte, including script/style/template content.
22
+ // validatePolishEdits and POLISH_EDIT_FIELDS are the edit
23
+ // contract every caller (the CLI included) uses. No IO, no model. Plain Node ESM.
24
+
25
+ import { CHARACTER_REFERENCE } from "./html-markup.mjs"
26
+ import { factSpans } from "./polish-project.mjs"
27
+ import { polishDefects } from "./polish-rules.mjs"
28
+
29
+ export { numeralKeys, project, SLOT, termCounts, textOf } from "./polish-project.mjs"
30
+
31
+ /** The fields of one polish edit: before and after are required strings, reason an optional string. */
32
+ export const POLISH_EDIT_FIELDS = Object.freeze({ before: "required", after: "required", reason: "optional" })
33
+
34
+ /** Throws a TypeError naming the first edit that breaks POLISH_EDIT_FIELDS; returns the edits. */
35
+ export function validatePolishEdits(edits) {
36
+ if (!Array.isArray(edits)) throw new TypeError("edits must be an array")
37
+ edits.forEach((edit, index) => {
38
+ if (edit === null || typeof edit !== "object" || Array.isArray(edit)) throw new TypeError(`edit ${index} must be an object`)
39
+ for (const [field, need] of Object.entries(POLISH_EDIT_FIELDS)) {
40
+ if (need === "optional" && edit[field] === undefined) continue
41
+ if (typeof edit[field] !== "string") throw new TypeError(`edit ${index} needs ${need === "optional" ? "a string" : "string"} ${field}`)
42
+ }
43
+ })
44
+ return edits
45
+ }
46
+
47
+ /** Characters an edit's before and after may never contain. */
48
+ export const POLISH_MARKUP_CHARACTERS = /[<>&]/
49
+ /** The actionable rejection reason for an edit holding a markup character. */
50
+ export const POLISH_MARKUP_REASON = "edit contains markup characters (<, >, &): route this section through the full fidelity gates"
51
+ /** The actionable rejection reason for a located region touching a character reference. */
52
+ export const POLISH_REFERENCE_REASON = "edit touches a character reference: route this section through the full fidelity gates"
53
+
54
+ /** True when [start, end) overlaps a character reference of html or starts inside one. */
55
+ export function touchesReference(html, start, end) {
56
+ for (const match of String(html).matchAll(CHARACTER_REFERENCE)) {
57
+ if (match.index < end && match.index + match[0].length > start) return true
58
+ }
59
+ return /&[#\w]*$/.test(html.slice(Math.max(0, start - 16), start))
60
+ }
61
+
62
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
63
+
64
+ /** The single whitespace-normalized match of `before` in html as {start, end}, or {count} otherwise. */
65
+ function locate(html, before) {
66
+ if (before.trim() === "") return { count: 0 }
67
+ const pattern = new RegExp(before.split(/\s+/).map(escape).join("\\s+"), "g")
68
+ const hits = [...html.matchAll(pattern)]
69
+ return hits.length === 1 ? { count: 1, start: hits[0].index, end: hits[0].index + hits[0][0].length } : { count: hits.length }
70
+ }
71
+
72
+ /** The id of a fact span the region [start, end) cuts through (overlaps without containing), or null. */
73
+ export function cutSpan(html, start, end) {
74
+ return factSpans(html).find((span) => span.start < end && span.end > start && !(start <= span.start && span.end <= end))?.id ?? null
75
+ }
76
+
77
+ /**
78
+ * @param {string} html the filled report
79
+ * @param {{ before: string, after: string, reason?: string }[]} edits in edits-file order
80
+ * @param {{ rows: object[] }} ledger
81
+ * @returns {{ out: string, applied: number[], rejected: { index: number, code: string, reason: string }[],
82
+ * unlocated: { index: number, code: string, reason: string }[] }}
83
+ */
84
+ export function applyEdits(html, edits, ledger) {
85
+ validatePolishEdits(edits)
86
+ const original = String(html)
87
+ const result = { out: original, applied: [], rejected: [], unlocated: [] }
88
+ const defects = (candidate) => {
89
+ const messages = [result.out, original].flatMap((base) => polishDefects(base, candidate, ledger, { ordered: true }).map((d) => d.message))
90
+ return [...new Set(messages)]
91
+ }
92
+ edits.forEach((edit, index) => {
93
+ const touches = POLISH_MARKUP_CHARACTERS.test(edit.before) || POLISH_MARKUP_CHARACTERS.test(edit.after)
94
+ const where = locate(result.out, edit.before)
95
+ if (where.count !== 1 && !touches) {
96
+ result.unlocated.push({ index, code: "polish_edit_unlocated", reason: `before matches ${where.count} times; it must match exactly once` })
97
+ return
98
+ }
99
+ const reasons = touches ? [POLISH_MARKUP_REASON] : []
100
+ const candidate = where.count === 1 ? result.out.slice(0, where.start) + edit.after + result.out.slice(where.end) : result.out
101
+ if (where.count === 1) {
102
+ const cut = cutSpan(result.out, where.start, where.end)
103
+ if (touchesReference(result.out, where.start, where.end)) reasons.push(POLISH_REFERENCE_REASON)
104
+ reasons.push(...defects(candidate))
105
+ if (cut !== null) reasons.push(`the edit cuts through fact span ${cut}`)
106
+ }
107
+ if (reasons.length > 0) {
108
+ result.rejected.push({ index, code: "polish_edit_rejected", reason: reasons.join("; ") })
109
+ return
110
+ }
111
+ result.out = candidate
112
+ result.applied.push(index)
113
+ })
114
+ return result
115
+ }