parseman 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/README.md +8 -3
  2. package/dist/analysis/choice-cost-render.d.ts +12 -4
  3. package/dist/analysis/choice-cost-render.d.ts.map +1 -1
  4. package/dist/analysis/choice-cost.d.ts +2 -2
  5. package/dist/analysis/choice-cost.d.ts.map +1 -1
  6. package/dist/analysis/commitment.d.ts +131 -0
  7. package/dist/analysis/commitment.d.ts.map +1 -0
  8. package/dist/analysis/diagnostics.cjs +9929 -7465
  9. package/dist/analysis/diagnostics.cjs.map +3 -4
  10. package/dist/analysis/diagnostics.d.ts +2 -2
  11. package/dist/analysis/diagnostics.js +9929 -7465
  12. package/dist/analysis/diagnostics.js.map +3 -4
  13. package/dist/analysis/duplication.d.ts.map +1 -1
  14. package/dist/analysis/fix.d.ts +3 -3
  15. package/dist/analysis/fix.d.ts.map +1 -1
  16. package/dist/analysis/format-number.d.ts +23 -0
  17. package/dist/analysis/format-number.d.ts.map +1 -0
  18. package/dist/analysis/gating.d.ts +39 -2
  19. package/dist/analysis/gating.d.ts.map +1 -1
  20. package/dist/analysis/rebuild.d.ts.map +1 -1
  21. package/dist/analysis/terminal.d.ts +5 -2
  22. package/dist/analysis/terminal.d.ts.map +1 -1
  23. package/dist/cli/index.cjs +10865 -8535
  24. package/dist/cli/index.cjs.map +3 -4
  25. package/dist/cli/index.js +10865 -8535
  26. package/dist/cli/index.js.map +3 -4
  27. package/dist/combinators/adjacency.d.ts +100 -0
  28. package/dist/combinators/adjacency.d.ts.map +1 -0
  29. package/dist/combinators/choice.d.ts.map +1 -1
  30. package/dist/combinators/completions.d.ts.map +1 -1
  31. package/dist/combinators/dispatch.d.ts +8 -1
  32. package/dist/combinators/dispatch.d.ts.map +1 -1
  33. package/dist/combinators/expect.d.ts.map +1 -1
  34. package/dist/combinators/first-set.d.ts.map +1 -1
  35. package/dist/combinators/gate.d.ts.map +1 -1
  36. package/dist/combinators/grammar.d.ts +22 -0
  37. package/dist/combinators/grammar.d.ts.map +1 -1
  38. package/dist/combinators/literal.d.ts +8 -0
  39. package/dist/combinators/literal.d.ts.map +1 -1
  40. package/dist/combinators/map.d.ts +0 -1
  41. package/dist/combinators/map.d.ts.map +1 -1
  42. package/dist/combinators/node.d.ts +13 -3
  43. package/dist/combinators/node.d.ts.map +1 -1
  44. package/dist/combinators/parser.d.ts +1 -1
  45. package/dist/combinators/parser.d.ts.map +1 -1
  46. package/dist/combinators/ref.d.ts.map +1 -1
  47. package/dist/combinators/regex.d.ts +1 -16
  48. package/dist/combinators/regex.d.ts.map +1 -1
  49. package/dist/combinators/repeat.d.ts +30 -2
  50. package/dist/combinators/repeat.d.ts.map +1 -1
  51. package/dist/combinators/scanTo.d.ts +71 -1
  52. package/dist/combinators/scanTo.d.ts.map +1 -1
  53. package/dist/combinators/sequence.d.ts.map +1 -1
  54. package/dist/combinators/token.d.ts.map +1 -1
  55. package/dist/combinators/trivia-boundary.d.ts +26 -0
  56. package/dist/combinators/trivia-boundary.d.ts.map +1 -0
  57. package/dist/combinators/trivia-skip.d.ts +40 -1
  58. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  59. package/dist/combinators/withCtx.d.ts.map +1 -1
  60. package/dist/compiler/compile-linkable-table.d.ts +104 -0
  61. package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
  62. package/dist/compiler/degradation.d.ts +0 -2
  63. package/dist/compiler/degradation.d.ts.map +1 -1
  64. package/dist/compiler/fields.d.ts +13 -0
  65. package/dist/compiler/fields.d.ts.map +1 -1
  66. package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
  67. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  68. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  69. package/dist/compiler/linker.d.ts +47 -53
  70. package/dist/compiler/linker.d.ts.map +1 -1
  71. package/dist/compiler/token-alphabet.d.ts +81 -0
  72. package/dist/compiler/token-alphabet.d.ts.map +1 -0
  73. package/dist/compiler/token-dispatch.d.ts +95 -0
  74. package/dist/compiler/token-dispatch.d.ts.map +1 -0
  75. package/dist/compiler/token-scanner.d.ts +53 -0
  76. package/dist/compiler/token-scanner.d.ts.map +1 -0
  77. package/dist/compiler/value-usage.d.ts.map +1 -1
  78. package/dist/coverage.d.ts +1 -1
  79. package/dist/coverage.d.ts.map +1 -1
  80. package/dist/cst/capture-buffer.d.ts +35 -0
  81. package/dist/cst/capture-buffer.d.ts.map +1 -1
  82. package/dist/cst/reflection-attach.d.ts +8 -0
  83. package/dist/cst/reflection-attach.d.ts.map +1 -0
  84. package/dist/cst/reflection-symbols.d.ts +10 -0
  85. package/dist/cst/reflection-symbols.d.ts.map +1 -0
  86. package/dist/cst/reflection.d.ts +2 -4
  87. package/dist/cst/reflection.d.ts.map +1 -1
  88. package/dist/cst/root-trivia-scope.d.ts +26 -0
  89. package/dist/cst/root-trivia-scope.d.ts.map +1 -0
  90. package/dist/cst/trivia-charscan.d.ts +18 -0
  91. package/dist/cst/trivia-charscan.d.ts.map +1 -0
  92. package/dist/cst/trivia-kinds.d.ts +19 -0
  93. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  94. package/dist/functional/doc.d.ts.map +1 -1
  95. package/dist/functional/run-tabled.d.ts +12 -0
  96. package/dist/functional/run-tabled.d.ts.map +1 -0
  97. package/dist/functional/run.d.ts +25 -42
  98. package/dist/functional/run.d.ts.map +1 -1
  99. package/dist/grammar-metadata.d.ts +3 -0
  100. package/dist/grammar-metadata.d.ts.map +1 -0
  101. package/dist/index.cjs +9224 -7203
  102. package/dist/index.cjs.map +3 -4
  103. package/dist/index.d.ts +18 -9
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +9219 -7198
  106. package/dist/index.js.map +3 -4
  107. package/dist/language-service/index.cjs +311 -121
  108. package/dist/language-service/index.cjs.map +3 -4
  109. package/dist/language-service/index.js +311 -121
  110. package/dist/language-service/index.js.map +3 -4
  111. package/dist/oracle/index.cjs.map +0 -1
  112. package/dist/oracle/index.js.map +0 -1
  113. package/dist/parse-context.d.ts +49 -0
  114. package/dist/parse-context.d.ts.map +1 -0
  115. package/dist/plugin/evaluator.d.ts +40 -1
  116. package/dist/plugin/evaluator.d.ts.map +1 -1
  117. package/dist/plugin/index.cjs +11146 -9593
  118. package/dist/plugin/index.cjs.map +3 -4
  119. package/dist/plugin/index.d.ts.map +1 -1
  120. package/dist/plugin/index.js +11146 -9593
  121. package/dist/plugin/index.js.map +3 -4
  122. package/dist/regex/classes.d.ts +28 -0
  123. package/dist/regex/classes.d.ts.map +1 -1
  124. package/dist/regex/first-set.d.ts +14 -0
  125. package/dist/regex/first-set.d.ts.map +1 -1
  126. package/dist/run/index.cjs +136 -51
  127. package/dist/run/index.cjs.map +2 -3
  128. package/dist/run/index.js +136 -51
  129. package/dist/run/index.js.map +2 -3
  130. package/dist/spec/index.cjs +2898 -1623
  131. package/dist/spec/index.cjs.map +3 -4
  132. package/dist/spec/index.js +2898 -1623
  133. package/dist/spec/index.js.map +3 -4
  134. package/dist/spec/model.d.ts +8 -0
  135. package/dist/spec/model.d.ts.map +1 -1
  136. package/dist/table/assemble.d.ts +307 -0
  137. package/dist/table/assemble.d.ts.map +1 -0
  138. package/dist/table/cell.d.ts +42 -0
  139. package/dist/table/cell.d.ts.map +1 -0
  140. package/dist/table/child-slots.d.ts +30 -0
  141. package/dist/table/child-slots.d.ts.map +1 -0
  142. package/dist/table/compile-rule-map.d.ts +136 -0
  143. package/dist/table/compile-rule-map.d.ts.map +1 -0
  144. package/dist/table/compile.d.ts +56 -0
  145. package/dist/table/compile.d.ts.map +1 -0
  146. package/dist/table/duplication-hook.d.ts +32 -0
  147. package/dist/table/duplication-hook.d.ts.map +1 -0
  148. package/dist/table/emit-assembly.d.ts +139 -0
  149. package/dist/table/emit-assembly.d.ts.map +1 -0
  150. package/dist/table/emit.d.ts +113 -0
  151. package/dist/table/emit.d.ts.map +1 -0
  152. package/dist/table/encode-baseline.d.ts +16 -0
  153. package/dist/table/encode-baseline.d.ts.map +1 -0
  154. package/dist/table/encode.d.ts +71 -0
  155. package/dist/table/encode.d.ts.map +1 -0
  156. package/dist/table/exec-baseline.d.ts +9 -0
  157. package/dist/table/exec-baseline.d.ts.map +1 -0
  158. package/dist/table/exec.d.ts +72 -0
  159. package/dist/table/exec.d.ts.map +1 -0
  160. package/dist/table/fold.d.ts +25 -0
  161. package/dist/table/fold.d.ts.map +1 -0
  162. package/dist/table/index.cjs +11375 -0
  163. package/dist/table/index.cjs.map +6 -0
  164. package/dist/table/index.d.ts +100 -0
  165. package/dist/table/index.d.ts.map +1 -0
  166. package/dist/table/index.js +11352 -0
  167. package/dist/table/index.js.map +6 -0
  168. package/dist/table/inspect.d.ts +17 -0
  169. package/dist/table/inspect.d.ts.map +1 -0
  170. package/dist/table/ops.d.ts +446 -0
  171. package/dist/table/ops.d.ts.map +1 -0
  172. package/dist/table/program.d.ts +493 -0
  173. package/dist/table/program.d.ts.map +1 -0
  174. package/dist/table/run-support.d.ts +38 -0
  175. package/dist/table/run-support.d.ts.map +1 -0
  176. package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
  177. package/dist/table/scan-shapes.d.ts.map +1 -0
  178. package/dist/table/site-labels.d.ts +58 -0
  179. package/dist/table/site-labels.d.ts.map +1 -0
  180. package/dist/table/stamp.d.ts +70 -0
  181. package/dist/table/stamp.d.ts.map +1 -0
  182. package/dist/types.d.ts +80 -24
  183. package/dist/types.d.ts.map +1 -1
  184. package/dist/version.d.ts +1 -1
  185. package/package.json +25 -2
  186. package/src/analysis/choice-cost-gate.ts +359 -0
  187. package/src/analysis/choice-cost-render.ts +293 -0
  188. package/src/analysis/choice-cost.ts +1008 -0
  189. package/src/analysis/commitment.ts +557 -0
  190. package/src/analysis/corpus.ts +130 -0
  191. package/src/analysis/diagnose-render.ts +625 -0
  192. package/src/analysis/diagnose.ts +317 -0
  193. package/src/analysis/diagnostics.ts +33 -0
  194. package/src/analysis/duplication.ts +1955 -0
  195. package/src/analysis/fix-render.ts +217 -0
  196. package/src/analysis/fix.ts +669 -0
  197. package/src/analysis/format-number.ts +31 -0
  198. package/src/analysis/gating.ts +792 -0
  199. package/src/analysis/grammar.ts +72 -0
  200. package/src/analysis/rebuild.ts +287 -0
  201. package/src/analysis/terminal.ts +336 -0
  202. package/src/cli/index.ts +413 -0
  203. package/src/combinators/adjacency.ts +171 -0
  204. package/src/combinators/attempt.ts +41 -0
  205. package/src/combinators/case-fold.ts +90 -0
  206. package/src/combinators/choice.ts +466 -0
  207. package/src/combinators/completions.ts +64 -0
  208. package/src/combinators/dispatch.ts +358 -0
  209. package/src/combinators/expect.ts +159 -0
  210. package/src/combinators/first-set.ts +322 -0
  211. package/src/combinators/gate.ts +50 -0
  212. package/src/combinators/grammar.ts +272 -0
  213. package/src/combinators/keywords.ts +145 -0
  214. package/src/combinators/literal.ts +102 -0
  215. package/src/combinators/map.ts +106 -0
  216. package/src/combinators/node.ts +345 -0
  217. package/src/combinators/not.ts +55 -0
  218. package/src/combinators/parser.ts +266 -0
  219. package/src/combinators/peek.ts +63 -0
  220. package/src/combinators/probe.ts +25 -0
  221. package/src/combinators/ref.ts +109 -0
  222. package/src/combinators/regex.ts +170 -0
  223. package/src/combinators/repeat.ts +561 -0
  224. package/src/combinators/scanTo.ts +393 -0
  225. package/src/combinators/sequence.ts +221 -0
  226. package/src/combinators/token.ts +120 -0
  227. package/src/combinators/trivia-boundary.ts +48 -0
  228. package/src/combinators/trivia-skip.ts +821 -0
  229. package/src/combinators/withCtx.ts +45 -0
  230. package/src/compiler/build-arity.ts +330 -0
  231. package/src/compiler/compile-linkable-table.ts +209 -0
  232. package/src/compiler/degradation.ts +266 -0
  233. package/src/compiler/fields.ts +191 -0
  234. package/src/compiler/grammar-coverage-ids.ts +129 -0
  235. package/src/compiler/ir-serialize.ts +615 -0
  236. package/src/compiler/linker.ts +954 -0
  237. package/src/compiler/token-alphabet.ts +209 -0
  238. package/src/compiler/token-dispatch.ts +353 -0
  239. package/src/compiler/token-scanner.ts +318 -0
  240. package/src/compiler/value-usage.ts +147 -0
  241. package/src/coverage.ts +383 -0
  242. package/src/cst/capture-buffer.ts +297 -0
  243. package/src/cst/host-mode.ts +69 -0
  244. package/src/cst/reflection-attach.ts +11 -0
  245. package/src/cst/reflection-symbols.ts +9 -0
  246. package/src/cst/reflection.ts +149 -0
  247. package/src/cst/relative-spans.ts +207 -0
  248. package/src/cst/root-trivia-scope.ts +30 -0
  249. package/src/cst/trivia-charscan.ts +312 -0
  250. package/src/cst/trivia-entries.ts +285 -0
  251. package/src/cst/trivia-index.ts +131 -0
  252. package/src/cst/trivia-kinds.ts +358 -0
  253. package/src/cst/types.ts +76 -0
  254. package/src/cst/walk.ts +101 -0
  255. package/src/functional/doc.ts +959 -0
  256. package/src/functional/run-tabled.ts +153 -0
  257. package/src/functional/run.ts +558 -0
  258. package/src/grammar-metadata.ts +2 -0
  259. package/src/index.ts +189 -0
  260. package/src/language-service/index.ts +221 -0
  261. package/src/line-index.ts +131 -0
  262. package/src/oracle/digest.ts +620 -0
  263. package/src/oracle/index.ts +55 -0
  264. package/src/parse-context.ts +91 -0
  265. package/src/plugin/direct-builder-static.ts +102 -0
  266. package/src/plugin/evaluator.ts +1505 -0
  267. package/src/plugin/free-identifiers.ts +338 -0
  268. package/src/plugin/index.ts +2281 -0
  269. package/src/plugin/reducer-resolver.ts +606 -0
  270. package/src/recovery/scan.ts +155 -0
  271. package/src/regex/classes.ts +183 -0
  272. package/src/regex/first-set.ts +316 -0
  273. package/src/run/index.ts +31 -0
  274. package/src/spec/ebnf.ts +128 -0
  275. package/src/spec/index.ts +54 -0
  276. package/src/spec/model.ts +636 -0
  277. package/src/spec/railroad-lib.ts +559 -0
  278. package/src/spec/railroad.ts +256 -0
  279. package/src/table/assemble.ts +2994 -0
  280. package/src/table/cell.ts +42 -0
  281. package/src/table/child-slots.ts +113 -0
  282. package/src/table/compile-rule-map.ts +336 -0
  283. package/src/table/compile.ts +244 -0
  284. package/src/table/duplication-hook.ts +89 -0
  285. package/src/table/emit-assembly.ts +1766 -0
  286. package/src/table/emit.ts +418 -0
  287. package/src/table/encode-baseline.ts +340 -0
  288. package/src/table/encode.ts +1569 -0
  289. package/src/table/exec-baseline.ts +537 -0
  290. package/src/table/exec.ts +1672 -0
  291. package/src/table/fold.ts +38 -0
  292. package/src/table/index.ts +108 -0
  293. package/src/table/inspect.ts +52 -0
  294. package/src/table/ops.ts +459 -0
  295. package/src/table/program.ts +855 -0
  296. package/src/table/run-support.ts +66 -0
  297. package/src/table/scan-shapes.ts +1578 -0
  298. package/src/table/site-labels.ts +228 -0
  299. package/src/table/stamp.ts +229 -0
  300. package/src/types.ts +563 -0
  301. package/src/version.ts +16 -0
  302. package/dist/compiler/codegen.d.ts +0 -334
  303. package/dist/compiler/codegen.d.ts.map +0 -1
  304. package/dist/compiler/inline-build.d.ts +0 -11
  305. package/dist/compiler/inline-build.d.ts.map +0 -1
  306. package/dist/compiler/inline-callback.d.ts +0 -21
  307. package/dist/compiler/inline-callback.d.ts.map +0 -1
  308. package/dist/compiler/line-index.d.ts +0 -2
  309. package/dist/compiler/line-index.d.ts.map +0 -1
  310. package/dist/compiler/module-hoist.d.ts +0 -106
  311. package/dist/compiler/module-hoist.d.ts.map +0 -1
  312. package/dist/compiler/scannable-run.d.ts.map +0 -1
  313. package/dist/compiler/scannable-terminal.d.ts +0 -23
  314. package/dist/compiler/scannable-terminal.d.ts.map +0 -1
  315. package/dist/compiler/trivia-fast-path.d.ts +0 -38
  316. package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
