parseman 0.43.0 → 0.45.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 (97) hide show
  1. package/dist/analysis/diagnose.d.ts +118 -0
  2. package/dist/analysis/diagnose.d.ts.map +1 -0
  3. package/dist/analysis/gating.d.ts +9 -9
  4. package/dist/analysis/gating.d.ts.map +1 -1
  5. package/dist/analysis/grammar.d.ts.map +1 -1
  6. package/dist/combinators/attempt.d.ts.map +1 -1
  7. package/dist/combinators/choice.d.ts.map +1 -1
  8. package/dist/combinators/dispatch.d.ts +19 -1
  9. package/dist/combinators/dispatch.d.ts.map +1 -1
  10. package/dist/combinators/expect.d.ts.map +1 -1
  11. package/dist/combinators/first-set.d.ts.map +1 -1
  12. package/dist/combinators/grammar.d.ts +7 -0
  13. package/dist/combinators/grammar.d.ts.map +1 -1
  14. package/dist/combinators/map.d.ts +11 -0
  15. package/dist/combinators/map.d.ts.map +1 -1
  16. package/dist/combinators/node.d.ts +19 -0
  17. package/dist/combinators/node.d.ts.map +1 -1
  18. package/dist/combinators/token.d.ts +3 -2
  19. package/dist/combinators/token.d.ts.map +1 -1
  20. package/dist/combinators/trivia-skip.d.ts +1 -0
  21. package/dist/combinators/trivia-skip.d.ts.map +1 -1
  22. package/dist/compiler/build-arity.d.ts +28 -0
  23. package/dist/compiler/build-arity.d.ts.map +1 -1
  24. package/dist/compiler/codegen.d.ts +80 -60
  25. package/dist/compiler/codegen.d.ts.map +1 -1
  26. package/dist/compiler/degradation.d.ts +123 -0
  27. package/dist/compiler/degradation.d.ts.map +1 -0
  28. package/dist/compiler/fields.d.ts.map +1 -1
  29. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  30. package/dist/compiler/inline-build.d.ts.map +1 -1
  31. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  32. package/dist/compiler/linker.d.ts +1 -1
  33. package/dist/compiler/linker.d.ts.map +1 -1
  34. package/dist/compiler/scannable-run.d.ts +1 -1
  35. package/dist/compiler/scannable-run.d.ts.map +1 -1
  36. package/dist/compiler/trivia-fast-path.d.ts +4 -3
  37. package/dist/compiler/trivia-fast-path.d.ts.map +1 -1
  38. package/dist/coverage.d.ts +13 -0
  39. package/dist/coverage.d.ts.map +1 -1
  40. package/dist/cst/reflection.d.ts.map +1 -1
  41. package/dist/cst/trivia-entries.d.ts +15 -9
  42. package/dist/cst/trivia-entries.d.ts.map +1 -1
  43. package/dist/cst/trivia-kinds.d.ts +15 -11
  44. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  45. package/dist/functional/run.d.ts +22 -16
  46. package/dist/functional/run.d.ts.map +1 -1
  47. package/dist/index.cjs +2131 -1594
  48. package/dist/index.cjs.map +4 -4
  49. package/dist/index.d.ts +6 -8
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +2131 -1594
  52. package/dist/index.js.map +4 -4
  53. package/dist/language-service/index.cjs +201 -132
  54. package/dist/language-service/index.cjs.map +4 -4
  55. package/dist/language-service/index.js +201 -132
  56. package/dist/language-service/index.js.map +4 -4
  57. package/dist/oracle/digest.d.ts +132 -6
  58. package/dist/oracle/digest.d.ts.map +1 -1
  59. package/dist/oracle/index.cjs +136 -305
  60. package/dist/oracle/index.cjs.map +4 -4
  61. package/dist/oracle/index.d.ts +43 -31
  62. package/dist/oracle/index.d.ts.map +1 -1
  63. package/dist/oracle/index.js +136 -305
  64. package/dist/oracle/index.js.map +4 -4
  65. package/dist/plugin/evaluator.d.ts +3 -0
  66. package/dist/plugin/evaluator.d.ts.map +1 -1
  67. package/dist/plugin/index.cjs +2333 -1721
  68. package/dist/plugin/index.cjs.map +4 -4
  69. package/dist/plugin/index.d.ts +18 -0
  70. package/dist/plugin/index.d.ts.map +1 -1
  71. package/dist/plugin/index.js +2333 -1721
  72. package/dist/plugin/index.js.map +4 -4
  73. package/dist/plugin/reducer-resolver.d.ts +41 -0
  74. package/dist/plugin/reducer-resolver.d.ts.map +1 -0
  75. package/dist/run/index.cjs +185 -132
  76. package/dist/run/index.cjs.map +3 -3
  77. package/dist/run/index.d.ts +1 -1
  78. package/dist/run/index.d.ts.map +1 -1
  79. package/dist/run/index.js +185 -132
  80. package/dist/run/index.js.map +3 -3
  81. package/dist/spec/index.cjs +1637 -1322
  82. package/dist/spec/index.cjs.map +4 -4
  83. package/dist/spec/index.js +1637 -1322
  84. package/dist/spec/index.js.map +4 -4
  85. package/dist/spec/model.d.ts.map +1 -1
  86. package/dist/types.d.ts +31 -1
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/version.d.ts +1 -1
  89. package/package.json +7 -3
  90. package/dist/combinators/guard.d.ts +0 -8
  91. package/dist/combinators/guard.d.ts.map +0 -1
  92. package/dist/cst/offset-model.d.ts +0 -119
  93. package/dist/cst/offset-model.d.ts.map +0 -1
  94. package/dist/oracle/corpus.d.ts +0 -32
  95. package/dist/oracle/corpus.d.ts.map +0 -1
  96. package/dist/oracle/identity.d.ts +0 -117
  97. package/dist/oracle/identity.d.ts.map +0 -1
