@telorun/cel 0.107.0 → 0.108.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 (78) hide show
  1. package/dist/backend-runtime.d.ts +46 -8
  2. package/dist/backend-runtime.d.ts.map +1 -1
  3. package/dist/backend-runtime.js +133 -30
  4. package/dist/catalog-runtime.d.ts.map +1 -1
  5. package/dist/catalog-runtime.js +110 -109
  6. package/dist/cel-map-value.d.ts +20 -7
  7. package/dist/cel-map-value.d.ts.map +1 -1
  8. package/dist/cel-map-value.js +29 -18
  9. package/dist/cel-value.d.ts +10 -3
  10. package/dist/cel-value.d.ts.map +1 -1
  11. package/dist/check-diagnostic.d.ts +3 -2
  12. package/dist/check-diagnostic.d.ts.map +1 -1
  13. package/dist/checker.d.ts +9 -0
  14. package/dist/checker.d.ts.map +1 -1
  15. package/dist/checker.js +98 -13
  16. package/dist/closure-backend.js +66 -15
  17. package/dist/duration-value.d.ts +11 -0
  18. package/dist/duration-value.d.ts.map +1 -1
  19. package/dist/duration-value.js +17 -3
  20. package/dist/emitted-module.d.ts +1 -1
  21. package/dist/emitted-module.js +1 -1
  22. package/dist/engine-version.d.ts +1 -1
  23. package/dist/engine-version.js +1 -1
  24. package/dist/environment.d.ts +8 -0
  25. package/dist/environment.d.ts.map +1 -1
  26. package/dist/environment.js +20 -5
  27. package/dist/function-registry.d.ts +40 -0
  28. package/dist/function-registry.d.ts.map +1 -1
  29. package/dist/function-registry.js +105 -2
  30. package/dist/index.d.ts +10 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +12 -6
  33. package/dist/js-emitter.d.ts +3 -2
  34. package/dist/js-emitter.d.ts.map +1 -1
  35. package/dist/js-emitter.js +11 -5
  36. package/dist/macro-check.d.ts +8 -0
  37. package/dist/macro-check.d.ts.map +1 -1
  38. package/dist/macro-check.js +1 -1
  39. package/dist/member-read.d.ts +42 -5
  40. package/dist/member-read.d.ts.map +1 -1
  41. package/dist/member-read.js +47 -6
  42. package/dist/resolved-call.d.ts +13 -0
  43. package/dist/resolved-call.d.ts.map +1 -1
  44. package/dist/runtime-library.d.ts +13 -2
  45. package/dist/runtime-library.d.ts.map +1 -1
  46. package/dist/runtime-library.js +134 -125
  47. package/dist/serializer.d.ts.map +1 -1
  48. package/dist/serializer.js +20 -4
  49. package/dist/timestamp-value.d.ts +9 -0
  50. package/dist/timestamp-value.d.ts.map +1 -1
  51. package/dist/timestamp-value.js +15 -0
  52. package/dist/type-expression.d.ts +18 -2
  53. package/dist/type-expression.d.ts.map +1 -1
  54. package/dist/type-expression.js +23 -3
  55. package/dist/value-equality.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/backend-runtime.ts +180 -30
  58. package/src/catalog-runtime.ts +133 -133
  59. package/src/cel-map-value.ts +31 -20
  60. package/src/cel-value.ts +11 -3
  61. package/src/check-diagnostic.ts +4 -2
  62. package/src/checker.ts +123 -10
  63. package/src/closure-backend.ts +59 -14
  64. package/src/duration-value.ts +18 -3
  65. package/src/emitted-module.ts +1 -1
  66. package/src/engine-version.ts +1 -1
  67. package/src/environment.ts +26 -5
  68. package/src/function-registry.ts +114 -2
  69. package/src/index.ts +24 -3
  70. package/src/js-emitter.ts +13 -5
  71. package/src/macro-check.ts +7 -1
  72. package/src/member-read.ts +48 -7
  73. package/src/resolved-call.ts +13 -0
  74. package/src/runtime-library.ts +167 -143
  75. package/src/serializer.ts +19 -3
  76. package/src/timestamp-value.ts +16 -0
  77. package/src/type-expression.ts +25 -3
  78. package/src/value-equality.ts +3 -3
package/src/cel-value.ts CHANGED
@@ -82,12 +82,20 @@ export interface CelMapValueEntry {
82
82
  }
83
83
 
84
84
  /**
85
- * A map. Entries live in a `Map` keyed by the canonical text of each key, so the map
86
- * holds int, uint, bool and string keys alike and **no key is ever a property name**:
85
+ * What identifies a key inside a map's entries. A `Map` distinguishes its own keys **by
86
+ * type**, so the four CEL key types collapse onto three JS ones with no namespace to
87
+ * separate by hand: a string key is itself, a bool is itself, and an int, a uint and a
88
+ * whole double are all the `bigint` CEL equality makes them.
89
+ */
90
+ export type CelMapKey = string | bigint | boolean;
91
+
92
+ /**
93
+ * A map. Entries live in a `Map` keyed by each key's own `CelMapKey`, so the map holds
94
+ * int, uint, bool and string keys alike and **no key is ever a property name**:
87
95
  * `__proto__`, `constructor` and `prototype` are data here, as they are in any map.
88
96
  */
