@json-schema-engine/compiler 0.0.1

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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/dist/emit.d.ts +52 -0
  4. package/dist/emit.d.ts.map +1 -0
  5. package/dist/emit.js +166 -0
  6. package/dist/emit.js.map +1 -0
  7. package/dist/explain.d.ts +28 -0
  8. package/dist/explain.d.ts.map +1 -0
  9. package/dist/explain.js +42 -0
  10. package/dist/explain.js.map +1 -0
  11. package/dist/index.d.ts +132 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +169 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/plan.d.ts +83 -0
  16. package/dist/plan.d.ts.map +1 -0
  17. package/dist/plan.js +385 -0
  18. package/dist/plan.js.map +1 -0
  19. package/dist/runtime-compile.d.ts +30 -0
  20. package/dist/runtime-compile.d.ts.map +1 -0
  21. package/dist/runtime-compile.js +29 -0
  22. package/dist/runtime-compile.js.map +1 -0
  23. package/dist/runtime.d.ts +157 -0
  24. package/dist/runtime.d.ts.map +1 -0
  25. package/dist/runtime.js +336 -0
  26. package/dist/runtime.js.map +1 -0
  27. package/dist/serialize/apply.d.ts +28 -0
  28. package/dist/serialize/apply.d.ts.map +1 -0
  29. package/dist/serialize/apply.js +211 -0
  30. package/dist/serialize/apply.js.map +1 -0
  31. package/dist/serialize/context.d.ts +143 -0
  32. package/dist/serialize/context.d.ts.map +1 -0
  33. package/dist/serialize/context.js +93 -0
  34. package/dist/serialize/context.js.map +1 -0
  35. package/dist/serialize/expressions.d.ts +7 -0
  36. package/dist/serialize/expressions.d.ts.map +1 -0
  37. package/dist/serialize/expressions.js +177 -0
  38. package/dist/serialize/expressions.js.map +1 -0
  39. package/dist/serialize/guards.d.ts +7 -0
  40. package/dist/serialize/guards.d.ts.map +1 -0
  41. package/dist/serialize/guards.js +19 -0
  42. package/dist/serialize/guards.js.map +1 -0
  43. package/dist/serialize/index.d.ts +7 -0
  44. package/dist/serialize/index.d.ts.map +1 -0
  45. package/dist/serialize/index.js +266 -0
  46. package/dist/serialize/index.js.map +1 -0
  47. package/dist/serialize/keywords.d.ts +32 -0
  48. package/dist/serialize/keywords.d.ts.map +1 -0
  49. package/dist/serialize/keywords.js +131 -0
  50. package/dist/serialize/keywords.js.map +1 -0
  51. package/dist/serialize/messages.d.ts +36 -0
  52. package/dist/serialize/messages.d.ts.map +1 -0
  53. package/dist/serialize/messages.js +100 -0
  54. package/dist/serialize/messages.js.map +1 -0
  55. package/dist/serialize/names.d.ts +47 -0
  56. package/dist/serialize/names.d.ts.map +1 -0
  57. package/dist/serialize/names.js +115 -0
  58. package/dist/serialize/names.js.map +1 -0
  59. package/dist/serialize/spans.d.ts +34 -0
  60. package/dist/serialize/spans.d.ts.map +1 -0
  61. package/dist/serialize/spans.js +61 -0
  62. package/dist/serialize/spans.js.map +1 -0
  63. package/dist/serialize/statements.d.ts +16 -0
  64. package/dist/serialize/statements.d.ts.map +1 -0
  65. package/dist/serialize/statements.js +396 -0
  66. package/dist/serialize/statements.js.map +1 -0
  67. package/dist/serialize/unit.d.ts +8 -0
  68. package/dist/serialize/unit.d.ts.map +1 -0
  69. package/dist/serialize/unit.js +107 -0
  70. package/dist/serialize/unit.js.map +1 -0
  71. package/dist/standalone.d.ts +24 -0
  72. package/dist/standalone.d.ts.map +1 -0
  73. package/dist/standalone.js +166 -0
  74. package/dist/standalone.js.map +1 -0
  75. package/package.json +38 -0
  76. package/src/emit.ts +196 -0
  77. package/src/explain.ts +67 -0
  78. package/src/index.ts +366 -0
  79. package/src/plan.ts +496 -0
  80. package/src/runtime-compile.ts +90 -0
  81. package/src/runtime.ts +599 -0
  82. package/src/serialize/apply.ts +251 -0
  83. package/src/serialize/context.ts +161 -0
  84. package/src/serialize/expressions.ts +202 -0
  85. package/src/serialize/guards.ts +20 -0
  86. package/src/serialize/index.ts +432 -0
  87. package/src/serialize/keywords.ts +144 -0
  88. package/src/serialize/messages.ts +120 -0
  89. package/src/serialize/names.ts +136 -0
  90. package/src/serialize/spans.ts +88 -0
  91. package/src/serialize/statements.ts +531 -0
  92. package/src/serialize/unit.ts +118 -0
  93. package/src/standalone.ts +187 -0