@@ -5,17 +5,143 @@
5
5
  * comparing two reports should not have to diff hex to see that the format moved.
6
6
  */
7
7
  export declare const DIGEST_FORMAT = 1;
8
+ /**
9
+ * Anything that absorbs a stream of text. Node's `crypto.Hash` satisfies it, and
10
+ * so does a three-line test double.
11
+ *
12
+ * The CALLER supplies this, and therefore owns the algorithm and the result.
13
+ * Deciding what the canonical byte stream IS depends on parseman's node shapes
14
+ * and on nothing else; deciding that you wanted sha256-hex does not.
15
+ */
16
+ export type DigestTarget = {
17
+ update(chunk: string): void;
18
+ };
19
+ /**
20
+ * Raised when a walk exceeds its visit budget.
21
+ *
22
+ * A distinct class, and deliberately NOT an ordinary `Error` a caller might
23
+ * lump in with a parse failure: this says the TOOL gave up, which is the
24
+ * opposite of a fact about the grammar.
25
+ */
26
+ export declare class CanonicalBudgetError extends Error {
27
+ readonly name = "CanonicalBudgetError";
28
+ constructor(budget: number, projected?: number);
29
+ }
30
+ /**
31
+ * Default visit budget.
32
+ *
33
+ * A backstop against unbounded work, not a correctness feature. Any walk that
34
+ * finishes under budget writes exactly the bytes it always did, so this cannot
35
+ * move a recorded digest — which is the only reason it can be added to a shipped
36
+ * format at all.
37
+ *
38
+ * It is NOT a fix for the underlying asymmetry. The fix is to dedupe by node
39
+ * IDENTITY instead of by ancestor path, and that rewrites the byte stream for
40
+ * every value with any sharing in it: `{ left: shared, right: shared }` stops
41
+ * being two full writes and becomes a back-reference. That invalidates every
42
+ * digest, every committed baseline, and {@link DIGEST_FORMAT} itself. It is a
43
+ * format decision for the owner, not something to slip in behind a bug fix.
44
+ *
45
+ * ## The tempting middle road does not exist
46
+ *
47
+ * Stated because it looks obviously right and is obviously right about the wrong
48
+ * thing: memoise the canonical BYTES of a subtree by node identity, splice the
49
+ * cached bytes in at each of the two places, and you get the identical output
50
+ * with the work done once. It is sound — a subtree's bytes depend only on the
51
+ * subtree, except for a back-edge leaving it, which is exactly the case
52
+ * {@link projectVisits} declines — and it does not help.
53
+ *
54
+ * It does not help because the walk is ALREADY linear in its own output, and it
55
+ * is the OUTPUT that is exponential: a depth-`d` two-way unroll writes 33·2^d
56
+ * chars, measured, and every one of them has to reach the hash. Caching removes
57
+ * re-derivation, of which there is a constant factor's worth; it cannot remove
58
+ * bytes the format requires. At depth 40 that is 36 TB of canonical text — 7
59
+ * hours of sha256 at the 1.37 Gchar/s this machine manages, with traversal,
60
+ * allocation and splicing all free. Depth 40 has no answer to compute quickly.
61
+ * There is only refusing quickly, which is what the probe does.
62
+ */
63
+ export declare const DEFAULT_MAX_VISITS = 100000000;
64
+ /** Options shared by every entry point that walks a value. */
65
+ export type CanonicalOptions = {
66
+ /**
67
+ * Maximum object visits before {@link CanonicalBudgetError}. Defaults to
68
+ * {@link DEFAULT_MAX_VISITS}. A walk that finishes under budget is byte-for-byte
69
+ * unaffected, so this can never change a digest.
70
+ *
71
+ * MUST be a non-negative safe integer. `NaN`, `Infinity`, a negative and a
72
+ * fractional value are all REJECTED rather than clamped — see
73
+ * {@link newState} for why a bad budget is a thrown error and not a shrug.
74
+ */
75
+ maxVisits?: number | undefined;
76
+ };
8
77
  /**
9
78
  * The canonical token string for a value. Exported because a moved digest is
10
79
  * only actionable if you can see WHAT moved: diff two canonical strings and the
11
80
  * answer is a line, not a hex mismatch.
81
+ *
82
+ * This MATERIALISES the projection, so it is bounded by the maximum JS string
83
+ * length and by available memory. Use it to explain a move you already know
84
+ * about; take the digest itself with {@link digestInto}, which is bounded by
85
+ * neither.
86
+ */
87
+ export declare function canonicalize(value: unknown, options?: CanonicalOptions): string;
88
+ /**
89
+ * Stream a value's canonical projection into a caller-owned hash.
90
+ *
91
+ * This is the primitive: deterministic serialization of ONE parse result, which
92
+ * is the part only parseman can do — it is parseman's node shapes that decide
93
+ * which distinctions are semantically meaningful. The caller brings the hash and
94
+ * keeps the result, so the algorithm, the encoding and the digest width are all
95
+ * theirs.
96
+ *
97
+ * ```ts
98
+ * const sha = createHash('sha256')
99
+ * digestInto(sha, tree)
100
+ * const digest = sha.digest('hex')
101
+ * ```
102
+ *
103
+ * `prefix` is written ahead of the first token with NO separator, for callers
104
+ * that discriminate one digest space from another (parseman's own oracle writes
105
+ * `OK:` or `ERR:`). Pass `''` when there is nothing to discriminate.
106
+ *
107
+ * Equivalent to `hash(prefix + canonicalize(value))`, and preferable in every
108
+ * case where you do not need to READ the projection.
109
+ *
110
+ * ## Two sharp edges, stated because the target is YOURS
111
+ *
112
+ * **A throw leaves the target written-to.** The token stream is pushed at
113
+ * `target` as the walk proceeds, so a {@link CanonicalBudgetError}, a throwing
114
+ * getter, or any other failure mid-walk leaves an ARBITRARY PREFIX of the
115
+ * projection already absorbed. The hash object is then polluted: its digest is
116
+ * neither the value's nor anything else's, and it is not recoverable — a
117
+ * `crypto.Hash` cannot be rewound. On any throw, DISCARD the target and start a
118
+ * fresh one. {@link digestValue} does exactly that by owning its hash for a
119
+ * single call.
120
+ *
121
+ * **Two calls against one target concatenate with NO delimiter.** There is no
122
+ * record separator between calls: `digestInto(t, a); digestInto(t, b)` writes
123
+ * exactly `canonicalize(a) + canonicalize(b)`, with nothing in between. That is
124
+ * ambiguous across the call boundary — the byte stream does not record where one
125
+ * value ended, so a differently-split sequence can produce the identical stream
126
+ * and therefore the identical digest. Concretely, `digestInto(t, 1);
127
+ * digestInto(t, 2)` and the single call `digestInto(t, 2, '#1')` both write
128
+ * `#1#2`. (Within ONE call the projection is unambiguous — every token is
129
+ * self-contained and NUL-joined; it is only the seam between calls that is not.)
130
+ *
131
+ * This is DOCUMENTED rather than fixed. Any delimiter at the seam — written
132
+ * before, after, or between — changes the bytes some existing caller already
133
+ * hashes, and moving a recorded digest is a {@link DIGEST_FORMAT} decision for
134
+ * the owner, not a patch. Callers hashing a SEQUENCE should digest a wrapper
135
+ * value (`digestInto(t, [a, b])`), or write their own unambiguous separator
136
+ * between calls, or use one target per value.
12
137
  */