89
97
  export interface CelMap extends Branded<"map"> {
90
- readonly entries: ReadonlyMap<string, CelMapValueEntry>;
98
+ readonly entries: ReadonlyMap<CelMapKey, CelMapValueEntry>;
91
99
  }
92
100
 
93
101
  /** Every evaluation failure this engine names. A code is never derived from a message. */
@@ -67,12 +67,14 @@ export interface CelCheckDiagnostic {
67
67
  * refusal to compile a tree the front end already reported a syntax diagnostic for,
68
68
  * which is a question the caller should have asked before compiling, and
69
69
  * `emitted_module_rejected` is the refusal to run a loaded module whose integrity header
70
- * is not the one the emission asked for.
70
+ * is not the one the emission asked for, and `signature_too_wide` is a registration
71
+ * declaring more values than an implementation receives.
71
72
  */
72
73
  export type CelEngineErrorCode =
73
74
  | "namespaces_mismatch"
74
75
  | "unreadable_expression"
75
- | "emitted_module_rejected";
76
+ | "emitted_module_rejected"
77
+ | "signature_too_wide";
76
78
 
77
79
  export class CelEngineError extends Error {
78
80
  readonly code: CelEngineErrorCode;
package/src/checker.ts CHANGED
@@ -80,6 +80,15 @@ export interface NamespaceFunction {
80
80
  readonly parameters?: readonly CelType[];
81
81
  /** How the declaration is written, for a listing; absent with the parameters. */
82
82
  readonly signature?: string;
83
+ /**
84
+ * Types the declaration named that nothing is registered under, in the order written.
85
+ *
86
+ * The declaration is accepted and reads `dyn` where one stands: a host's declaration is
87
+ * data out of someone's manifest, so a name its own registry does not carry is the host
88
+ * disagreeing with itself — reported at the call, where there is a range, rather than
89
+ * thrown at registration, where there is none. Same rule as an unjudged schema node.
90
+ */
91
+ readonly unregisteredTypes?: readonly string[];
83
92
  readonly deterministic?: boolean;
84
93
  readonly hostBacked?: boolean;
85
94
  readonly throws?: readonly string[];
@@ -171,6 +180,10 @@ class Checker implements MacroHost {
171
180
  }
172
181
  }
173
182
 
183
+ typeOfPresence(node: Extract<CelNode, { kind: "select" }>): CelType {
184
+ return this.selectType(node, true);
185
+ }
186
+
174
187
  // --- the walk -----------------------------------------------------------
175
188
 
176
189
  typeOf(node: CelNode): CelType {
@@ -186,7 +199,7 @@ class Checker implements MacroHost {
186
199
  case "map":
187
200
  return this.mapType(node);
188
201
  case "select":
189
- return this.selectType(node);
202
+ return this.selectType(node, node.optional);
190
203
  case "index":
191
204
  return this.indexType(node);
192
205
  case "unary":
@@ -318,13 +331,19 @@ class Checker implements MacroHost {
318
331
  return reduced;
319
332
  }
320
333
 
321
- private selectType(node: Extract<CelNode, { kind: "select" }>): CelType {
334
+ /**
335
+ * `presence` is whether the read asks whether the member is THERE — written `.?b`, or
336
+ * standing as the argument of `has()`. It decides nothing about a type that is known to
337
+ * hold no members, which both forms refuse; it decides what a UNION's member-less
338
+ * branches are, which is the absence case a presence-shaped read answers for.
339
+ */
340
+ private selectType(node: Extract<CelNode, { kind: "select" }>, presence: boolean): CelType {
322
341
  const qualified = this.qualifiedVariableType(node);
323
342
  if (qualified) return qualified;
324
343
  const operand = this.typeOf(node.operand);
325
344
  if (node.field === "") return DYN;
326
345
  const guarded = this.checkNullable(node.operand, operand, `.${node.field}`, node.fieldRange);
327
- const held = this.memberType(guarded, node.field, node.fieldRange);
346
+ const held = this.memberType(guarded, node.field, node.fieldRange, presence);
328
347
  // Reading through an optional answers an optional, whichever form the read is
329
348
  // written in: that is what makes a chain of reads over a value that may be absent
330
349
  // stay one expression instead of needing a guard at every step.
@@ -367,11 +386,22 @@ class Checker implements MacroHost {
367
386
  }
368
387
 
369
388
  /** The type of a named member, reported against whatever the operand turned out to be. */
370
- private memberType(operand: CelType, field: string, range: SourceRange): CelType {
389
+ private memberType(
390
+ operand: CelType,
391
+ field: string,
392
+ range: SourceRange,
393
+ presence = false,
394
+ ): CelType {
371
395
  if (isDyn(operand) || operand.kind === "parameter") return DYN;
372
- if (operand.kind === "optional") return this.memberType(operand.value, field, range);
396
+ if (operand.kind === "optional") {
397
+ return this.memberType(operand.value, field, range, presence);
398
+ }
373
399
  if (operand.kind === "union") {
374
- return unionOf(operand.members.map((member) => this.memberType(member, field, range)));
400
+ return unionOf(
401
+ presenceBranches(operand, holdsMembers, presence).map((member) =>
402
+ this.memberType(member, field, range, presence),
403
+ ),
404
+ );
375
405
  }
376
406
  if (operand.kind === "record") return this.recordMemberType(operand, field, range);
377
407
  if (operand.kind === "map") {
@@ -406,7 +436,7 @@ class Checker implements MacroHost {
406
436
  const operand = this.typeOf(node.operand);
407
437
  const index = this.typeOf(node.index);
408
438
  const guarded = this.checkNullable(node.operand, operand, "[…]", node.range);
409
- const held = this.elementType(guarded, index, node);
439
+ const held = this.elementType(guarded, index, node, node.optional);
410
440
  return node.optional || guarded.kind === "optional" ? optionalOf(unwrapOptional(held)) : held;
411
441
  }
412
442
 
@@ -414,11 +444,18 @@ class Checker implements MacroHost {
414
444
  operand: CelType,
415
445
  index: CelType,
416
446
  node: Extract<CelNode, { kind: "index" }>,
447
+ presence = false,
417
448
  ): CelType {
418
449
  if (isDyn(operand) || operand.kind === "parameter") return DYN;
419
- if (operand.kind === "optional") return this.elementType(operand.value, index, node);
450
+ if (operand.kind === "optional") {
451
+ return this.elementType(operand.value, index, node, presence);
452
+ }
420
453
  if (operand.kind === "union") {
421
- return unionOf(operand.members.map((member) => this.elementType(member, index, node)));
454
+ return unionOf(
455
+ presenceBranches(operand, holdsElements, presence).map((member) =>
456
+ this.elementType(member, index, node, presence),
457
+ ),
458
+ );
422
459
  }
423
460
  if (operand.kind === "list") {
424
461
  if (!assignable(index, INT) && !isDyn(index)) {
@@ -659,7 +696,7 @@ class Checker implements MacroHost {
659
696
  if (failure.reason === "unknown") {
660
697
  this.report(
661
698
  "CEL_UNKNOWN_FUNCTION",
662
- `no function named ${JSON.stringify(node.name)} is registered`,
699
+ `no function named ${JSON.stringify(node.name)} is registered${this.closestNames(node.name, form, args.length)}`,
663
700
  node.range,
664
701
  this.renameFix(node),
665
702
  );
@@ -690,6 +727,28 @@ class Checker implements MacroHost {
690
727
  );
691
728
  }
692
729
 
730
+ /**
731
+ * What a call on an unregistered name is offered instead: the registered names that
732
+ * accept its form and arity, nearest first, as a clause to hang off the refusal.
733
+ *
734
+ * **The alternatives belong in the MESSAGE, and the fix stays singular.** A reader of
735
+ * `no('x')` is one name away from the five functions that would have worked, and the
736
+ * engine this one replaces said so; losing it is a real loss of help. A list of five
737
+ * repairs, though, is not a repair — an editor applying one would be guessing which
738
+ * function the author meant — so the single rename fix is still offered only where
739
+ * exactly one name is a case-insensitive match.
740
+ *
741
+ * It names no command and no tool: which listing a host offers is the host's, and an
742
+ * engine embedded in one with no CLI would be pointing at nothing.
743
+ */
744
+ private closestNames(name: string, form: CallForm, arity: number): string {
745
+ const candidates = this.context.registry.candidateNames(name, form, arity);
746
+ if (candidates.length === 0) return "";
747
+ const written = arity === 0 ? "no arguments" : arity === 1 ? "1 argument" : `${arity} arguments`;
748
+ const shape = form === "receiver" ? `written on a value with ${written}` : `taking ${written}`;
749
+ return ` — the closest ${shape}: ${candidates.join(", ")}`;
750
+ }
751
+
693
752
  /** The one registered name this call might have meant, as a whole-source repair. */
694
753
  private renameFix(node: CelCallNode | CelReceiverCallNode): CelDiagnosticFix | undefined {
695
754
  const wanted = node.name.toLowerCase();
@@ -757,6 +816,7 @@ class Checker implements MacroHost {
757
816
  namespace: node.namespace,
758
817
  arity: args.length,
759
818
  range: node.range,
819
+ argumentTypes: args.map((type) => formatType(withoutParameters(type))),
760
820
  });
761
821
  // An OPEN namespace declares only part of what it reaches, so a name it does not
762
822
  // carry is not this engine's to refuse — it is listed and left to the host.
@@ -775,12 +835,27 @@ class Checker implements MacroHost {
775
835
  namespace: node.namespace,
776
836
  arity: args.length,
777
837
  range: node.range,
838
+ argumentTypes: args.map((type) => formatType(withoutParameters(type))),
778
839
  ...(declared.signature === undefined ? {} : { signature: declared.signature }),
779
840
  returns: formatType(withoutParameters(declared.returns)),
780
841
  deterministic: declared.deterministic ?? true,
781
842
  hostBacked: declared.hostBacked ?? false,
782
843
  ...(declared.throws ? { throws: declared.throws } : {}),
783
844
  });
845
+ // A type the host named and its own registry does not carry: the declaration stands,
846
+ // reads `dyn` where that type stood, and the disagreement is a verdict HERE — this is
847
+ // the only place the name has a range.
848
+ const unregistered = declared.unregisteredTypes ?? [];
849
+ if (unregistered.length > 0) {
850
+ const names = unregistered.map((name) => JSON.stringify(name)).join(", ");
851
+ this.report(
852
+ "CEL_TYPE_ERROR",
853
+ unregistered.length === 1
854
+ ? `${qualified} is declared over the type ${names}, and no type is registered under that name`
855
+ : `${qualified} is declared over the types ${names}, and no type is registered under those names`,
856
+ node.range,
857
+ );
858
+ }
784
859
  // Parameters withheld: the host judges arity and arguments against its own, richer
785
860
  // signature grammar, and this engine types the result and says nothing else.
786
861
  if (parameters === undefined) return declared.returns;
@@ -815,6 +890,44 @@ function isNullTest(args: readonly CelType[]): boolean {
815
890
  return (isNull(left) && admitsNull(right)) || (isNull(right) && admitsNull(left));
816
891
  }
817
892
 
893
+ /**
894
+ * Which branches of a union a read is judged against.
895
+ *
896
+ * A **presence-shaped** read — `a.?b`, `a[?k]`, `has(a.b)` — asks whether the member is
897
+ * THERE, so a branch that can hold no member at all is the absence case it answers
898
+ * `optional.none()` / `false` for at runtime, not a mistake in the expression: that is how
899
+ * a two-shape field is discriminated without a type test. Where **no** branch can hold one
900
+ * the read is judged against them all, so an operand that is known to hold no members is
901
+ * still refused in the presence form exactly as in the plain one.
902
+ */
903
+ function presenceBranches(
904
+ union: Extract<CelType, { kind: "union" }>,
905
+ holds: (type: CelType) => boolean,
906
+ presence: boolean,
907
+ ): readonly CelType[] {
908
+ if (!presence) return union.members;
909
+ const holding = union.members.filter(holds);
910
+ return holding.length > 0 ? holding : union.members;
911
+ }
912
+
913
+ /** Whether a type can hold a named member at all — what `memberType` reads one of. */
914
+ function holdsMembers(type: CelType): boolean {
915
+ if (isDyn(type) || type.kind === "parameter") return true;
916
+ if (type.kind === "record" || type.kind === "map") return true;
917
+ if (type.kind === "optional") return holdsMembers(type.value);
918
+ if (type.kind === "union") return type.members.some(holdsMembers);
919
+ return false;
920
+ }
921
+
922
+ /** Whether a type can hold an element at all — what `elementType` reads one of. */
923
+ function holdsElements(type: CelType): boolean {
924
+ if (isDyn(type) || type.kind === "parameter") return true;
925
+ if (type.kind === "record" || type.kind === "map" || type.kind === "list") return true;
926
+ if (type.kind === "optional") return holdsElements(type.value);
927
+ if (type.kind === "union") return type.members.some(holdsElements);
928
+ return false;
929
+ }
930
+
818
931
  /** An optional of an optional is one optional; a chain of reads does not nest them. */
819
932
  function unwrapOptional(type: CelType): CelType {
820
933
  return type.kind === "optional" ? unwrapOptional(type.value) : type;
@@ -186,21 +186,23 @@ class Compiler {
186
186
  }));
187
187
  const { range } = node;
188
188
  return (frame) => {
189
- const pairs: [CelValue, CelValue][] = [];
189
+ // Flat — key, value, key, value — so a literal of n entries costs one allocation
190
+ // rather than one per entry. The emitter writes the same call.
191
+ const flat: CelValue[] = [];
190
192
  for (const entry of entries) {
191
193
  const key = entry.key(frame);
192
194
  if (isCelError(key)) return key;
193
195
  const value = entry.value(frame);
194
196
  if (isCelError(value)) return value;
195
197
  if (!entry.optional) {
196
- pairs.push([key, value]);
198
+ flat.push(key, value);
197
199
  continue;
198
200
  }
199
201
  const held = optionalEntry(value, range);
200
202
  if (isCelError(held)) return held;
201
- if (held.present) pairs.push([key, held.held as CelValue]);
203
+ if (held.present) flat.push(key, held.held as CelValue);
202
204
  }
203
- return celMapFromEntries(pairs, range);
205
+ return celMapFromEntries(flat, range);
204
206
  };
205
207
  }
206
208
 
@@ -297,20 +299,63 @@ class Compiler {
297
299
 
298
300
  /**
299
301
  * One dispatch: evaluate the arguments, carry the first error out, then hand the values
300
- * to the site, which resolves the overload on their own types.
302
+ * to the site **positionally**, which resolves the overload on their own types. The arity
303
+ * is the dispatch key's and known at compile time, so the step is chosen once here and no
304
+ * argument array is built per call — the same choice the emitter writes into its text.
301
305
  */
302
306
  private callStep(name: string, form: CallForm, args: readonly CelStep[], range: SourceRange): CelStep {
303
307
  const site = callSiteOf(this.target, name, form, range);
304
- const count = args.length;
305
- return (frame) => {
306
- const values: CelValue[] = new Array<CelValue>(count);
307
- for (let at = 0; at < count; at += 1) {
308
- const value = args[at]!(frame);
309
- if (isCelError(value)) return value;
310
- values[at] = value;
308
+ const [first, second, third, fourth] = args;
309
+ switch (args.length) {
310
+ case 0:
311
+ return () => site.call0();
312
+ case 1:
313
+ return (frame) => {
314
+ const a = first!(frame);
315
+ return isCelError(a) ? a : site.call1(a);
316
+ };
317
+ case 2:
318
+ return (frame) => {
319
+ const a = first!(frame);
320
+ if (isCelError(a)) return a;
321
+ const b = second!(frame);
322
+ return isCelError(b) ? b : site.call2(a, b);
323
+ };
324
+ case 3:
325
+ return (frame) => {
326
+ const a = first!(frame);
327
+ if (isCelError(a)) return a;
328
+ const b = second!(frame);
329
+ if (isCelError(b)) return b;
330
+ const c = third!(frame);
331
+ return isCelError(c) ? c : site.call3(a, b, c);
332
+ };
333
+ case 4:
334
+ return (frame) => {
335
+ const a = first!(frame);
336
+ if (isCelError(a)) return a;
337
+ const b = second!(frame);
338
+ if (isCelError(b)) return b;
339
+ const c = third!(frame);
340
+ if (isCelError(c)) return c;
341
+ const d = fourth!(frame);
342
+ return isCelError(d) ? d : site.call4(a, b, c, d);
343
+ };
344
+ default: {
345
+ // A call written wider than any signature may be. Its arguments are still evaluated
346
+ // in order, so an error in one carries out ahead of the refusal.
347
+ const count = args.length;
348
+ return (frame) => {
349
+ const values: CelValue[] = new Array<CelValue>(count);
350
+ for (let at = 0; at < count; at += 1) {
351
+ const held = args[at]!(frame);
352
+ if (isCelError(held)) return held;
353
+ values[at] = held;
354
+ }
355
+ return site.call(values);
356
+ };
311
357
  }
312
- return site.call(values);
313
- };
358
+ }
314
359
  }
315
360
 
316
361
  private qualifiedCallStep(
@@ -23,7 +23,7 @@
23
23
  * own spelling, and the one plain encoding a duration has wherever it is written down.
24
24
  */
25
25
 
26
- import { celError, CEL_VALUE_TYPE, type CelDuration, type CelError } from "./cel-value.js";
26
+ import { celError, CEL_VALUE_TYPE, isCelError, type CelDuration, type CelError } from "./cel-value.js";
27
27
  import type { SourceRange } from "./syntax-tree.js";
28
28
 
29
29
  /** The widest and narrowest total a duration holds, in nanoseconds: int64. */
@@ -78,6 +78,21 @@ const PART = /([0-9]*)(?:\.([0-9]*))?(ns|us|µs|μs|ms|s|m|h)/g;
78
78
  * `ns`, `us`, `ms`, `s`, `m` and `h` (`1h30m`, `1.5s`, `-10m`).
79
79
  */
80
80
  export function parseDuration(text: string, range?: SourceRange): CelDuration | CelError {
81
+ const total = durationNanosFromText(text, range);
82
+ return isCelError(total) ? total : celDurationFromNanos(total, range);
83
+ }
84
+
85
+ /**
86
+ * The same text as a TOTAL OF NANOSECONDS, with no range applied.
87
+ *
88
+ * The grammar and the range are separate questions, and a consumer outside CEL has the same
89
+ * grammar with a different range: protobuf's `google.protobuf.Duration` reaches ±10,000
90
+ * years, which **cannot be held in an int64 of nanoseconds at all**, so a reader that must
91
+ * carry one (a journal entry, a value off a transport) cannot go through `parseDuration` and
92
+ * would otherwise restate this grammar. It answers an unbounded `bigint`; applying a range
93
+ * is the caller's, and `celDurationFromNanos` is what applies CEL's.
94
+ */
95
+ export function durationNanosFromText(text: string, range?: SourceRange): bigint | CelError {
81
96
  const refuse = () =>
82
97
  celError("invalid_conversion", `${JSON.stringify(text)} is not a duration`, range);
83
98
  let body = text;
@@ -86,7 +101,7 @@ export function parseDuration(text: string, range?: SourceRange): CelDuration |
86
101
  negative = body.startsWith("-");
87
102
  body = body.slice(1);
88
103
  }
89
- if (body === "0") return celDurationFromNanos(0n, range);
104
+ if (body === "0") return 0n;
90
105
  if (body === "") return refuse();
91
106
  PART.lastIndex = 0;
92
107
  let total = 0n;
@@ -104,7 +119,7 @@ export function parseDuration(text: string, range?: SourceRange): CelDuration |
104
119
  at += whole.length;
105
120
  }
106
121
  if (at !== body.length) return refuse();
107
- return celDurationFromNanos(negative ? -total : total, range);
122
+ return negative ? -total : total;
108
123
  }
109
124
 
110
125
  /** Seconds with an `s` suffix, the fraction trimmed to what it carries. */
@@ -94,7 +94,7 @@ import type { CelNode } from "./syntax-tree.js";
94
94
  * code the emitter writes for a corpus drawn from the package's own total enumerations, beside
95
95
  * this number, so a change to that text fails naming the bump it owes.
96
96
  */
97
- export const EMITTER_FORMAT_GENERATION = 1;
97
+ export const EMITTER_FORMAT_GENERATION = 2;
98
98
 
99
99
  /** The prefix of the one line a stored module's header is read from. */
100
100
  const HEADER_PREFIX = "//@telo.cel ";
@@ -6,4 +6,4 @@
6
6
  // integrity header, so a module one build wrote is never run by another.
7
7
 
8
8
  /** This build of the CEL engine, as an emitted module names it. */
9
- export const ENGINE_VERSION = "0.107.0";
9
+ export const ENGINE_VERSION = "0.108.0";
@@ -37,7 +37,7 @@ import { environmentDigest } from "./environment-digest.js";
37
37
  import type { CelValue } from "./cel-value.js";
38
38
  import { CEL_VALUE_KEYS } from "./cel-value.js";
39
39
  import type { CelType } from "./cel-type.js";
40
- import { formatType } from "./cel-type.js";
40
+ import { DYN, formatType } from "./cel-type.js";
41
41
  import { CelEngineError } from "./check-diagnostic.js";
42
42
  import type { CheckResult, NamespaceFunction } from "./checker.js";
43
43
  import { checkExpression } from "./checker.js";
@@ -182,6 +182,14 @@ export interface SchemaRegistrationReport {
182
182
  * by construction rather than by a flag: a declaration that carried parameters and asked for
183
183
  * them to be ignored would hold a list nothing reads, which no reader can tell from a list
184
184
  * that is simply wrong.
185
+ *
186
+ * **A type name this environment registers nothing under is accepted and reads `dyn`**, and
187
+ * every call to such a function carries a ranged `CEL_TYPE_ERROR` naming it. A declaration
188
+ * is a host's own data — a module function's declared result out of a manifest — so a typo
189
+ * in one is the host disagreeing with its own registry, which is reported where there is a
190
+ * range rather than thrown where there is none. A type registered AFTER a declaration naming
191
+ * it does not change that declaration: a declaration resolves its types once, when it is
192
+ * made.
185
193
  */
186
194
  export type NamespaceFunctionDeclaration = (
187
195
  | { readonly signature: string; readonly name?: never; readonly returns?: never }
@@ -443,8 +451,21 @@ export class CelEnvironment {
443
451
  ...(entry.hostBacked === undefined ? {} : { hostBacked: entry.hostBacked }),
444
452
  ...(entry.throws === undefined ? {} : { throws: entry.throws }),
445
453
  };
454
+ // A name this environment registers no type under is RECORDED and read as `dyn`,
455
+ // never thrown: a namespace declaration is a host's own data — a module function's
456
+ // declared result out of a manifest — so a typo there would otherwise be a crash
457
+ // with no line, and the consumer that knows where it was written is the one that
458
+ // can anchor the diagnostic. The checker reports it at each call.
459
+ const unregistered: string[] = [];
460
+ const resolver: NominalResolver = (named, args) => {
461
+ const resolved = this.nominalResolver(named, args);
462
+ if (resolved) return resolved;
463
+ if (!unregistered.includes(named)) unregistered.push(named);
464
+ return DYN;
465
+ };
466
+ const recorded = () => (unregistered.length > 0 ? { unregisteredTypes: [...unregistered] } : {});
446
467
  if (entry.signature !== undefined) {
447
- const signature = parseSignature(entry.signature, this.nominalResolver);
468
+ const signature = parseSignature(entry.signature, resolver);
448
469
  if (signature.form !== "global") {
449
470
  throw new CelTypeRegistrationError(
450
471
  `a namespaced function is declared without a receiver: ${JSON.stringify(entry.signature)}`,
@@ -455,6 +476,7 @@ export class CelEnvironment {
455
476
  returns: signature.returns,
456
477
  parameters: signature.parameters,
457
478
  signature: formatSignature(signature),
479
+ ...recorded(),
458
480
  ...flags,
459
481
  });
460
482
  continue;
@@ -462,9 +484,8 @@ export class CelEnvironment {
462
484
  declared.set(entry.name, {
463
485
  name: entry.name,
464
486
  returns:
465
- typeof entry.returns === "string"
466
- ? parseTypeExpression(entry.returns, this.nominalResolver)
467
- : entry.returns,
487
+ typeof entry.returns === "string" ? parseTypeExpression(entry.returns, resolver) : entry.returns,
488
+ ...recorded(),
468
489
  ...flags,
469
490
  });
470
491
  }
@@ -15,10 +15,13 @@
15
15
  * once, with the candidates it considered.
16
16
  */
17
17
 
18
+ import { CelEngineError } from "./check-diagnostic.js";
18
19
  import type { CelType } from "./cel-type.js";
19
- import { assignable, DYN, formatType, isDyn } from "./cel-type.js";
20
+ import { assignable, DYN, formatType, isDyn, typesEqual } from "./cel-type.js";
20
21
  import type { CallForm, CelSignature, FunctionMetadata } from "./signature.js";
21
22
  import { formatSignature, signatureKey } from "./signature.js";
23
+ import { CALL_SITE_DIRECT_ARITY } from "./runtime-library.js";
24
+ import { isIdentifierSpelling, isReservedWord } from "./reserved-words.js";
22
25
 
23
26
  export interface RegisteredFunction {
24
27
  readonly signature: CelSignature;
@@ -33,6 +36,16 @@ export type ResolutionFailure =
33
36
  /** The name and form are registered; no overload takes these arguments. */
34
37
  | { readonly reason: "no-overload"; readonly candidates: readonly RegisteredFunction[] };
35
38
 
39
+ /**
40
+ * How many names a refused call may name as candidates.
41
+ *
42
+ * It is **declared** rather than left to a message's taste, because the conformance
43
+ * vectors pin that message byte for byte: a bound and an order a second engine cannot
44
+ * reproduce would be a row no port can pass. The order is edit distance then name, both
45
+ * over the name exactly as it was written.
46
+ */
47
+ export const UNKNOWN_FUNCTION_CANDIDATES = 5;
48
+
36
49
  export interface Resolution {
37
50
  readonly resolved: RegisteredFunction;
38
51
  /** The return type with every type parameter substituted by what this call bound. */
@@ -51,6 +64,16 @@ export class FunctionRegistry {
51
64
 
52
65
  /** Registers a function, replacing any registration answering the same call. */
53
66
  register(signature: CelSignature, metadata: FunctionMetadata = {}): void {
67
+ // An implementation receives its arguments positionally, up to the bound, so a wider
68
+ // signature is refused HERE rather than resolved and then called with its tail dropped.
69
+ // A declared bound nothing enforces is the silent wrong answer it exists to prevent.
70
+ const arity = signature.parameters.length + (signature.form === "receiver" ? 1 : 0);
71
+ if (arity > CALL_SITE_DIRECT_ARITY) {
72
+ throw new CelEngineError(
73
+ "signature_too_wide",
74
+ `${formatSignature(signature)} takes ${arity} values and an implementation receives at most ${CALL_SITE_DIRECT_ARITY}`,
75
+ );
76
+ }
54
77
  const entries = this.byName.get(signature.name) ?? [];
55
78
  const key = signatureKey(signature);
56
79
  const at = entries.findIndex((entry) => signatureKey(entry.signature) === key);
@@ -93,6 +116,38 @@ export class FunctionRegistry {
93
116
  return this.byName.get(name) ?? [];
94
117
  }
95
118
 
119
+ /**
120
+ * The names this environment registers that would accept a call of that form and
121
+ * arity, nearest first — what a call on a name nothing registers is offered instead.
122
+ *
123
+ * **Form and arity are a filter rather than a ranking**, because a nearer name a call
124
+ * cannot be written on is not a repair: `'a'.size()` is not helped by `size`. A name
125
+ * that is not a name is filtered for the same reason — the operators register under
126
+ * their own symbols (`!`, `-`, `+`), and `no(1)` offered `!` and `-` as its two
127
+ * nearest spellings, neither of which can be written as a call at all. Among what
128
+ * passes the filter the order is edit distance then name, and the list is cut at
129
+ * {@link UNKNOWN_FUNCTION_CANDIDATES} — a declared bound and a declared order, so the
130
+ * message is the same text on every engine. Distance is counted over the name as
131
+ * written, with no case folding: a port reproduces one rule and not a host language's
132
+ * notion of lower case.
133
+ */
134
+ candidateNames(wanted: string, form: CallForm, arity: number): readonly string[] {
135
+ const matching: string[] = [];
136
+ for (const [name, entries] of this.byName) {
137
+ if (name === wanted) continue;
138
+ if (!isIdentifierSpelling(name) || isReservedWord(name)) continue;
139
+ const accepts = entries.some(
140
+ (entry) => entry.signature.form === form && entry.signature.parameters.length === arity,
141
+ );
142
+ if (accepts) matching.push(name);
143
+ }
144
+ return matching
145
+ .map((name) => ({ name, distance: editDistance(wanted, name) }))
146
+ .sort((left, right) => left.distance - right.distance || compareNames(left.name, right.name))
147
+ .slice(0, UNKNOWN_FUNCTION_CANDIDATES)
148
+ .map((candidate) => candidate.name);
149
+ }
150
+
96
151
  /**
97
152
  * The function a call resolves to. `receiver` is the type the call is written on,
98
153
  * absent for a global call.
@@ -114,11 +169,41 @@ export class FunctionRegistry {
114
169
  // unlisted variable from turning one mistake into two.
115
170
  const exact = this.match(viable, args, receiver, false);
116
171
  if (exact) return exact;
117
- const loose = this.match(viable, args, receiver, true);
172
+ const loose = this.matchLoosely(viable, args, receiver);
118
173
  if (loose) return loose;
119
174
  return { reason: "no-overload", candidates: inForm };
120
175
  }
121
176
 
177
+ /**
178
+ * The loose pass: a `dyn` argument makes a candidate viable, and **where several become
179
+ * viable and they do not agree on a return type, the call answers `dyn`.**
180
+ *
181
+ * Taking the first one instead is a concrete type nothing established. `dyn('a') +
182
+ * dyn('b')` answered `int` — the first `+` overload — so a host that declares nothing
183
+ * about a name had `a + b` typed `int`, and a consumer comparing that against a declared
184
+ * `string` slot refused a manifest that runs correctly. The honest answer is the one the
185
+ * engine's own design already states: overloads are resolved per call site on the VALUES'
186
+ * own types, so a `dyn` operand is exactly the case where the static answer is unknown.
187
+ *
188
+ * The candidate is still carried for the caller that wants one (a listing, a flag), and a
189
+ * single viable overload still answers its own return type — `dyn` only where they differ.
190
+ */
191
+ private matchLoosely(
192
+ candidates: readonly RegisteredFunction[],
193
+ args: readonly CelType[],
194
+ receiver: CelType | undefined,
195
+ ): Resolution | undefined {
196
+ const viable: Resolution[] = [];
197
+ for (const candidate of candidates) {
198
+ const held = this.match([candidate], args, receiver, true);
199
+ if (held) viable.push(held);
200
+ }
201
+ const first = viable[0];
202
+ if (!first) return undefined;
203
+ const agree = viable.every((held) => typesEqual(held.returns, first.returns));
204
+ return agree ? first : { resolved: first.resolved, returns: DYN };
205
+ }
206
+
122
207
  private match(
123
208
  candidates: readonly RegisteredFunction[],
124
209
  args: readonly CelType[],
@@ -219,6 +304,33 @@ export function substitute(type: CelType, bindings: ReadonlyMap<string, CelType>
219
304
  }
220
305
  }
221
306
 
307
+ /** Code-unit order, so a port orders two names without a locale. */
308
+ function compareNames(left: string, right: string): number {
309
+ return left < right ? -1 : left > right ? 1 : 0;
310
+ }
311
+
312
+ /**
313
+ * Levenshtein distance in UTF-16 code units — one insertion, deletion or substitution
314
+ * each costing one, which is the whole rule a port needs to reproduce.
315
+ *
316
+ * Code units rather than code points for the same reason every range in this package is
317
+ * in them: it is the one unit both ends of the engine already count in, and a name is
318
+ * compared against a name rather than cut.
319
+ */
320
+ function editDistance(from: string, to: string): number {
321
+ let previous = Array.from({ length: to.length + 1 }, (ignored, at) => at);
322
+ for (let left = 1; left <= from.length; left += 1) {
323
+ const row = new Array<number>(to.length + 1);
324
+ row[0] = left;
325
+ for (let right = 1; right <= to.length; right += 1) {
326
+ const substitution = previous[right - 1]! + (from[left - 1] === to[right - 1] ? 0 : 1);
327
+ row[right] = Math.min(substitution, previous[right]! + 1, row[right - 1]! + 1);
328
+ }
329
+ previous = row;
330
+ }
331
+ return previous[to.length]!;
332
+ }
333
+
222
334
  /** How a resolution failure names what it looked at, for a message a human reads. */
223
335
  export function describeCandidates(candidates: readonly RegisteredFunction[]): string {
224
336
  return candidates.map((candidate) => formatSignature(candidate.signature)).join(", ");