@@ -0,0 +1,531 @@
1
+ // LowerStmt rendering: the per-keyword statement list with its grouped
2
+ // anyOf/oneOf runs, every statement kind, and the apply folds.
3
+
4
+ import {
5
+ type JsonValue,
6
+ type LowerApply,
7
+ type LowerMessage,
8
+ type LowerParams,
9
+ type LowerStmt,
10
+ } from "@json-schema-engine/core";
11
+ import { type CodeChunk, id, join, js, num, json } from "../emit.js";
12
+ import { EV, bindingVar, counterVar, foldVar } from "./names.js";
13
+ import { UnitContext, SerializeError } from "./context.js";
14
+ import {
15
+ renderMessage,
16
+ paramsChunk,
17
+ pushError,
18
+ errMark,
19
+ errsLength,
20
+ errsCut,
21
+ } from "./messages.js";
22
+ import {
23
+ channelSpans,
24
+ spanDecls,
25
+ spanResets,
26
+ branchSpan,
27
+ annsLength,
28
+ annsCut,
29
+ annsPush,
30
+ } from "./spans.js";
31
+ import { annUnit, annRecordSegment, produceChannel } from "./keywords.js";
32
+ import { expr } from "./expressions.js";
33
+ import { applyCall, tryInline } from "./apply.js";
34
+
35
+ /**
36
+ * Serialize one keyword's statement list. Runs of anyMayPass applies
37
+ * (the anyOf shape) group into a single OR check; short-circuit emission
38
+ * is licensed here because the planner interpreted every node whose
39
+ * channel a consumer could observe (slice licensing, DESIGN §7). Runs of
40
+ * exactlyOne applies (the oneOf shape) group into a counter block that
41
+ * runs EVERY branch — never stop-at-first-success, since the count past 1
42
+ * must still be exact.
43
+ */
44
+ export function keywordStatements(
45
+ ctx: UnitContext,
46
+ stmts: readonly LowerStmt[],
47
+ ): CodeChunk[] {
48
+ const out: CodeChunk[] = [];
49
+ let anyRun: CodeChunk[] = [];
50
+ let oneRun: CodeChunk[] = [];
51
+ const flushAny = (message?: LowerMessage, params?: LowerParams) => {
52
+ if (anyRun.length === 0) return;
53
+ if (ctx.output === "list") {
54
+ // Every branch runs (§7: list artifacts never short-circuit), each
55
+ // marking/truncating its channel spans (branchSpan). An accepting
56
+ // run drops every branch's errors (errMark).
57
+ const a = counterVar(ctx.counters.tally++);
58
+ const em = errMark(ctx)!;
59
+ const runs = anyRun.map((call) =>
60
+ branchSpan(ctx, call, js`${a} = true;`),
61
+ );
62
+ const onFail = pushError(
63
+ ctx,
64
+ renderMessage(ctx, message ?? ["no branch matched"]),
65
+ true,
66
+ paramsChunk(ctx, params),
67
+ );
68
+ out.push(
69
+ js`let ${a} = false; const ${em} = ${errsLength(ctx)}; ${join(" ", runs)} if (${a}) { ${errsCut(ctx, em)} } else { ${onFail} }`,
70
+ );
71
+ } else if (ctx.regionMode) {
72
+ // Every branch runs (no short-circuit — a later branch's success
73
+ // contributes coverage the interpreter would merge), each marking and
74
+ // truncating its own channel span (rule 2). Flag failure on no match.
75
+ const a = counterVar(ctx.counters.tally++);
76
+ const runs = anyRun.map((call) =>
77
+ branchSpan(ctx, call, js`${a} = true;`),
78
+ );
79
+ out.push(
80
+ js`let ${a} = false; ${join(" ", runs)} if (!${a}) return false;`,
81
+ );
82
+ } else {
83
+ out.push(js`if (!(${join(" || ", anyRun)})) return false;`);
84
+ }
85
+ anyRun = [];
86
+ };
87
+ const flushOne = (message?: LowerMessage, params?: LowerParams) => {
88
+ if (oneRun.length === 0) return;
89
+ const c = counterVar(ctx.counters.tally++);
90
+ if (ctx.output === "list") {
91
+ // Params referencing the passing-branch indexes (tallyList) need an
92
+ // index accumulator next to the count; branch order IS run order.
93
+ // Both-pass discards at the caller (the unit fails, its span
94
+ // truncates); each branch marks/truncates its own spans here.
95
+ const wantsList =
96
+ ctx.listParams &&
97
+ params !== undefined &&
98
+ Object.values(params).some((p) => p.kind === "tallyList");
99
+ const p = wantsList ? counterVar(ctx.counters.tally++) : undefined;
100
+ const incs = oneRun.map((call, k) =>
101
+ branchSpan(
102
+ ctx,
103
+ call,
104
+ p ? js`${c}++; ${p}.push(${num(k)});` : js`${c}++;`,
105
+ ),
106
+ );
107
+ const decl = p ? js`let ${c} = 0; const ${p} = [];` : js`let ${c} = 0;`;
108
+ const onFail = pushError(
109
+ ctx,
110
+ renderMessage(
111
+ ctx,
112
+ message ?? [{ kind: "tally" }, " branches matched"],
113
+ c,
114
+ ),
115
+ true,
116
+ paramsChunk(ctx, params, c, p),
117
+ );
118
+ const em = errMark(ctx)!;
119
+ out.push(
120
+ js`${decl} const ${em} = ${errsLength(ctx)}; ${join(" ", incs)} if (${c} === 1) { ${errsCut(ctx, em)} } else { ${onFail} }`,
121
+ );
122
+ } else if (ctx.regionMode) {
123
+ // Run every branch with a per-branch mark/truncate (rule 2): a passing
124
+ // branch's coverage merges, a failing branch's truncates. On a non-unit
125
+ // count the unit fails and the caller truncates the whole span.
126
+ const incs = oneRun.map((call) => branchSpan(ctx, call, js`${c}++;`));
127
+ out.push(
128
+ js`let ${c} = 0; ${join(" ", incs)} if (${c} !== 1) return false;`,
129
+ );
130
+ } else {
131
+ const incs = oneRun.map((call) => js`if (${call}) ${c}++;`);
132
+ out.push(
133
+ js`let ${c} = 0; ${join(" ", incs)} if (${c} !== 1) return false;`,
134
+ );
135
+ }
136
+ oneRun = [];
137
+ };
138
+ for (const stmt of stmts) {
139
+ if (stmt.kind === "apply" && stmt.apply.fold === "anyMayPass") {
140
+ flushOne();
141
+ anyRun.push(applyCall(ctx, stmt.apply));
142
+ continue;
143
+ }
144
+ if (stmt.kind === "apply" && stmt.apply.fold === "exactlyOne") {
145
+ flushAny();
146
+ oneRun.push(applyCall(ctx, stmt.apply));
147
+ continue;
148
+ }
149
+ if (stmt.kind === "combineCheck") {
150
+ // Closes the pending run with the keyword's own failure message.
151
+ flushAny(stmt.message, stmt.params);
152
+ flushOne(stmt.message, stmt.params);
153
+ continue;
154
+ }
155
+ flushAny();
156
+ flushOne();
157
+ out.push(statement(ctx, stmt));
158
+ }
159
+ flushAny();
160
+ flushOne();
161
+ return out;
162
+ }
163
+
164
+ /** An `if` statement; an applyExpr condition is hoisted so its channel spans and error mark bracket the call. */
165
+ function emitIf(
166
+ ctx: UnitContext,
167
+ stmt: Extract<LowerStmt, { kind: "if" }>,
168
+ ): CodeChunk {
169
+ // `if`'s condition IS a bare applyExpr (the only such shape besides
170
+ // contains' probe): hoist the call so its span marks/truncates before
171
+ // the branch reads the verdict. Any other applyExpr position throws in
172
+ // annotation mode (expr()), so no silent annotation loss.
173
+ if (
174
+ (ctx.annMode || ctx.regionMode || ctx.output === "list") &&
175
+ stmt.cond.kind === "applyExpr"
176
+ ) {
177
+ // `if`'s condition is in-place: hoist the call so its channel spans
178
+ // mark/truncate on the condition verdict (a passing condition's
179
+ // coverage merges, a failing one's — with the else taken — truncates;
180
+ // rule 4). ann marks `anns`, region marks `ev`, composed both. The
181
+ // condition's errors are always irrelevant (`if` accepts regardless
182
+ // of its subschema), so list mode truncates them unconditionally.
183
+ const spans = channelSpans(ctx);
184
+ const t = id("m" + String(ctx.counters.temp++));
185
+ const em = errMark(ctx);
186
+ const call = applyCall(ctx, stmt.cond.apply);
187
+ const errDecl = em ? js`const ${em} = ${errsLength(ctx)}; ` : js``;
188
+ const errReset = em ? js` ${errsCut(ctx, em)}` : js``;
189
+ const resets =
190
+ spans.length > 0 ? js` if (!${t}) { ${spanResets(ctx, spans)} }` : js``;
191
+ const head = js`${spanDecls(ctx, spans)} ${errDecl}const ${t} = ${call};${errReset}${resets}`;
192
+ const thenBody = join(
193
+ "\n",
194
+ stmt.then.map((s) => statement(ctx, s)),
195
+ );
196
+ if (!stmt.else) return js`${head} if (${t}) { ${thenBody} }`;
197
+ const elseBody = join(
198
+ "\n",
199
+ stmt.else.map((s) => statement(ctx, s)),
200
+ );
201
+ return js`${head} if (${t}) { ${thenBody} } else { ${elseBody} }`;
202
+ }
203
+ const thenBody = join(
204
+ "\n",
205
+ stmt.then.map((s) => statement(ctx, s)),
206
+ );
207
+ const cond = js`if (${expr(ctx, stmt.cond)}) { ${thenBody} }`;
208
+ if (!stmt.else) return cond;
209
+ const elseBody = join(
210
+ "\n",
211
+ stmt.else.map((s) => statement(ctx, s)),
212
+ );
213
+ return js`${cond} else { ${elseBody} }`;
214
+ }
215
+
216
+ /** A sweep over an object's own keys (plain for-in under the plain-data contract). */
217
+ function emitForEachKey(
218
+ ctx: UnitContext,
219
+ stmt: Extract<LowerStmt, { kind: "forEachKey" }>,
220
+ ): CodeChunk {
221
+ const b = bindingVar(stmt.binding);
222
+ const body = join(
223
+ "\n",
224
+ stmt.body.map((s) => statement(ctx, s)),
225
+ );
226
+ // Plain for-in: under the plain-data instance contract (DESIGN §7)
227
+ // instances carry no inherited enumerables, so for-in ≡ Object.keys
228
+ // without the per-validation array allocation.
229
+ const t = expr(ctx, stmt.target);
230
+ if (ctx.flags.plainData) {
231
+ return js`for (const ${b} in ${t}) { ${body} }`;
232
+ }
233
+ return js`for (const ${b} of Object.keys(${t})) { ${body} }`;
234
+ }
235
+
236
+ /** A sweep over an array's indexes. */
237
+ function emitForEachIndex(
238
+ ctx: UnitContext,
239
+ stmt: Extract<LowerStmt, { kind: "forEachIndex" }>,
240
+ ): CodeChunk {
241
+ const b = bindingVar(stmt.binding);
242
+ const body = join(
243
+ "\n",
244
+ stmt.body.map((s) => statement(ctx, s)),
245
+ );
246
+ return js`for (let ${b} = ${num(stmt.start ?? 0)}; ${b} < ${expr(ctx, stmt.target)}.length; ${b}++) { ${body} }`;
247
+ }
248
+
249
+ /** A keyword failure: an error unit in list mode, fail-fast in flag mode. */
250
+ function emitFail(
251
+ ctx: UnitContext,
252
+ stmt: Extract<LowerStmt, { kind: "fail" }>,
253
+ ): CodeChunk {
254
+ if (ctx.output === "list") {
255
+ return pushError(
256
+ ctx,
257
+ renderMessage(ctx, stmt.message),
258
+ true,
259
+ paramsChunk(ctx, stmt.params),
260
+ );
261
+ }
262
+ // Flag mode: verdict-only, fail fast.
263
+ return js`return false;`;
264
+ }
265
+
266
+ /** The keyword's own value as an annotation unit (annotation mode only). */
267
+ function emitAnnotate(
268
+ ctx: UnitContext,
269
+ _stmt: Extract<LowerStmt, { kind: "annotate" }>,
270
+ ): CodeChunk {
271
+ // Annotation mode records the keyword's own value as a unit; every
272
+ // other mode elides. The value is a schema constant (draft-03 §12.9).
273
+ if (!ctx.annMode || !ctx.annKwKept) return js``;
274
+ const value = (ctx.unit.ref.node as Record<string, JsonValue>)[
275
+ ctx.currentKeyword
276
+ ]!;
277
+ return annsPush(ctx, annUnit(ctx, json(value)));
278
+ }
279
+
280
+ /** A consumed producer's dependency data onto the coverage channel (region mode only). */
281
+ function emitProduce(
282
+ ctx: UnitContext,
283
+ stmt: Extract<LowerStmt, { kind: "produce" }>,
284
+ ): CodeChunk {
285
+ // Region mode pushes a consumed producer's dependency data onto the
286
+ // channel (rule 5); dependency data is never an annotation unit. In
287
+ // list mode the push waits on the keyword's own verdict (kwOk).
288
+ if (!ctx.covKwKept) return js``;
289
+ const push = produceChannel(ctx, stmt.value);
290
+ return ctx.kwOk ? js`if (${ctx.kwOk}) { ${push} }` : push;
291
+ }
292
+
293
+ /** Bind the channel fold a following coverageCovers reads (region mode only). */
294
+ function emitCoverageFold(
295
+ ctx: UnitContext,
296
+ stmt: Extract<LowerStmt, { kind: "coverageFold" }>,
297
+ ): CodeChunk {
298
+ // Bind the channel fold a following coverageCovers reads (rule 6). Only
299
+ // a tracked unit's consumer emits this, and only in region mode.
300
+ if (!ctx.regionMode) {
301
+ throw new SerializeError("coverageFold requires region emission (phase B)");
302
+ }
303
+ const f = foldVar(ctx.counters.temp++);
304
+ ctx.coverageFolds.set(stmt.binding, { var: f, half: stmt.half });
305
+ if (stmt.half === "names") {
306
+ return js`const ${f} = ${id("h_covN")}(${EV});`;
307
+ }
308
+ // indexes: fold over the current instance array's length (the enclosing
309
+ // lower() guards this with an array-type test).
310
+ return js`const ${f} = ${id("h_covI")}(${EV}, ${ctx.valueVar}.length);`;
311
+ }
312
+
313
+ /** The contains shape: count matching items, then check the range; the probe's spans and the loop's error mark follow the mode. */
314
+ function emitCountRange(
315
+ ctx: UnitContext,
316
+ stmt: Extract<LowerStmt, { kind: "countRange" }>,
317
+ ): CodeChunk {
318
+ const b = bindingVar(stmt.binding);
319
+ const c = counterVar(ctx.counters.tally++);
320
+ const max = Number.isFinite(stmt.max) ? num(stmt.max) : null;
321
+ const outOfRange =
322
+ max === null
323
+ ? js`${c} < ${num(stmt.min)}`
324
+ : js`${c} < ${num(stmt.min)} || ${c} > ${max}`;
325
+ const onFail =
326
+ ctx.output === "list"
327
+ ? pushError(
328
+ ctx,
329
+ renderMessage(ctx, stmt.outOfRangeMessage, c),
330
+ true,
331
+ paramsChunk(ctx, stmt.outOfRangeParams, c),
332
+ )
333
+ : js`return false;`;
334
+ // List mode: an accepting contains drops every probe's errors, a
335
+ // rejecting one keeps them (§12.2) — one mark around the whole loop,
336
+ // never per probe.
337
+ const em = errMark(ctx);
338
+ const errDecl = em ? js`const ${em} = ${errsLength(ctx)}; ` : js``;
339
+ const check = em
340
+ ? js`if (${outOfRange}) { ${onFail} } else { ${errsCut(ctx, em)} }`
341
+ : js`if (${outOfRange}) { ${onFail} }`;
342
+ // contains' probe IS a bare applyExpr (the only shape besides `if`'s
343
+ // condition): mark/truncate each probe so a matching item's
344
+ // annotations merge and a failing item's discard. collectIndexes feeds
345
+ // the matched-index accumulator the following produce renders.
346
+ if (ctx.annMode && stmt.countWhen.kind === "applyExpr") {
347
+ const probe = applyCall(ctx, stmt.countWhen.apply);
348
+ const m = id("m" + String(ctx.counters.temp++));
349
+ const pushIdx =
350
+ ctx.annKw?.matched && stmt.collectIndexes
351
+ ? js` ${ctx.annKw.matched}.push(${b});`
352
+ : js``;
353
+ const loop = js`${errDecl}let ${c} = 0; for (let ${b} = 0; ${b} < ${expr(ctx, stmt.target)}.length; ${b}++) { const ${m} = ${annsLength(ctx)}; if (${probe}) { ${c}++;${pushIdx} } else ${annsCut(ctx, m)} }`;
354
+ return js`${loop} ${check}`;
355
+ }
356
+ if (ctx.regionMode && stmt.countWhen.kind === "applyExpr") {
357
+ // The probe is a CHILD-cursor apply (a plain call that never receives
358
+ // `ev`), so there is no channel span to mark here — `contains`'
359
+ // coverage reaches the channel only through its matched-index produce
360
+ // (rule 5). Accumulate the matched indexes for that produce.
361
+ const probe = applyCall(ctx, stmt.countWhen.apply);
362
+ const pushIdx =
363
+ ctx.annKw?.matched && stmt.collectIndexes
364
+ ? js` ${ctx.annKw.matched}.push(${b});`
365
+ : js``;
366
+ const loop = js`${errDecl}let ${c} = 0; for (let ${b} = 0; ${b} < ${expr(ctx, stmt.target)}.length; ${b}++) { if (${probe}) { ${c}++;${pushIdx} } }`;
367
+ return js`${loop} ${check}`;
368
+ }
369
+ const loop = js`${errDecl}let ${c} = 0; for (let ${b} = 0; ${b} < ${expr(ctx, stmt.target)}.length; ${b}++) { if (${expr(ctx, stmt.countWhen)}) ${c}++; }`;
370
+ return js`${loop} ${check}`;
371
+ }
372
+
373
+ export function statement(ctx: UnitContext, stmt: LowerStmt): CodeChunk {
374
+ switch (stmt.kind) {
375
+ case "if":
376
+ return emitIf(ctx, stmt);
377
+ case "forEachKey":
378
+ return emitForEachKey(ctx, stmt);
379
+ case "forEachIndex":
380
+ return emitForEachIndex(ctx, stmt);
381
+ case "fail":
382
+ return emitFail(ctx, stmt);
383
+ case "annotate":
384
+ return emitAnnotate(ctx, stmt);
385
+ case "produce":
386
+ return emitProduce(ctx, stmt);
387
+ case "coverageFold":
388
+ return emitCoverageFold(ctx, stmt);
389
+ case "apply":
390
+ return applyStatement(ctx, stmt.apply);
391
+ case "combineCheck":
392
+ throw new SerializeError(
393
+ "combineCheck must directly follow its anyMayPass/exactlyOne run",
394
+ );
395
+ case "countRange":
396
+ return emitCountRange(ctx, stmt);
397
+ }
398
+ }
399
+
400
+ /** List-mode conjunct and negation folds (null for the grouped folds). */
401
+ function emitListApply(
402
+ ctx: UnitContext,
403
+ apply: LowerApply,
404
+ call: CodeChunk,
405
+ evSpan: boolean,
406
+ ): CodeChunk | null {
407
+ switch (apply.fold) {
408
+ case "allMustPass": {
409
+ // Record the attempted segment (before the verdict) for the
410
+ // keyword's produce accumulator, then mark the channel spans so a
411
+ // failing application's annotations (annMode) and coverage (region
412
+ // emission) truncate — its errors stay (the keyword rejects), and
413
+ // list mode CONTINUES on failure (ok = false, no return): the
414
+ // unit's remaining keywords, including a consumer sweep that now
415
+ // covers less, still run.
416
+ const rec = annRecordSegment(ctx, apply.cursor);
417
+ const pre = rec ? js`${rec} ` : js``;
418
+ // A driven sibling's application (`then`/`else` under `if`) fails that
419
+ // sibling's own verdict, as the interpreter reports it.
420
+ const slot =
421
+ ctx.trace && apply.sibling !== undefined
422
+ ? (ctx.kwVerdicts.get(apply.sibling) ?? null)
423
+ : ctx.kwOk;
424
+ const fail = slot ? js`ok = false; ${slot} = false;` : js`ok = false;`;
425
+ const spans = channelSpans(ctx, evSpan);
426
+ if (spans.length === 0) return js`${pre}if (!${call}) { ${fail} }`;
427
+ return js`${pre}${spanDecls(ctx, spans)} if (!${call}) { ${fail} ${spanResets(ctx, spans)} }`;
428
+ }
429
+ case "negate": {
430
+ // A failing negated subschema's spans truncate, and so do its
431
+ // errors (`not` accepts, §12.2); a PASSING one's annotations and
432
+ // coverage STAY (the interpreter merges any passing application
433
+ // into the unit frame, and same-unit consumers see it) while the
434
+ // unit records the not-error.
435
+ const spans = channelSpans(ctx, evSpan);
436
+ const em = errMark(ctx)!;
437
+ const err = pushError(
438
+ ctx,
439
+ renderMessage(ctx, apply.message ?? ["must not match the subschema"]),
440
+ true,
441
+ paramsChunk(ctx, apply.params),
442
+ );
443
+ return js`${spanDecls(ctx, spans)} const ${em} = ${errsLength(ctx)}; if (${call}) { ${err} } else { ${errsCut(ctx, em)} ${spanResets(ctx, spans)} }`;
444
+ }
445
+ default:
446
+ return null; // grouped folds handled by keywordStatements; discard by applyExpr
447
+ }
448
+ }
449
+
450
+ /** Region-mode conjunct and negation folds (null for the grouped folds). */
451
+ function emitRegionApply(
452
+ ctx: UnitContext,
453
+ apply: LowerApply,
454
+ call: CodeChunk,
455
+ evSpan: boolean,
456
+ ): CodeChunk | null {
457
+ switch (apply.fold) {
458
+ case "allMustPass": {
459
+ // Record the attempted child segment (before the verdict) for a
460
+ // consumed producer's channel push (rule 5).
461
+ const rec = annRecordSegment(ctx, apply.cursor);
462
+ const pre = rec ? js`${rec} ` : js``;
463
+ if (call.text === "true") return rec ?? js``;
464
+ if (call.text === "false") return js`${pre}return false;`;
465
+ if (evSpan) {
466
+ // In-place conjunct: a failed span truncates and fails the unit;
467
+ // the caller's own span then truncates this whole call (rule 2).
468
+ const m = id("m" + String(ctx.counters.temp++));
469
+ return js`const ${m} = ${EV}.length; if (!${call}) { ${EV}.length = ${m}; return false; }`;
470
+ }
471
+ // Child-cursor conjunct (a swept property/item): no channel span.
472
+ return js`${pre}if (!${call}) return false;`;
473
+ }
474
+ case "negate": {
475
+ if (call.text === "true") return js`return false;`;
476
+ if (call.text === "false") return js``;
477
+ // A passing negated subschema dies with the unit (return false → the
478
+ // caller truncates); a failing one truncates its own span (rule 3).
479
+ const m = id("m" + String(ctx.counters.temp++));
480
+ return js`const ${m} = ${EV}.length; if (${call}) return false; ${EV}.length = ${m};`;
481
+ }
482
+ default:
483
+ return null; // grouped folds handled by keywordStatements; discard by applyExpr
484
+ }
485
+ }
486
+
487
+ export function applyStatement(ctx: UnitContext, apply: LowerApply): CodeChunk {
488
+ if (apply.fold === "allMustPass") {
489
+ const inlined = tryInline(ctx, apply);
490
+ if (inlined) return inlined;
491
+ }
492
+ const call = applyCall(ctx, apply);
493
+ // A call RECEIVES the coverage channel exactly when it is an in-place
494
+ // apply to a non-boolean target (a region variant, or a coverage-
495
+ // harvesting trampoline); a child-cursor apply is a plain call, and a
496
+ // boolean folds to a literal or reports without producing — neither
497
+ // touches the channel, so neither needs an ev mark/truncate span (rule 1).
498
+ const evSpan =
499
+ ctx.regionMode &&
500
+ apply.cursor.kind === "here" &&
501
+ call.text !== "true" &&
502
+ call.text !== "false";
503
+ if (ctx.output === "list") {
504
+ const folded = emitListApply(ctx, apply, call, evSpan);
505
+ if (folded) return folded;
506
+ } else if (ctx.regionMode) {
507
+ const folded = emitRegionApply(ctx, apply, call, evSpan);
508
+ if (folded) return folded;
509
+ }
510
+ if (apply.fold === "allMustPass" && call.text === "true") return js``;
511
+ if (apply.fold === "allMustPass" && call.text === "false")
512
+ return js`return false;`;
513
+ switch (apply.fold) {
514
+ case "allMustPass":
515
+ return js`if (!${call}) return false;`;
516
+ case "negate":
517
+ return js`if (${call}) return false;`;
518
+ case "anyMayPass":
519
+ case "exactlyOne":
520
+ // keywordStatements groups consecutive runs of these before they
521
+ // reach here (a lone run of one is still a "run").
522
+ throw new SerializeError(
523
+ "fold '" + apply.fold + "' must be grouped by keywordStatements",
524
+ );
525
+ case "discard":
526
+ // A bare discard apply statement has no verdict-folding meaning —
527
+ // it's only legal as an applyExpr operand (the if/contains probe
528
+ // shape), never a standalone statement.
529
+ throw new SerializeError("fold 'discard' is only legal inside applyExpr");
530
+ }
531
+ }
@@ -0,0 +1,118 @@
1
+ // Per-unit orchestration: run each present keyword's lower() in dialect
2
+ // order and hand its statements to the emitters.
3
+
4
+ import {
5
+ escapeSegment,
6
+ type JsonValue,
7
+ type LowerStmt,
8
+ type LoweringContext,
9
+ } from "@json-schema-engine/core";
10
+ import { type CodeChunk, id, join, js, str, json } from "../emit.js";
11
+ import { TN } from "./names.js";
12
+ import { UnitContext, SerializeError } from "./context.js";
13
+ import { beginKeyword } from "./keywords.js";
14
+ import { keywordStatements } from "./statements.js";
15
+ import { annsPush } from "./spans.js";
16
+
17
+ /** Serialize every present keyword of this unit, in dialect order. */
18
+ export function unitBody(ctx: UnitContext): CodeChunk[] {
19
+ const node = ctx.unit.ref.node as Record<string, JsonValue>;
20
+ const dialect = ctx.registry.dialectFor(ctx.unit.ref.baseUri);
21
+ // Mirrors buildPlan's refOnly (plan.ts, citing engine.ts:397): the plan
22
+ // only resolved edges for $ref when this is true, so siblings must be
23
+ // skipped here too, or this would try to serialize applies the plan
24
+ // never planned.
25
+ const refOnly = dialect.refIgnoresSiblings && Object.hasOwn(node, "$ref");
26
+ const out: CodeChunk[] = [];
27
+ if (ctx.trace) {
28
+ // One verdict slot per present non-structural keyword, declared up front:
29
+ // `if` settles `then`/`else`'s verdicts before those keywords' own (empty)
30
+ // statement lists run. Structural keywords record no entry (engine.ts).
31
+ const slots: CodeChunk[] = [];
32
+ for (const entry of dialect.ordered) {
33
+ if (refOnly && entry.name !== "$ref") continue;
34
+ if (!Object.hasOwn(node, entry.name)) continue;
35
+ if (entry.behavior.structural === true) continue;
36
+ const k = id("k" + String(ctx.counters.temp++));
37
+ ctx.kwVerdicts.set(entry.name, k);
38
+ slots.push(js`${k} = true`);
39
+ }
40
+ if (slots.length > 0) out.push(js`let ${join(", ", slots)};`);
41
+ }
42
+ for (const entry of dialect.ordered) {
43
+ if (refOnly && entry.name !== "$ref") continue;
44
+ if (!Object.hasOwn(node, entry.name)) continue;
45
+ const behavior = entry.behavior;
46
+ if (typeof behavior.lower !== "function") {
47
+ throw new SerializeError(
48
+ "planner accepted unlowerable keyword '" + entry.name + "' (bug)",
49
+ );
50
+ }
51
+ const stmts = collect(ctx, entry.name, (lctx) => {
52
+ behavior.lower!(node[entry.name]!, lctx);
53
+ });
54
+ ctx.currentVocab = entry.vocabularyUri;
55
+ ctx.currentBehaviorId = behavior.id;
56
+ // Accumulator declarations hoist above the keyword's statements: the
57
+ // application call sites feed them, the produce reads them.
58
+ const decls = beginKeyword(ctx, stmts);
59
+ const chunks = keywordStatements(ctx, stmts);
60
+ out.push(...decls, ...chunks);
61
+ // The verdict entry follows the keyword's applications, as the
62
+ // interpreter's traceKeyword does.
63
+ if (ctx.trace && behavior.structural !== true) {
64
+ const k = ctx.kwVerdicts.get(entry.name)!;
65
+ out.push(
66
+ js`${TN}.keywords.push({ name: ${str(entry.name)}, valid: ${k} });`,
67
+ );
68
+ }
69
+ }
70
+ // Unknown keywords collect as annotations (engine.ts:414): unconditional
71
+ // constant annotations, in schema-key order, after every dialect keyword.
72
+ // The refOnly break silences siblings, matching the interpreter. Trace
73
+ // emission records each as an accepting keyword entry whether or not its
74
+ // annotation is selected.
75
+ if ((ctx.annMode || ctx.trace) && !refOnly) {
76
+ for (const name of Object.keys(node)) {
77
+ if (dialect.keywords.has(name)) continue;
78
+ if (ctx.trace) {
79
+ out.push(js`${TN}.keywords.push({ name: ${str(name)}, valid: true });`);
80
+ }
81
+ if (!ctx.annMode) continue;
82
+ if (ctx.annKeep && !ctx.annKeep("", name, null)) continue;
83
+ const suffix = "/" + escapeSegment(name);
84
+ const sloc = ctx.unit.ref.baseUri + "#" + ctx.unit.ref.pointer + suffix;
85
+ out.push(
86
+ annsPush(
87
+ ctx,
88
+ js`{ keyword: ${str(name)}, evaluationPath: ${id("ep")} + ${str(suffix)}, schemaLocation: ${str(sloc)}, inputLocation: ${id("ip")}, annotation: ${json(node[name]!)} }`,
89
+ ),
90
+ );
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Run one keyword's lower() against a fresh LoweringContext, return its stmts. */
97
+ export function collect(
98
+ ctx: UnitContext,
99
+ keyword: string,
100
+ run: (lctx: LoweringContext) => void,
101
+ ): LowerStmt[] {
102
+ ctx.currentKeyword = keyword;
103
+ const stmts: LowerStmt[] = [];
104
+ const unit = ctx.unit;
105
+ const lctx: LoweringContext = {
106
+ instance: { kind: "instance" },
107
+ schema: unit.ref.node as Record<string, JsonValue>,
108
+ staticCoverage: () => unit.coverage,
109
+ // A tracked unit's consumers read the runtime channel; a static-coverage
110
+ // consumer (even one that is a region member) keeps its static path and
111
+ // instead PUSHES its produce onto the channel for the enclosing consumer.
112
+ runtimeCoverage: () => unit.tracking === true,
113
+ emit: (...s) => stmts.push(...s),
114
+ binding: () => ctx.counters.binding++,
115
+ };
116
+ run(lctx);
117
+ return stmts;
118
+ }