13
- export declare function canonicalize(value: unknown): string;
14
- /** Full 64-hex sha256 of a string. */
15
- export declare function hash(payload: string): string;
138
+ export declare function digestInto(target: DigestTarget, value: unknown, prefix?: string, options?: CanonicalOptions): void;
16
139
  /**
17
- * Per-entry fingerprint width. 64 bits over any realistic corpus is far below
18
- * the collision floor, and a report a human has to read stays readable.
140
+ * Full 64-hex sha256 of a value's canonical projection, streamed.
141
+ *
142
+ * The convenience wrapper over {@link digestInto} for the common case.
143
+ * Identical to `sha256(prefix + canonicalize(value))` for every value the
144
+ * latter can survive, and unbounded where it is not.
19
145
  */
20
- export declare const FINGERPRINT_HEX = 16;
146
+ export declare function digestValue(value: unknown, prefix?: string, options?: CanonicalOptions): string;
21
147
  //# sourceMappingURL=digest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../src/oracle/digest.ts"],"names":[],"mappings":"AAsDA;;;;;GAKG;AACH,eAAO,MAAM,aAAa,IAAI,CAAA;AAyG9B;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAInD;AAED,sCAAsC;AACtC,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE5C;AAED;;;GAGG;AACH,eAAO,MAAM,eAAe,KAAK,CAAA"}