@@ -0,0 +1,620 @@
1
+ /**
2
+ * The canonical projection and the hashing the identity oracle is built on.
3
+ *
4
+ * Everything here exists to make ONE claim trustworthy: "this grammar change did
5
+ * not move the tree." That claim is only worth as much as the projection behind
6
+ * it, so the rules below are stated rather than left to `JSON.stringify`, which
7
+ * silently collapses several distinctions a grammar refactor can actually move:
8
+ *
9
+ * | value | `JSON.stringify` | here |
10
+ * |------------------------|------------------|-----------------|
11
+ * | `{ a: undefined }` | `{}` | distinct from `{}` |
12
+ * | `NaN` / `±Infinity` | `null` | each distinct |
13
+ * | `-0` | `0` | distinct from `0` |
14
+ * | `new Map([[1, 2]])` | `{}` | entries, in order |
15
+ * | `new Set([1])` | `{}` | members, in order |
16
+ * | `new Foo({ x: 1 })` | `{"x":1}` | tagged `Foo` |
17
+ *
18
+ * A refactor that starts emitting `undefined` where it used to omit a key, or
19
+ * that swaps one node class for another with the same fields, is a tree move.
20
+ * An oracle that cannot see it is worse than no oracle, because it certifies.
21
+ *
22
+ * ## Key order does not matter; array and Map/Set order does
23
+ *
24
+ * Object keys are sorted, so property INSERTION order — which a refactor churns
25
+ * constantly and which no consumer observes — never moves a digest. Array
26
+ * elements and Map/Set entries keep their order, because those orders are the
27
+ * output.
28
+ *
29
+ * ## Sharing is not a cycle
30
+ *
31
+ * A node reachable by two paths is written out twice, in full. Only a genuine
32
+ * back-edge into the CURRENT path is abbreviated, and it records the DISTANCE to
33
+ * the ancestor it points at, so a self-reference and a reference to the root are
34
+ * different digests. (Marking every repeat as a cycle — the easy version — makes
35
+ * the digest depend on traversal order for any DAG, which parse trees with
36
+ * interned or shared subtrees routinely are.)
37
+ *
38
+ * The cost of that choice is stated plainly, because it has now bitten a
39
+ * consumer: writing a shared subtree once per PATH is EXPONENTIAL in sharing
40
+ * depth. A node referenced from two places at each of `d` levels is written
41
+ * `2^d` times. See {@link DEFAULT_MAX_VISITS} for the backstop and for why the
42
+ * obvious fix is a format change rather than a patch.
43
+ *
44
+ * ## Own ENUMERABLE keys only
45
+ *
46
+ * `Object.keys`, deliberately, not `getOwnPropertyNames`. The oracle answers "did
47
+ * the OUTPUT move" and non-enumerable properties are engine bookkeeping a
48
+ * consumer never sees — parseman's own carried CST pieces among them. Hashing
49
+ * them would make a consumer's grammar digest move when parseman's internals
50
+ * change, which is precisely the wrong sensitivity for a grammar-refactor gate.
51
+ *
52
+ * ## Tokens, not concatenation
53
+ *
54
+ * Every value writes one or more self-contained tokens joined by NUL. Writing
55
+ * `#1` then `#2` into a single buffer would be indistinguishable from `#12`; a
56
+ * projection with an ambiguity in it is a projection that can be made to lie.
57
+ * Strings are JSON-escaped, so no token can contain a raw NUL.
58
+ *
59
+ * ## The projection is streamed, never materialised
60
+ *
61
+ * {@link digestInto} walks a value and pushes the token stream at a caller-owned
62
+ * hash. Nothing accumulates, so there is no maximum-string-length ceiling and no
63
+ * corpus size at which the digest stops being takeable. {@link canonicalize}
64
+ * still builds the string, because reading it is its entire purpose — but it is
65
+ * a debugging aid, not the path a gate runs on.
66
+ */
67
+ import { createHash } from 'node:crypto'
68
+
69
+ /**
70
+ * Bump when the token grammar below changes in a way that alters output for any
71
+ * input. It is a coarse, human-readable companion to the behavioural harness
72
+ * digest — the harness digest is what actually enforces this, but a reader
73
+ * comparing two reports should not have to diff hex to see that the format moved.
74
+ */
75
+ export const DIGEST_FORMAT = 1
76
+
77
+ /**
78
+ * The token separator, in one place.
79
+ *
80
+ * {@link canonicalize} joins with it and {@link StreamSink} interleaves it, and
81
+ * they MUST agree — the equality of those two byte streams is what lets a
82
+ * streamed digest reproduce one taken from the string form. Sharing the constant
83
+ * is how that stops being a thing anyone has to remember.
84
+ */
85
+ const SEPARATOR = String.fromCharCode(0)
86
+
87
+ const str = (s: string): string => JSON.stringify(s)
88
+
89
+ const tagOf = (v: object): string => {
90
+ const ctor = (v as { constructor?: unknown }).constructor
91
+ return typeof ctor === 'function' && typeof ctor.name === 'string' ? ctor.name : ''
92
+ }
93
+
94
+ /**
95
+ * Where canonical tokens go.
96
+ *
97
+ * Two implementations describing the SAME byte stream: {@link ArraySink}
98
+ * materialises it, {@link StreamSink} pushes it at a hash and forgets it.
99
+ */
100
+ type CanonicalSink = {
101
+ push(token: string): void
102
+ }
103
+
104
+ /** Collects tokens so {@link canonicalize} can join them. */
105
+ class ArraySink implements CanonicalSink {
106
+ readonly tokens: string[] = []
107
+
108
+ push(token: string): void {
109
+ this.tokens.push(token)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Anything that absorbs a stream of text. Node's `crypto.Hash` satisfies it, and
115
+ * so does a three-line test double.
116
+ *
117
+ * The CALLER supplies this, and therefore owns the algorithm and the result.
118
+ * Deciding what the canonical byte stream IS depends on parseman's node shapes
119
+ * and on nothing else; deciding that you wanted sha256-hex does not.
120
+ */
121
+ export type DigestTarget = {
122
+ update(chunk: string): void
123
+ }
124
+
125
+ /**
126
+ * Pushes the canonical token stream at a {@link DigestTarget} without ever
127
+ * materialising it.
128
+ *
129
+ * The bytes written are exactly what {@link canonicalize} would have returned —
130
+ * the separator goes BETWEEN tokens, never after — so hashing either gives the
131
+ * same digest. That equality is not an implementation detail, it is the entire
132
+ * safety argument for streaming: every digest ever recorded stays reproducible.
133
+ * `test/unit/oracle-digest.test.ts` asserts it directly over the canary shapes.
134
+ *
135
+ * Tokens are buffered and flushed in blocks rather than written one at a time:
136
+ * one `update()` per token is a call into native code per AST node. Flushing
137
+ * only ever happens at a token boundary and tokens are whole JS strings, so a
138
+ * surrogate pair can never be split across two `update()` calls — which would
139
+ * encode to different UTF-8 bytes than the joined string does.
140
+ */
141
+ class StreamSink implements CanonicalSink {
142
+ private readonly target: DigestTarget
143
+ private buffer: string[] = []
144
+ private buffered = 0
145
+ private started = false
146
+
147
+ constructor(target: DigestTarget) {
148
+ this.target = target
149
+ }
150
+
151
+ /**
152
+ * Write text that is NOT a canonical token and takes no separator — the
153
+ * `OK:` / `ERR:` discriminator, which the string form concatenates onto the
154
+ * front rather than joining.
155
+ */
156
+ prefix(text: string): void {
157
+ if (text === '') return
158
+ this.buffer.push(text)
159
+ this.buffered += text.length
160
+ }
161
+
162
+ push(token: string): void {
163
+ if (this.started) {
164
+ this.buffer.push(SEPARATOR)
165
+ this.buffered += 1
166
+ } else {
167
+ this.started = true
168
+ }
169
+ this.buffer.push(token)
170
+ this.buffered += token.length
171
+ if (this.buffered >= FLUSH_CHARS) this.flush()
172
+ }
173
+
174
+ flush(): void {
175
+ if (this.buffer.length === 0) return
176
+ this.target.update(this.buffer.join(''))
177
+ this.buffer = []
178
+ this.buffered = 0
179
+ }
180
+ }
181
+
182
+ /**
183
+ * How much token text to accumulate before handing a block to the target. Large
184
+ * enough that per-call overhead disappears, small enough that the extra live
185
+ * memory is a rounding error next to the tree being walked.
186
+ */
187
+ const FLUSH_CHARS = 1 << 16
188
+
189
+ /**
190
+ * Raised when a walk exceeds its visit budget.
191
+ *
192
+ * A distinct class, and deliberately NOT an ordinary `Error` a caller might
193
+ * lump in with a parse failure: this says the TOOL gave up, which is the
194
+ * opposite of a fact about the grammar.
195
+ */
196
+ export class CanonicalBudgetError extends Error {
197
+ override readonly name = 'CanonicalBudgetError'
198
+
199
+ constructor(budget: number, projected?: number) {
200
+ super(
201
+ `canonicalize: exceeded ${budget} visits. The value is not merely large — it is almost certainly a DAG being `
202
+ + 'unrolled. This projection writes a shared subtree once per PATH that reaches it (see "Sharing is not a '
203
+ + 'cycle"), so a node referenced from two places at each of d levels costs 2^d. Deduplicate the shared '
204
+ + 'structure, or raise `maxVisits` if the value really is that big.'
205
+ + (projected === undefined
206
+ ? ''
207
+ : ` Refused EARLY: the walk was projected to visit ${projected} objects, so it could not have finished `
208
+ + 'under this budget however long it ran. See {@link projectVisits}.'),
209
+ )
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Default visit budget.
215
+ *
216
+ * A backstop against unbounded work, not a correctness feature. Any walk that
217
+ * finishes under budget writes exactly the bytes it always did, so this cannot
218
+ * move a recorded digest — which is the only reason it can be added to a shipped
219
+ * format at all.
220
+ *
221
+ * It is NOT a fix for the underlying asymmetry. The fix is to dedupe by node
222
+ * IDENTITY instead of by ancestor path, and that rewrites the byte stream for
223
+ * every value with any sharing in it: `{ left: shared, right: shared }` stops
224
+ * being two full writes and becomes a back-reference. That invalidates every
225
+ * digest, every committed baseline, and {@link DIGEST_FORMAT} itself. It is a
226
+ * format decision for the owner, not something to slip in behind a bug fix.
227
+ *
228
+ * ## The tempting middle road does not exist
229
+ *
230
+ * Stated because it looks obviously right and is obviously right about the wrong
231
+ * thing: memoise the canonical BYTES of a subtree by node identity, splice the
232
+ * cached bytes in at each of the two places, and you get the identical output
233
+ * with the work done once. It is sound — a subtree's bytes depend only on the
234
+ * subtree, except for a back-edge leaving it, which is exactly the case
235
+ * {@link projectVisits} declines — and it does not help.
236
+ *
237
+ * It does not help because the walk is ALREADY linear in its own output, and it
238
+ * is the OUTPUT that is exponential: a depth-`d` two-way unroll writes 33·2^d
239
+ * chars, measured, and every one of them has to reach the hash. Caching removes
240
+ * re-derivation, of which there is a constant factor's worth; it cannot remove
241
+ * bytes the format requires. At depth 40 that is 36 TB of canonical text — 7
242
+ * hours of sha256 at the 1.37 Gchar/s this machine manages, with traversal,
243
+ * allocation and splicing all free. Depth 40 has no answer to compute quickly.
244
+ * There is only refusing quickly, which is what the probe does.
245
+ */
246
+ export const DEFAULT_MAX_VISITS = 100_000_000
247
+
248
+ /**
249
+ * Object visits to spend before the walk stops to ask whether it can possibly
250
+ * finish. Zero cost below it, so nothing a gate actually digests ever pays.
251
+ *
252
+ * A million visits is ~0.4s of walking on the machine this was measured on —
253
+ * far past any real parse tree, far short of the 49 SECONDS an exponential
254
+ * unroll used to spend before {@link DEFAULT_MAX_VISITS} noticed.
255
+ */
256
+ const EXPANSION_PROBE_VISITS = 1_000_000
257
+
258
+ /**
259
+ * Distinct objects the probe will look at before giving up on answering.
260
+ *
261
+ * A value with more distinct nodes than this is genuinely enormous rather than
262
+ * DAG-shaped, and the probe has nothing useful to say about it — an unrolling
263
+ * DAG is tiny in DISTINCT nodes (depth 40 is 41 of them) and huge only in paths.
264
+ * Capping here is what keeps the probe from becoming a second full walk: the cap
265
+ * is what the probe costs, once, in the worst case, and giving up early costs
266
+ * only the old behaviour.
267
+ */
268
+ const EXPANSION_PROBE_NODES = 200_000
269
+
270
+ /**
271
+ * How far past the budget a projection must land before it is acted on.
272
+ *
273
+ * The projection re-reads the value — including any getters — so it is a
274
+ * measurement of the value, not of the walk, and a value that answers
275
+ * differently the second time can make it wrong. A margin this wide means only
276
+ * an order-of-magnitude disagreement can refuse a walk that would have
277
+ * finished; anything nearer the line falls through to the walk itself, which is
278
+ * the authority. Erring costs time, never bytes.
279
+ */
280
+ const EXPANSION_PROBE_MARGIN = 4
281
+
282
+ type WriteState = {
283
+ readonly path: Map<object, number>
284
+ readonly sink: CanonicalSink
285
+ readonly budget: number
286
+ readonly root: unknown
287
+ visits: number
288
+ /**
289
+ * The value of {@link visits} at which to run the probe, or `-1` to never run
290
+ * it. Exact equality, so it fires once and is then unreachable.
291
+ */
292
+ probeAt: number
293
+ }
294
+
295
+ /**
296
+ * The number of objects {@link write} will visit for `root`, or `undefined` if
297
+ * that cannot be answered cheaply and exactly.
298
+ *
299
+ * This counts what the walk counts — one per object visit, zero for a scalar,
300
+ * one for a back-edge — but MEMOISED by node identity, so a shared subtree is
301
+ * counted once instead of once per path. That is the whole trick: counting the
302
+ * work is linear in DISTINCT nodes even when doing the work is exponential in
303
+ * paths.
304
+ *
305
+ * It answers `undefined` rather than guessing whenever the count could be wrong
306
+ * in the direction that matters:
307
+ *
308
+ * - **A back-edge.** Whether a node abbreviates to `^n` depends on the ancestor
309
+ * set at the moment it is reached, so for a cyclic value a memoised count is
310
+ * not context-free and can come out HIGH. Any back-edge at all abandons the
311
+ * answer.
312
+ * - **Too many distinct nodes.** {@link EXPANSION_PROBE_NODES}, above.
313
+ * - **A throw.** A getter, a Proxy trap, or the added stack depth of running a
314
+ * recursive count on top of an in-progress recursive walk. None of these are
315
+ * the probe's business to diagnose; not answering is always available.
316
+ */
317
+ function projectVisits(root: unknown): number | undefined {
318
+ const memo = new Map<object, number>()
319
+ const path = new Set<object>()
320
+ let nodes = 0
321
+ let usable = true
322
+
323
+ const count = (v: unknown): number => {
324
+ if (typeof v !== 'object' || v === null) return 0
325
+ if (path.has(v)) {
326
+ usable = false
327
+ return 1
328
+ }
329
+ const done = memo.get(v)
330
+ if (done !== undefined) return done
331
+ if (++nodes > EXPANSION_PROBE_NODES) {
332
+ usable = false
333
+ return 1
334
+ }
335
+
336
+ path.add(v)
337
+ // Mirrors `write`'s child enumeration exactly. Key ORDER is irrelevant to a
338
+ // count, so this does not sort; nothing else here may drift from `write`.
339
+ let total = 1
340
+ if (Array.isArray(v)) {
341
+ for (const item of v) total += count(item)
342
+ } else if (v instanceof Map) {
343
+ for (const [k, val] of v) total += count(k) + count(val)
344
+ } else if (v instanceof Set) {
345
+ for (const item of v) total += count(item)
346
+ } else if (!(v instanceof Date) && !(v instanceof RegExp)) {
347
+ for (const k of Object.keys(v)) total += count((v as Record<string, unknown>)[k])
348
+ }
349
+ path.delete(v)
350
+
351
+ memo.set(v, total)
352
+ return total
353
+ }
354
+
355
+ try {
356
+ const total = count(root)
357
+ return usable ? total : undefined
358
+ } catch {
359
+ return undefined
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Stop a walk that provably cannot finish, at the point it has proved expensive.
365
+ *
366
+ * The budget is a backstop that only notices after the work is done, and for an
367
+ * exponential unroll "after" is minutes. This asks the cheap question instead —
368
+ * how many visits would the whole walk take — and refuses immediately when the
369
+ * answer is far past the budget the walk is already spending.
370
+ *
371
+ * The OUTCOME is unchanged: a walk projected past its budget is a walk that
372
+ * raises {@link CanonicalBudgetError}, so this moves nothing but the clock. It
373
+ * cannot move a digest, because no walk it stops was ever going to produce one.
374
+ */
375
+ function probeExpansion(state: WriteState): void {
376
+ state.probeAt = -1
377
+ const projected = projectVisits(state.root)
378
+ if (projected !== undefined && projected / EXPANSION_PROBE_MARGIN > state.budget) {
379
+ throw new CanonicalBudgetError(state.budget, projected)
380
+ }
381
+ }
382
+
383
+ function writeScalar(v: unknown, out: CanonicalSink): void {
384
+ switch (typeof v) {
385
+ case 'undefined':
386
+ out.push('u')
387
+ return
388
+ case 'boolean':
389
+ out.push(v ? 'T' : 'F')
390
+ return
391
+ case 'bigint':
392
+ out.push(`n${v}`)
393
+ return
394
+ case 'string':
395
+ out.push(`s${str(v)}`)
396
+ return
397
+ // TODO(oracle-callable-identity): a callable is projected by NAME and a symbol by
398
+ // DESCRIPTION, so two distinct same-named functions — or two same-description
399
+ // symbols — digest identically, and a change between them reads as "identical".
400
+ // This is a real hole in a tool whose whole claim is paranoia, and it is left open
401
+ // deliberately: closing it means either hashing `Function.prototype.toString()`,
402
+ // which flips the digest on a comment edit inside a builder, or REFUSING to digest
403
+ // any value carrying a callable, which rejects legitimate ASTs that park a callback
404
+ // on a node. Both are worse than the hole for some caller, and picking between them
405
+ // is a product decision, not a bug fix. Documented under "What it is not" in
406
+ // `docs/guide/identity-oracle.md` so no reader infers a guarantee that is not here.
407
+ case 'symbol':
408
+ out.push(`y${str(v.description ?? '')}`)
409
+ return
410
+ case 'function':
411
+ out.push(`f${str(v.name)}`)
412
+ return
413
+ case 'number':
414
+ if (Number.isNaN(v)) out.push('#nan')
415
+ else if (v === Number.POSITIVE_INFINITY) out.push('#inf')
416
+ else if (v === Number.NEGATIVE_INFINITY) out.push('#-inf')
417
+ else if (Object.is(v, -0)) out.push('#-0')
418
+ // Number→string is exact and round-trips in JS, so this loses nothing.
419
+ else out.push(`#${v}`)
420
+ return
421
+ default:
422
+ // `typeof null`. Distinct from `undefined`, which is `'u'`.
423
+ out.push('z')
424
+ }
425
+ }
426
+
427
+ function write(v: unknown, state: WriteState, depth: number): void {
428
+ const out = state.sink
429
+ // Everything that is not a live object reference is written here, so the rest
430
+ // of the function has `v: object` and needs no assertion to index or key on it.
431
+ if (typeof v !== 'object' || v === null) {
432
+ writeScalar(v, out)
433
+ return
434
+ }
435
+
436
+ // Counted per OBJECT visit, not per token: the runaway this guards against is
437
+ // re-walking structure, and scalars cannot re-walk anything.
438
+ if (--state.visits < 0) throw new CanonicalBudgetError(state.budget)
439
+ // One equality against a number already in a register, on a path that already
440
+ // does three Map operations. Fires at most once per walk; `-1` is unreachable.
441
+ if (state.visits === state.probeAt) probeExpansion(state)
442
+
443
+ const path = state.path
444
+ const seenAt = path.get(v)
445
+ if (seenAt !== undefined) {
446
+ // Distance to the ancestor, not its identity: a self-reference and a
447
+ // reference to the root must not hash the same.
448
+ out.push(`^${depth - seenAt}`)
449
+ return
450
+ }
451
+ path.set(v, depth)
452
+
453
+ if (Array.isArray(v)) {
454
+ out.push('[')
455
+ // `for..of` rather than indexing so a sparse hole reads as the `undefined`
456
+ // it is, consistently with a present `undefined` — the distinction between
457
+ // a hole and an explicit undefined is not observable output.
458
+ for (const item of v) write(item, state, depth + 1)
459
+ out.push(']')
460
+ } else if (v instanceof Map) {
461
+ out.push('M')
462
+ for (const [k, val] of v) {
463
+ write(k, state, depth + 1)
464
+ write(val, state, depth + 1)
465
+ }
466
+ out.push('}')
467
+ } else if (v instanceof Set) {
468
+ out.push('S')
469
+ for (const item of v) write(item, state, depth + 1)
470
+ out.push('}')
471
+ } else if (v instanceof Date) {
472
+ out.push(`D${v.getTime()}`)
473
+ } else if (v instanceof RegExp) {
474
+ out.push(`R${str(v.source)}${str(v.flags)}`)
475
+ } else {
476
+ const tag = tagOf(v)
477
+ out.push(`{${str(tag === 'Object' ? '' : tag)}`)
478
+ for (const k of Object.keys(v).sort()) {
479
+ out.push(`k${str(k)}`)
480
+ write((v as Record<string, unknown>)[k], state, depth + 1)
481
+ }
482
+ out.push('}')
483
+ }
484
+
485
+ path.delete(v)
486
+ }
487
+
488
+ /** Options shared by every entry point that walks a value. */
489
+ export type CanonicalOptions = {
490
+ /**
491
+ * Maximum object visits before {@link CanonicalBudgetError}. Defaults to
492
+ * {@link DEFAULT_MAX_VISITS}. A walk that finishes under budget is byte-for-byte
493
+ * unaffected, so this can never change a digest.
494
+ *
495
+ * MUST be a non-negative safe integer. `NaN`, `Infinity`, a negative and a
496
+ * fractional value are all REJECTED rather than clamped — see
497
+ * {@link newState} for why a bad budget is a thrown error and not a shrug.
498
+ */
499
+ maxVisits?: number | undefined
500
+ }
501
+
502
+ /**
503
+ * Build the walk state, validating the budget before anything is written.
504
+ *
505
+ * The budget is spent with `--visits < 0`, and that comparison is silently
506
+ * FALSE for `NaN` — so `maxVisits: NaN` did not raise the ceiling, it removed
507
+ * it, and the walk it was supposed to bound ran to the OOM the budget exists to
508
+ * prevent. `Infinity` is the same wish spelled honestly and gets the same
509
+ * refusal, because an unbounded walk is not an option this offers. A fraction
510
+ * never reaches `-1` cleanly either.
511
+ *
512
+ * So the budget is checked once, here, where the option is read: a bad number is
513
+ * a caller mistake and says so, rather than turning into a hang under load.
514
+ */
515
+ function newState(sink: CanonicalSink, options: CanonicalOptions | undefined, root: unknown): WriteState {
516
+ const budget = options?.maxVisits ?? DEFAULT_MAX_VISITS
517
+ if (!Number.isSafeInteger(budget) || budget < 0) {
518
+ throw new RangeError(
519
+ `canonicalize: maxVisits must be a non-negative safe integer, received ${String(budget)}. `
520
+ + 'A non-finite or fractional budget does not raise the ceiling, it REMOVES it — the budget is spent with '
521
+ + '`--visits < 0`, which is false forever for NaN and never reached for Infinity — so the walk this bounds '
522
+ + 'would run unbounded into the OOM the budget exists to prevent.',
523
+ )
524
+ }
525
+ // A budget too small to reach the probe never reaches it: the walk raises
526
+ // CanonicalBudgetError first, exactly as it always did.
527
+ const probeAt = budget >= EXPANSION_PROBE_VISITS ? budget - EXPANSION_PROBE_VISITS : -1
528
+ return { path: new Map(), sink, budget, root, visits: budget, probeAt }
529
+ }
530
+
531
+ /**
532
+ * The canonical token string for a value. Exported because a moved digest is
533
+ * only actionable if you can see WHAT moved: diff two canonical strings and the
534
+ * answer is a line, not a hex mismatch.
535
+ *
536
+ * This MATERIALISES the projection, so it is bounded by the maximum JS string
537
+ * length and by available memory. Use it to explain a move you already know
538
+ * about; take the digest itself with {@link digestInto}, which is bounded by
539
+ * neither.
540
+ */
541
+ export function canonicalize(value: unknown, options?: CanonicalOptions): string {
542
+ const out = new ArraySink()
543
+ write(value, newState(out, options, value), 0)
544
+ return out.tokens.join(SEPARATOR)
545
+ }
546
+
547
+ /**
548
+ * Stream a value's canonical projection into a caller-owned hash.
549
+ *
550
+ * This is the primitive: deterministic serialization of ONE parse result, which
551
+ * is the part only parseman can do — it is parseman's node shapes that decide
552
+ * which distinctions are semantically meaningful. The caller brings the hash and
553
+ * keeps the result, so the algorithm, the encoding and the digest width are all
554
+ * theirs.
555
+ *
556
+ * ```ts
557
+ * const sha = createHash('sha256')
558
+ * digestInto(sha, tree)
559
+ * const digest = sha.digest('hex')
560
+ * ```
561
+ *
562
+ * `prefix` is written ahead of the first token with NO separator, for callers
563
+ * that discriminate one digest space from another (parseman's own oracle writes
564
+ * `OK:` or `ERR:`). Pass `''` when there is nothing to discriminate.
565
+ *
566
+ * Equivalent to `hash(prefix + canonicalize(value))`, and preferable in every
567
+ * case where you do not need to READ the projection.
568
+ *
569
+ * ## Two sharp edges, stated because the target is YOURS
570
+ *
571
+ * **A throw leaves the target written-to.** The token stream is pushed at
572
+ * `target` as the walk proceeds, so a {@link CanonicalBudgetError}, a throwing
573
+ * getter, or any other failure mid-walk leaves an ARBITRARY PREFIX of the
574
+ * projection already absorbed. The hash object is then polluted: its digest is
575
+ * neither the value's nor anything else's, and it is not recoverable — a
576
+ * `crypto.Hash` cannot be rewound. On any throw, DISCARD the target and start a
577
+ * fresh one. {@link digestValue} does exactly that by owning its hash for a
578
+ * single call.
579
+ *
580
+ * **Two calls against one target concatenate with NO delimiter.** There is no
581
+ * record separator between calls: `digestInto(t, a); digestInto(t, b)` writes
582
+ * exactly `canonicalize(a) + canonicalize(b)`, with nothing in between. That is
583
+ * ambiguous across the call boundary — the byte stream does not record where one
584
+ * value ended, so a differently-split sequence can produce the identical stream
585
+ * and therefore the identical digest. Concretely, `digestInto(t, 1);
586
+ * digestInto(t, 2)` and the single call `digestInto(t, 2, '#1')` both write
587
+ * `#1#2`. (Within ONE call the projection is unambiguous — every token is
588
+ * self-contained and NUL-joined; it is only the seam between calls that is not.)
589
+ *
590
+ * This is DOCUMENTED rather than fixed. Any delimiter at the seam — written
591
+ * before, after, or between — changes the bytes some existing caller already
592
+ * hashes, and moving a recorded digest is a {@link DIGEST_FORMAT} decision for
593
+ * the owner, not a patch. Callers hashing a SEQUENCE should digest a wrapper
594
+ * value (`digestInto(t, [a, b])`), or write their own unambiguous separator
595
+ * between calls, or use one target per value.
596
+ */
597
+ export function digestInto(
598
+ target: DigestTarget,
599
+ value: unknown,
600
+ prefix = '',
601
+ options?: CanonicalOptions,
602
+ ): void {
603
+ const sink = new StreamSink(target)
604
+ sink.prefix(prefix)
605
+ write(value, newState(sink, options, value), 0)
606
+ sink.flush()
607
+ }
608
+
609
+ /**
610
+ * Full 64-hex sha256 of a value's canonical projection, streamed.
611
+ *
612
+ * The convenience wrapper over {@link digestInto} for the common case.
613
+ * Identical to `sha256(prefix + canonicalize(value))` for every value the
614
+ * latter can survive, and unbounded where it is not.
615
+ */
616
+ export function digestValue(value: unknown, prefix = '', options?: CanonicalOptions): string {
617
+ const sha = createHash('sha256')
618
+ digestInto(sha, value, prefix, options)
619
+ return sha.digest('hex')
620
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `parseman/oracle` — deterministic serialization of a parse result.
3
+ *
4
+ * Turns "is this rule collapse safe?" from a judgement call into an accept/reject.
5
+ * Digest your parse output before and after the change and compare the two
6
+ * digests: equal means the refactor is output-neutral and can land on its own
7
+ * merits; different means it is not a refactor, it is a semantics change, and
8
+ * needs the decision that goes with one.
9
+ *
10
+ * import { digestInto } from 'parseman/oracle'
11
+ * import { createHash } from 'node:crypto'
12
+ *
13
+ * const sha = createHash('sha256')
14
+ * digestInto(sha, parse(source))
15
+ * const digest = sha.digest('hex')
16
+ *
17
+ * It is a differential, not a correctness check. It says the output did not move;
18
+ * it says nothing about whether the output was right to begin with.
19
+ *
20
+ * ## Why the projection is here and the harness is not
21
+ *
22
+ * Deciding what the canonical byte stream IS depends on parseman's node shapes
23
+ * and on nothing else — which distinctions a refactor can move, and which are
24
+ * bookkeeping no consumer observes. No consumer can get that right, so it lives
25
+ * here, and every grammar author needs it whatever they are building.
26
+ *
27
+ * Walking a corpus, folding per-entry digests into an aggregate, three-way
28
+ * verdicts and report formatting used to live here too, as `loadCorpus`,
29
+ * `digestCorpus`, `compareReports` and `formatComparison`. Those do not help
30
+ * anyone build or diagnose a grammar: they are one consumer's regression-suite
31
+ * plumbing, and they only mean anything with that consumer's corpus roots and
32
+ * committed baseline in hand. They now live with the suite that needs them —
33
+ * jess's is `packages/syntax/less/less-parser/test/identity-oracle/`, a
34
+ * reasonable model to copy. Two things are worth carrying over: keep an
35
+ * `OK:`/`ERR:` prefix so a successful parse and a thrown error stay in disjoint
36
+ * hash spaces, and keep "the grammar rejected this input" and "the digest could
37
+ * not be computed" on separate channels — the second is a fact about the TOOL,
38
+ * and reporting it as the first is how a gate lies.
39
+ *
40
+ * Node-only: it hashes with `node:crypto`. It is a development gate, so it is a
41
+ * separate entry point and nothing in it reaches the browser-capable `parseman`
42
+ * bundle.
43
+ *
44
+ * See `docs/guide/identity-oracle.md` for the operating discipline — in
45
+ * particular why you must digest the BUILT artifact and rebuild between edits.
46
+ */
47
+ export {
48
+ canonicalize,
49
+ digestInto,
50
+ digestValue,
51
+ CanonicalBudgetError,
52
+ DEFAULT_MAX_VISITS,
53
+ DIGEST_FORMAT,
54
+ } from './digest.ts'
55
+ export type { CanonicalOptions, DigestTarget } from './digest.ts'