1
+ {"version":3,"file":"digest.d.ts","sourceRoot":"","sources":["../../src/oracle/digest.ts"],"names":[],"mappings":"AAoEA;;;;;GAKG;AACH,eAAO,MAAM,aAAa,IAAI,CAAA;AAsC9B;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B,CAAA;AAkED;;;;;;GAMG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,SAAkB,IAAI,0BAAyB;IAE/C,YAAY,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,EAW7C;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,kBAAkB,YAAc,CAAA;AAkP7C,8DAA8D;AAC9D,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC/B,CAAA;AA+BD;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,MAAM,CAI/E;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,OAAO,EACd,MAAM,SAAK,EACX,OAAO,CAAC,EAAE,gBAAgB,GACzB,IAAI,CAKN;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,SAAK,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,MAAM,CAI3F"}
@@ -20,24 +20,125 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/oracle/index.ts
21
21
  var oracle_exports = {};
22
22
  __export(oracle_exports, {
23
+ CanonicalBudgetError: () => CanonicalBudgetError,
24
+ DEFAULT_MAX_VISITS: () => DEFAULT_MAX_VISITS,
23
25
  DIGEST_FORMAT: () => DIGEST_FORMAT,
24
- HARNESS_DIGEST: () => HARNESS_DIGEST,
25
26
  canonicalize: () => canonicalize,
26
- compareReports: () => compareReports,
27
- digestCorpus: () => digestCorpus,
28
- formatComparison: () => formatComparison,
29
- loadCorpus: () => loadCorpus
27
+ digestInto: () => digestInto,
28
+ digestValue: () => digestValue
30
29
  });
31
30
  module.exports = __toCommonJS(oracle_exports);
32
31
 
33
32
  // src/oracle/digest.ts
34
33
  var import_node_crypto = require("node:crypto");
35
34
  var DIGEST_FORMAT = 1;
35
+ var SEPARATOR = String.fromCharCode(0);
36
36
  var str = (s) => JSON.stringify(s);
37
37
  var tagOf = (v) => {
38
38
  const ctor = v.constructor;
39
39
  return typeof ctor === "function" && typeof ctor.name === "string" ? ctor.name : "";
40
40
  };
41
+ var ArraySink = class {
42
+ tokens = [];
43
+ push(token) {
44
+ this.tokens.push(token);
45
+ }
46
+ };
47
+ var StreamSink = class {
48
+ target;
49
+ buffer = [];
50
+ buffered = 0;
51
+ started = false;
52
+ constructor(target) {
53
+ this.target = target;
54
+ }
55
+ /**
56
+ * Write text that is NOT a canonical token and takes no separator — the
57
+ * `OK:` / `ERR:` discriminator, which the string form concatenates onto the
58
+ * front rather than joining.
59
+ */
60
+ prefix(text) {
61
+ if (text === "") return;
62
+ this.buffer.push(text);
63
+ this.buffered += text.length;
64
+ }
65
+ push(token) {
66
+ if (this.started) {
67
+ this.buffer.push(SEPARATOR);
68
+ this.buffered += 1;
69
+ } else {
70
+ this.started = true;
71
+ }
72
+ this.buffer.push(token);
73
+ this.buffered += token.length;
74
+ if (this.buffered >= FLUSH_CHARS) this.flush();
75
+ }
76
+ flush() {
77
+ if (this.buffer.length === 0) return;
78
+ this.target.update(this.buffer.join(""));
79
+ this.buffer = [];
80
+ this.buffered = 0;
81
+ }
82
+ };
83
+ var FLUSH_CHARS = 1 << 16;
84
+ var CanonicalBudgetError = class extends Error {
85
+ name = "CanonicalBudgetError";
86
+ constructor(budget, projected) {
87
+ super(
88
+ `canonicalize: exceeded ${budget} visits. The value is not merely large \u2014 it is almost certainly a DAG being unrolled. This projection writes a shared subtree once per PATH that reaches it (see "Sharing is not a cycle"), so a node referenced from two places at each of d levels costs 2^d. Deduplicate the shared structure, or raise \`maxVisits\` if the value really is that big.` + (projected === void 0 ? "" : ` Refused EARLY: the walk was projected to visit ${projected} objects, so it could not have finished under this budget however long it ran. See {@link projectVisits}.`)
89
+ );
90
+ }
91
+ };
92
+ var DEFAULT_MAX_VISITS = 1e8;
93
+ var EXPANSION_PROBE_VISITS = 1e6;
94
+ var EXPANSION_PROBE_NODES = 2e5;
95
+ var EXPANSION_PROBE_MARGIN = 4;
96
+ function projectVisits(root) {
97
+ const memo = /* @__PURE__ */ new Map();
98
+ const path = /* @__PURE__ */ new Set();
99
+ let nodes = 0;
100
+ let usable = true;
101
+ const count = (v) => {
102
+ if (typeof v !== "object" || v === null) return 0;
103
+ if (path.has(v)) {
104
+ usable = false;
105
+ return 1;
106
+ }
107
+ const done = memo.get(v);
108
+ if (done !== void 0) return done;
109
+ if (++nodes > EXPANSION_PROBE_NODES) {
110
+ usable = false;
111
+ return 1;
112
+ }
113
+ path.add(v);
114
+ let total = 1;
115
+ if (Array.isArray(v)) {
116
+ for (const item of v) total += count(item);
117
+ } else if (v instanceof Map) {
118
+ for (const [k, val] of v) total += count(k) + count(val);
119
+ } else if (v instanceof Set) {
120
+ for (const item of v) total += count(item);
121
+ } else if (!(v instanceof Date) && !(v instanceof RegExp)) {
122
+ for (const k of Object.keys(v)) total += count(v[k]);
123
+ }
124
+ path.delete(v);
125
+ memo.set(v, total);
126
+ return total;
127
+ };
128
+ try {
129
+ const total = count(root);
130
+ return usable ? total : void 0;
131
+ } catch {
132
+ return void 0;
133
+ }
134
+ }
135
+ function probeExpansion(state) {
136
+ state.probeAt = -1;
137
+ const projected = projectVisits(state.root);
138
+ if (projected !== void 0 && projected / EXPANSION_PROBE_MARGIN > state.budget) {
139
+ throw new CanonicalBudgetError(state.budget, projected);
140
+ }
141
+ }
41
142
  function writeScalar(v, out) {
42
143
  switch (typeof v) {
43
144
  case "undefined":
@@ -79,11 +180,15 @@ function writeScalar(v, out) {
79
180
  out.push("z");
80
181
  }
81
182
  }
82
- function write(v, path, depth, out) {
183
+ function write(v, state, depth) {
184
+ const out = state.sink;
83
185
  if (typeof v !== "object" || v === null) {
84
186
  writeScalar(v, out);
85
187
  return;
86
188
  }
189
+ if (--state.visits < 0) throw new CanonicalBudgetError(state.budget);
190
+ if (state.visits === state.probeAt) probeExpansion(state);
191
+ const path = state.path;
87
192
  const seenAt = path.get(v);
88
193
  if (seenAt !== void 0) {
89
194
  out.push(`^${depth - seenAt}`);
@@ -92,18 +197,18 @@ function write(v, path, depth, out) {
92
197
  path.set(v, depth);
93
198
  if (Array.isArray(v)) {
94
199
  out.push("[");
95
- for (const item of v) write(item, path, depth + 1, out);
200
+ for (const item of v) write(item, state, depth + 1);
96
201
  out.push("]");
97
202
  } else if (v instanceof Map) {
98
203
  out.push("M");
99
204
  for (const [k, val] of v) {
100
- write(k, path, depth + 1, out);
101
- write(val, path, depth + 1, out);
205
+ write(k, state, depth + 1);
206
+ write(val, state, depth + 1);
102
207
  }
103
208
  out.push("}");
104
209
  } else if (v instanceof Set) {
105
210
  out.push("S");
106
- for (const item of v) write(item, path, depth + 1, out);
211
+ for (const item of v) write(item, state, depth + 1);
107
212
  out.push("}");
108
213
  } else if (v instanceof Date) {
109
214
  out.push(`D${v.getTime()}`);
@@ -114,310 +219,36 @@ function write(v, path, depth, out) {
114
219
  out.push(`{${str(tag === "Object" ? "" : tag)}`);
115
220
  for (const k of Object.keys(v).sort()) {
116
221
  out.push(`k${str(k)}`);
117
- write(v[k], path, depth + 1, out);
222
+ write(v[k], state, depth + 1);
118
223
  }
119
224
  out.push("}");
120
225
  }
121
226
  path.delete(v);
122
227
  }
123
- function canonicalize(value) {
124
- const out = [];
125
- write(value, /* @__PURE__ */ new Map(), 0, out);
126
- return out.join("\0");
127
- }
128
- function hash(payload2) {
129
- return (0, import_node_crypto.createHash)("sha256").update(payload2).digest("hex");
130
- }
131
- var FINGERPRINT_HEX = 16;
132
-
133
- // src/oracle/identity.ts
134
- var defaultProjectError = (thrown) => thrown instanceof Error ? { name: thrown.name, message: thrown.message } : { thrown };
135
- function payload(surface, entry, options) {
136
- try {
137
- return { text: `OK:${canonicalize(surface.parse(entry.source, entry.id))}`, threw: false };
138
- } catch (thrown) {
139
- const project = options.projectError ?? defaultProjectError;
140
- return { text: `ERR:${canonicalize(project(thrown, entry.id, surface.name))}`, threw: true };
141
- }
142
- }
143
- var fingerprint = (text) => hash(text).slice(0, FINGERPRINT_HEX);
144
- function digestCorpus(surfaces, corpus, options = {}) {
145
- if (surfaces.length === 0) throw new Error("digestCorpus: no surfaces \u2014 there is nothing to compare.");
146
- const names = /* @__PURE__ */ new Set();
147
- for (const s of surfaces) {
148
- if (names.has(s.name)) throw new Error(`digestCorpus: duplicate surface name ${JSON.stringify(s.name)}.`);
149
- names.add(s.name);
150
- }
151
- const entries = [...corpus].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
152
- for (let n = 1; n < entries.length; n++) {
153
- if (entries[n].id === entries[n - 1].id) {
154
- throw new Error(`digestCorpus: duplicate corpus id ${JSON.stringify(entries[n].id)}.`);
155
- }
156
- }
157
- const perEntry = {};
158
- const threw = {};
159
- const texts = [];
160
- for (const s of surfaces) threw[s.name] = 0;
161
- for (const entry of entries) {
162
- const row = {};
163
- const rowTexts = /* @__PURE__ */ new Map();
164
- for (const s of surfaces) {
165
- const result = payload(s, entry, options);
166
- if (result.threw) threw[s.name]++;
167
- row[s.name] = fingerprint(result.text);
168
- rowTexts.set(s.name, result.text);
169
- }
170
- perEntry[entry.id] = row;
171
- texts.push(rowTexts);
172
- }
173
- verifyDeterminism(surfaces, entries, texts, options);
174
- return {
175
- format: DIGEST_FORMAT,
176
- harness: HARNESS_DIGEST,
177
- entries: entries.length,
178
- surfaces: surfaces.map((s) => ({
179
- name: s.name,
180
- // The surface NAME is inside its own aggregate: two surfaces that happen
181
- // to agree on every entry must still have distinct aggregates, or a
182
- // report that swapped them would compare equal.
183
- aggregate: hash(
184
- [`surface:${s.name}`, ...entries.map((e) => `${e.id}:${perEntry[e.id][s.name]}`)].join("\n")
185
- ),
186
- threw: threw[s.name]
187
- })),
188
- perEntry
189
- };
190
- }
191
- function verifyDeterminism(surfaces, entries, texts, options) {
192
- const want = options.determinismSample ?? 32;
193
- if (want <= 0 || entries.length === 0) return;
194
- const stride = Math.max(1, Math.floor(entries.length / Math.min(want, entries.length)));
195
- for (let n = 0; n < entries.length; n += stride) {
196
- const entry = entries[n];
197
- for (const s of surfaces) {
198
- if (payload(s, entry, options).text === texts[n].get(s.name)) continue;
199
- throw new Error(
200
- `digestCorpus: surface ${JSON.stringify(s.name)} is NOT DETERMINISTIC on ${JSON.stringify(entry.id)} \u2014 two parses of the same source produced different output. Every digest from this grammar would move on its own, so a comparison against it cannot mean anything. Usual causes: a timestamp or counter in the output, iteration over a keyed-by-object Map, or a node carrying a reference to mutable shared state.`
201
- );
202
- }
203
- }
204
- }
205
- function compareReports(before, after) {
206
- if (before.harness !== after.harness) {
207
- return incomparable(
208
- `harness drift: reports were produced by DIFFERENT versions of the identity harness (${before.harness.slice(0, 12)}\u2026 vs ${after.harness.slice(0, 12)}\u2026). Their digests are not comparable, and treating them as such would either invent a regression or hide one. Re-run BOTH sides on one harness.`
209
- );
210
- }
211
- if (before.format !== after.format) {
212
- return incomparable(`digest format ${before.format} vs ${after.format} \u2014 re-run both sides on one version.`);
213
- }
214
- const beforeIds = new Set(Object.keys(before.perEntry));
215
- const afterIds = new Set(Object.keys(after.perEntry));
216
- const addedEntries = [...afterIds].filter((id) => !beforeIds.has(id)).sort();
217
- const removedEntries = [...beforeIds].filter((id) => !afterIds.has(id)).sort();
218
- const shared = [...beforeIds].filter((id) => afterIds.has(id)).sort();
219
- const byName = /* @__PURE__ */ new Map();
220
- for (const s of before.surfaces) byName.set(s.name, { ...byName.get(s.name), before: s });
221
- for (const s of after.surfaces) byName.set(s.name, { ...byName.get(s.name), after: s });
222
- const surfaces = [...byName.entries()].sort(([a], [b]) => a < b ? -1 : 1).map(([name, pair]) => {
223
- const moved = pair.before && pair.after ? shared.filter((id) => before.perEntry[id][name] !== after.perEntry[id][name]) : [];
224
- return {
225
- name,
226
- before: pair.before?.aggregate ?? null,
227
- after: pair.after?.aggregate ?? null,
228
- equal: pair.before !== void 0 && pair.after !== void 0 && pair.before.aggregate === pair.after.aggregate,
229
- moved,
230
- beforeThrew: pair.before?.threw ?? null,
231
- afterThrew: pair.after?.threw ?? null
232
- };
233
- });
234
- const identical = addedEntries.length === 0 && removedEntries.length === 0 && surfaces.every((s) => s.equal);
235
- return { verdict: identical ? "identical" : "moved", reason: null, addedEntries, removedEntries, surfaces };
236
- }
237
- function incomparable(reason) {
238
- return { verdict: "incomparable", reason, addedEntries: [], removedEntries: [], surfaces: [] };
239
- }
240
- function formatComparison(c, options = {}) {
241
- if (c.verdict === "incomparable") return `INCOMPARABLE \u2014 ${c.reason ?? "unknown reason"}`;
242
- const max = options.maxMoved ?? 10;
243
- const lines = [];
244
- for (const s of c.surfaces) {
245
- if (s.before === null) lines.push(` + surface ${s.name} (added)`);
246
- else if (s.after === null) lines.push(` - surface ${s.name} (removed)`);
247
- else if (s.equal) lines.push(` = ${s.name} ${s.before.slice(0, 16)}\u2026 threw=${s.afterThrew}`);
248
- else {
249
- lines.push(
250
- ` ! ${s.name} ${s.before.slice(0, 16)}\u2026 -> ${s.after.slice(0, 16)}\u2026 threw ${s.beforeThrew} -> ${s.afterThrew} (${s.moved.length} entries moved)`
251
- );
252
- for (const id of s.moved.slice(0, max)) lines.push(` ${id}`);
253
- if (s.moved.length > max) lines.push(` \u2026 and ${s.moved.length - max} more`);
254
- }
255
- }
256
- if (c.addedEntries.length > 0) lines.push(` corpus GAINED ${c.addedEntries.length} entries`);
257
- if (c.removedEntries.length > 0) {
258
- lines.push(
259
- ` corpus LOST ${c.removedEntries.length} entries \u2014 a differential over a SMALLER corpus is a weaker gate, not a passing one`
228
+ function newState(sink, options, root) {
229
+ const budget = options?.maxVisits ?? DEFAULT_MAX_VISITS;
230
+ if (!Number.isSafeInteger(budget) || budget < 0) {
231
+ throw new RangeError(
232
+ `canonicalize: maxVisits must be a non-negative safe integer, received ${String(budget)}. A non-finite or fractional budget does not raise the ceiling, it REMOVES it \u2014 the budget is spent with \`--visits < 0\`, which is false forever for NaN and never reached for Infinity \u2014 so the walk this bounds would run unbounded into the OOM the budget exists to prevent.`
260
233
  );
261
234
  }
262
- return [c.verdict === "identical" ? "IDENTICAL \u2014 output-neutral" : "MOVED \u2014 this is not a refactor", ...lines].join("\n");
235
+ const probeAt = budget >= EXPANSION_PROBE_VISITS ? budget - EXPANSION_PROBE_VISITS : -1;
236
+ return { path: /* @__PURE__ */ new Map(), sink, budget, root, visits: budget, probeAt };
263
237
  }
264
- function canaryReport() {
265
- class Tagged {
266
- x;
267
- constructor(x) {
268
- this.x = x;
269
- }
270
- }
271
- const shared = { shared: true };
272
- const cyclic = { name: "root" };
273
- cyclic.self = cyclic;
274
- cyclic.child = { up: cyclic };
275
- const values = {
276
- "a/scalars": [0, -0, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, 10n, true, false, null],
277
- "b/absent": [{ a: void 0 }, {}, { a: null }, [void 0], []],
278
- "c/key-order": [{ a: 1, b: 2 }, { b: 2, a: 1 }],
279
- "d/collections": [/* @__PURE__ */ new Map([["k", 1], ["j", 2]]), /* @__PURE__ */ new Set([1, 2]), /* @__PURE__ */ new Date(0), /ab+c/giu],
280
- "e/tagged": [new Tagged(1), { x: 1 }],
281
- "f/sharing": { left: shared, right: shared },
282
- "g/cycle": cyclic,
283
- "h/text": ["", "a\0b", "a b", '"quoted"', "\\"],
284
- "i/callable": [function named() {
285
- }, /* @__PURE__ */ Symbol("sym")]
286
- };
287
- const surfaces = [
288
- { name: "value", parse: (_source, id) => values[id] },
289
- {
290
- name: "thrower",
291
- parse: (_source, id) => {
292
- if (id === "a/scalars") throw new TypeError("canary");
293
- if (id === "b/absent") throw "a bare string";
294
- return { id };
295
- }
296
- }
297
- ];
298
- const corpus = Object.keys(values).map((id) => ({ id, source: id }));
299
- const perEntry = {};
300
- const threw = { value: 0, thrower: 0 };
301
- for (const entry of [...corpus].sort((a, b) => a.id < b.id ? -1 : 1)) {
302
- const row = {};
303
- for (const s of surfaces) {
304
- const result = payload(s, entry, {});
305
- if (result.threw) threw[s.name]++;
306
- row[s.name] = fingerprint(result.text);
307
- }
308
- perEntry[entry.id] = row;
309
- }
310
- const ids = Object.keys(perEntry).sort();
311
- return {
312
- format: DIGEST_FORMAT,
313
- harness: "",
314
- entries: ids.length,
315
- surfaces: surfaces.map((s) => ({
316
- name: s.name,
317
- aggregate: hash([`surface:${s.name}`, ...ids.map((id) => `${id}:${perEntry[id][s.name]}`)].join("\n")),
318
- threw: threw[s.name]
319
- })),
320
- perEntry
321
- };
238
+ function canonicalize(value, options) {
239
+ const out = new ArraySink();
240
+ write(value, newState(out, options, value), 0);
241
+ return out.tokens.join(SEPARATOR);
322
242
  }
323
- var HARNESS_DIGEST = (() => {
324
- const canary = canaryReport();
325
- return hash(
326
- [`format:${DIGEST_FORMAT}`, ...canary.surfaces.map((s) => `${s.name}:${s.aggregate}:${s.threw}`)].join("\n")
327
- );
328
- })();
329
-
330
- // src/oracle/corpus.ts
331
- var import_node_fs = require("node:fs");
332
- var import_node_path = require("node:path");
333
- var DEFAULT_IGNORE = [".git", "node_modules/.cache"];
334
- function loadCorpus(options) {
335
- const base = (0, import_node_path.resolve)(options.base);
336
- const ignoreDirs = options.ignoreDirs ?? DEFAULT_IGNORE;
337
- const ignoreNames = new Set(ignoreDirs.filter((d) => !d.includes("/")));
338
- const ignorePaths = ignoreDirs.filter((d) => d.includes("/")).map((d) => d.replace(/^\.?\/+/, "").replace(/\/+$/, ""));
339
- const maxBytes = options.maxBytes ?? Number.POSITIVE_INFINITY;
340
- const exts = new Set(options.extensions.map((e) => e.toLowerCase()));
341
- const missingRoots = [];
342
- const skippedLarge = [];
343
- const files = /* @__PURE__ */ new Map();
344
- const isIgnored = (name, relFromRoot) => {
345
- if (ignoreNames.has(name)) return true;
346
- return ignorePaths.some((p) => relFromRoot === p || relFromRoot.endsWith(`/${p}`));
347
- };
348
- const walk = (dir, root, visited) => {
349
- let real;
350
- try {
351
- real = (0, import_node_fs.realpathSync)(dir);
352
- } catch {
353
- return;
354
- }
355
- if (visited.has(real)) return;
356
- visited.add(real);
357
- for (const item of (0, import_node_fs.readdirSync)(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
358
- const full = (0, import_node_path.join)(dir, item.name);
359
- if (isIgnored(item.name, (0, import_node_path.relative)(root, full).split(import_node_path.sep).join("/"))) continue;
360
- let isDir = item.isDirectory();
361
- let isFile = item.isFile();
362
- let size = 0;
363
- if (item.isSymbolicLink() || !isDir && !isFile) {
364
- try {
365
- const st = (0, import_node_fs.statSync)(full);
366
- isDir = st.isDirectory();
367
- isFile = st.isFile();
368
- size = st.size;
369
- } catch {
370
- continue;
371
- }
372
- }
373
- if (isDir) {
374
- walk(full, root, visited);
375
- continue;
376
- }
377
- if (!isFile) continue;
378
- const dot = item.name.lastIndexOf(".");
379
- if (dot < 0 || !exts.has(item.name.slice(dot).toLowerCase())) continue;
380
- if (size === 0) {
381
- try {
382
- size = (0, import_node_fs.statSync)(full).size;
383
- } catch {
384
- continue;
385
- }
386
- }
387
- if (size > maxBytes) {
388
- skippedLarge.push(idOf(base, full));
389
- continue;
390
- }
391
- let realFile;
392
- try {
393
- realFile = (0, import_node_fs.realpathSync)(full);
394
- } catch {
395
- continue;
396
- }
397
- const id = idOf(base, full);
398
- const existing = files.get(realFile);
399
- if (existing === void 0 || id < existing) files.set(realFile, id);
400
- }
401
- };
402
- for (const root of options.roots) {
403
- const full = (0, import_node_path.resolve)(base, root);
404
- try {
405
- if (!(0, import_node_fs.statSync)(full).isDirectory()) throw new Error("not a directory");
406
- } catch {
407
- if (!options.allowMissingRoots) {
408
- throw new Error(
409
- `loadCorpus: root ${JSON.stringify(root)} does not resolve to a directory (${full}). A corpus that quietly shrank produces a different aggregate that looks like a grammar change. Fix the root, or pass allowMissingRoots and record \`missingRoots\` alongside the digest.`
410
- );
411
- }
412
- missingRoots.push(root);
413
- continue;
414
- }
415
- walk(full, full, /* @__PURE__ */ new Set());
416
- }
417
- const entries = [...files.values()].sort().map((id) => ({ id, source: (0, import_node_fs.readFileSync)((0, import_node_path.resolve)(base, id), "utf8") }));
418
- return { entries, missingRoots, skippedLarge: skippedLarge.sort() };
243
+ function digestInto(target, value, prefix = "", options) {
244
+ const sink = new StreamSink(target);
245
+ sink.prefix(prefix);
246
+ write(value, newState(sink, options, value), 0);
247
+ sink.flush();
419
248
  }
420
- function idOf(base, full) {
421
- return (0, import_node_path.relative)(base, full).split(import_node_path.sep).join("/");
249
+ function digestValue(value, prefix = "", options) {
250
+ const sha = (0, import_node_crypto.createHash)("sha256");
251
+ digestInto(sha, value, prefix, options);
252
+ return sha.digest("hex");
422
253
  }
423
254
  //# sourceMappingURL=index.cjs.map