@telorun/templating 0.12.0 → 0.14.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 (55) hide show
  1. package/README.md +2 -2
  2. package/dist/builtins.d.ts +11 -0
  3. package/dist/builtins.d.ts.map +1 -1
  4. package/dist/builtins.js +16 -0
  5. package/dist/cel/analyze.d.ts +2 -1
  6. package/dist/cel/analyze.d.ts.map +1 -1
  7. package/dist/cel/analyze.js +4 -5
  8. package/dist/cel/catalog.d.ts.map +1 -1
  9. package/dist/cel/catalog.js +27 -0
  10. package/dist/cel/diagnose.d.ts +50 -0
  11. package/dist/cel/diagnose.d.ts.map +1 -0
  12. package/dist/cel/diagnose.js +223 -0
  13. package/dist/cel/environment.d.ts +7 -0
  14. package/dist/cel/environment.d.ts.map +1 -1
  15. package/dist/cel/environment.js +19 -5
  16. package/dist/cel/walk.d.ts +17 -1
  17. package/dist/cel/walk.d.ts.map +1 -1
  18. package/dist/cel/walk.js +21 -3
  19. package/dist/engine.d.ts +110 -3
  20. package/dist/engine.d.ts.map +1 -1
  21. package/dist/engines/cel.d.ts +13 -9
  22. package/dist/engines/cel.d.ts.map +1 -1
  23. package/dist/engines/cel.js +86 -28
  24. package/dist/engines/include.d.ts +28 -0
  25. package/dist/engines/include.d.ts.map +1 -0
  26. package/dist/engines/include.js +142 -0
  27. package/dist/engines/literal.js +1 -1
  28. package/dist/engines/ref.js +1 -1
  29. package/dist/engines/sql.d.ts.map +1 -1
  30. package/dist/engines/sql.js +32 -5
  31. package/dist/index.d.ts +8 -6
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -4
  34. package/dist/manifest-schemas.d.ts +0 -16
  35. package/dist/manifest-schemas.d.ts.map +1 -1
  36. package/dist/manifest-schemas.js +0 -95
  37. package/dist/sentinel.d.ts +37 -0
  38. package/dist/sentinel.d.ts.map +1 -1
  39. package/dist/sentinel.js +54 -0
  40. package/package.json +2 -2
  41. package/src/builtins.ts +17 -0
  42. package/src/cel/analyze.ts +4 -7
  43. package/src/cel/catalog.ts +27 -0
  44. package/src/cel/diagnose.ts +293 -0
  45. package/src/cel/environment.ts +21 -5
  46. package/src/cel/walk.ts +37 -4
  47. package/src/engine.ts +116 -3
  48. package/src/engines/cel.ts +91 -31
  49. package/src/engines/include.ts +164 -0
  50. package/src/engines/literal.ts +1 -1
  51. package/src/engines/ref.ts +1 -1
  52. package/src/engines/sql.ts +41 -7
  53. package/src/index.ts +36 -5
  54. package/src/manifest-schemas.ts +0 -98
  55. package/src/sentinel.ts +62 -0
@@ -1 +1 @@
1
- {"version":3,"file":"manifest-schemas.d.ts","sourceRoot":"","sources":["../src/manifest-schemas.ts"],"names":[],"mappings":"AAAA;;;;;;kBAMkB;AAElB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;yEA+ByE;AACzE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoB7B,CAAC;AAqCF;;;;;;;;;;;;;;uDAcuD;AACvD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CA+C1D;AAED;;;;;+BAK+B;AAC/B,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;8BAG8B;AAC9B,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAK9B,CAAC"}
1
+ {"version":3,"file":"manifest-schemas.d.ts","sourceRoot":"","sources":["../src/manifest-schemas.ts"],"names":[],"mappings":"AAAA;;;;;;kBAMkB;AAElB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;yEA+ByE;AACzE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoB7B,CAAC;AAGF;;;;;+BAK+B;AAC/B,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;8BAG8B;AAC9B,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAK9B,CAAC"}
@@ -58,101 +58,6 @@ export const ResourceRefSchema = {
58
58
  },
59
59
  ],
60
60
  };
61
- const REF_ANNOTATION = "x-telo-ref";
62
- // The legacy base types a reference slot used to pin when references were
63
- // written as plain strings. Post-migration a reference resolves to an object
64
- // (the `{kind, name, alias?}` shape, or an unresolved `!ref` sentinel), so a
65
- // scalar `type` on a ref slot is a stale constraint that would reject the
66
- // resolved value. Object / array `type`s are left alone — they already admit
67
- // the reference object (and any inline value a slot like `inputType` accepts).
68
- const LEGACY_REF_SCALAR_TYPES = new Set(["string", "number", "integer", "boolean"]);
69
- // JSON Schema keywords whose values are themselves subschemas. Split by shape so
70
- // the ref-slot normalizer recurses only into schema positions — never into
71
- // data-bearing keywords (`default`, `const`, `enum`, `examples`), where a stray
72
- // `x-telo-ref` key would be data, not an annotation.
73
- const SUBSCHEMA_SINGLE = [
74
- "additionalProperties",
75
- "additionalItems",
76
- "contains",
77
- "not",
78
- "if",
79
- "then",
80
- "else",
81
- "propertyNames",
82
- "unevaluatedItems",
83
- "unevaluatedProperties",
84
- ];
85
- const SUBSCHEMA_LIST = ["allOf", "anyOf", "oneOf", "prefixItems"];
86
- const SUBSCHEMA_MAP = [
87
- "properties",
88
- "patternProperties",
89
- "$defs",
90
- "definitions",
91
- "dependentSchemas",
92
- ];
93
- /** Deep-clone `schema`, dropping the stale scalar `type` constraint from every
94
- * reference-slot node — one carrying an `x-telo-ref` string annotation.
95
- *
96
- * A reference slot's value is always a `!ref` sentinel or its resolved
97
- * `{kind, name, alias?}` object (never a bare string, post-migration). Older
98
- * published modules still pin `type: "string"` on these slots — the encoding
99
- * references took when they were written as plain strings — which now rejects
100
- * the resolved object. Removing only the scalar `type` lets the analyzer and
101
- * kernel accept references uniformly across module versions during the
102
- * migration away from `{kind, name}` / string references, without disturbing
103
- * slots that legitimately accept an inline object (e.g. `inputType` /
104
- * `outputType`, which take a Telo.Type reference *or* an inline JSON schema).
105
- * The `x-telo-ref` constraint itself (which kind the reference must satisfy) is
106
- * checked separately by the analyzer's reference walker, which reads the
107
- * original schema — not this validation-only copy. */
108
- export function normalizeRefSlots(schema) {
109
- if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
110
- return schema;
111
- }
112
- const node = schema;
113
- const out = { ...node };
114
- // Reference slot with a stale scalar `type` (legacy string-ref encoding):
115
- // drop the constraint so the resolved reference object / sentinel validates.
116
- //
117
- // A presence test, not a shape test — deliberately, since `templating` sits
118
- // BELOW the analyzer in the dependency order and cannot reach the shared
119
- // `readRefSlot` accessor. Presence is the only thing this rule needs, and it
120
- // is stable across every annotation shape.
121
- if (node[REF_ANNOTATION] !== undefined &&
122
- typeof node.type === "string" &&
123
- LEGACY_REF_SCALAR_TYPES.has(node.type)) {
124
- delete out.type;
125
- }
126
- for (const key of SUBSCHEMA_SINGLE) {
127
- const value = node[key];
128
- if (value && typeof value === "object" && !Array.isArray(value)) {
129
- out[key] = normalizeRefSlots(value);
130
- }
131
- }
132
- for (const key of SUBSCHEMA_LIST) {
133
- const value = node[key];
134
- if (Array.isArray(value))
135
- out[key] = value.map(normalizeRefSlots);
136
- }
137
- // `items` is either a single subschema or a tuple of subschemas.
138
- if (Array.isArray(node.items)) {
139
- out.items = node.items.map(normalizeRefSlots);
140
- }
141
- else if (node.items && typeof node.items === "object") {
142
- out.items = normalizeRefSlots(node.items);
143
- }
144
- for (const key of SUBSCHEMA_MAP) {
145
- const value = node[key];
146
- if (value && typeof value === "object" && !Array.isArray(value)) {
147
- const mapped = {};
148
- for (const [name, sub] of Object.entries(value)) {
149
- mapped[name] = normalizeRefSlots(sub);
150
- }
151
- out[key] = mapped;
152
- }
153
- }
154
- return out;
155
- }
156
61
  /** Stable URI under which the shared manifest root schema is registered
157
62
  * with module-side AJV instances. Module YAMLs reach the fragments via
158
63
  * `$ref: "telo://manifest#/$defs/<Name>"`. The URI is the contract;
@@ -19,4 +19,41 @@ export declare function makeTaggedSentinel(engine: string, source: string): Tagg
19
19
  export declare function isRefSentinel(v: unknown): v is TaggedSentinel & {
20
20
  engine: "ref";
21
21
  };
22
+ /** The CEL engine's name. Beside the other sentinel predicates for the same
23
+ * reason they are: a consumer that spells an engine name inline is a second
24
+ * place the registry's key is written down. */
25
+ export declare const CEL_ENGINE = "cel";
26
+ /** Engine names of the two file-embedding tags. Named here beside the other
27
+ * sentinel predicates so the kernel's resolution pass and the engines
28
+ * themselves agree on one spelling. */
29
+ export declare const INCLUDE_TEXT_ENGINE = "include-text";
30
+ export declare const INCLUDE_BYTES_ENGINE = "include-bytes";
31
+ /**
32
+ * The dotted chain a value names, or undefined.
33
+ *
34
+ * Only a PLAIN CHAIN — `steps.encode.result.output` — in either spelling a
35
+ * manifest may carry it: a `!cel` sentinel or the `${{ }}` string form. An
36
+ * expression that COMPUTES rather than names has no schema to read off a context,
37
+ * so a caller that navigates one gets nothing and reports nothing: silence where
38
+ * the analyzer knows least is the conservative direction.
39
+ *
40
+ * Here rather than in each caller because "is this expression a plain chain" is
41
+ * one question, and two copies of the answer would eventually disagree about a
42
+ * shape like `a.b[0]`.
43
+ */
44
+ export declare function plainChainOf(value: unknown): string | undefined;
45
+ /** Both file-embedding tag names, for a consumer holding an engine NAME rather
46
+ * than a value (the analyzer's expression walk reports names). */
47
+ export declare const INCLUDE_ENGINE_NAMES: ReadonlySet<string>;
48
+ /** True when `v` is an `!include-text` / `!include-bytes` sentinel — a file
49
+ * embed marked at parse time and still unresolved.
50
+ *
51
+ * The kernel's creation-time resolution keys off this the way Phase-5
52
+ * injection keys off {@link isRefSentinel}. Both tags survive precompile as
53
+ * markers rather than collapsing to a value, because the read is deferred: a
54
+ * manifest load must not pull payload layers, and the analyzer that types the
55
+ * slot cannot open files at all. */
56
+ export declare function isIncludeSentinel(v: unknown): v is TaggedSentinel & {
57
+ engine: typeof INCLUDE_TEXT_ENGINE | typeof INCLUDE_BYTES_ENGINE;
58
+ };
22
59
  //# sourceMappingURL=sentinel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sentinel.d.ts","sourceRoot":"","sources":["../src/sentinel.ts"],"names":[],"mappings":"AAAA;;;;;YAKY;AACZ,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,cAAc,CAQhE;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,cAAc,CAEjF;AAED;;;;4CAI4C;AAC5C,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,cAAc,GAAG;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,CAEjF"}
1
+ {"version":3,"file":"sentinel.d.ts","sourceRoot":"","sources":["../src/sentinel.ts"],"names":[],"mappings":"AAAA;;;;;YAKY;AACZ,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,cAAc,CAQhE;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,cAAc,CAEjF;AAED;;;;4CAI4C;AAC5C,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,cAAc,GAAG;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,CAEjF;AAED;;gDAEgD;AAChD,eAAO,MAAM,UAAU,QAAQ,CAAC;AAEhC;;wCAEwC;AACxC,eAAO,MAAM,mBAAmB,iBAAiB,CAAC;AAClD,eAAO,MAAM,oBAAoB,kBAAkB,CAAC;AAEpD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAa/D;AAED;mEACmE;AACnE,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAGnD,CAAC;AAEH;;;;;;;qCAOqC;AACrC,wBAAgB,iBAAiB,CAC/B,CAAC,EAAE,OAAO,GACT,CAAC,IAAI,cAAc,GAAG;IAAE,MAAM,EAAE,OAAO,mBAAmB,GAAG,OAAO,oBAAoB,CAAA;CAAE,CAI5F"}
package/dist/sentinel.js CHANGED
@@ -16,3 +16,57 @@ export function makeTaggedSentinel(engine, source) {
16
16
  export function isRefSentinel(v) {
17
17
  return isTaggedSentinel(v) && v.engine === "ref";
18
18
  }
19
+ /** The CEL engine's name. Beside the other sentinel predicates for the same
20
+ * reason they are: a consumer that spells an engine name inline is a second
21
+ * place the registry's key is written down. */
22
+ export const CEL_ENGINE = "cel";
23
+ /** Engine names of the two file-embedding tags. Named here beside the other
24
+ * sentinel predicates so the kernel's resolution pass and the engines
25
+ * themselves agree on one spelling. */
26
+ export const INCLUDE_TEXT_ENGINE = "include-text";
27
+ export const INCLUDE_BYTES_ENGINE = "include-bytes";
28
+ /**
29
+ * The dotted chain a value names, or undefined.
30
+ *
31
+ * Only a PLAIN CHAIN — `steps.encode.result.output` — in either spelling a
32
+ * manifest may carry it: a `!cel` sentinel or the `${{ }}` string form. An
33
+ * expression that COMPUTES rather than names has no schema to read off a context,
34
+ * so a caller that navigates one gets nothing and reports nothing: silence where
35
+ * the analyzer knows least is the conservative direction.
36
+ *
37
+ * Here rather than in each caller because "is this expression a plain chain" is
38
+ * one question, and two copies of the answer would eventually disagree about a
39
+ * shape like `a.b[0]`.
40
+ */
41
+ export function plainChainOf(value) {
42
+ const source = isTaggedSentinel(value)
43
+ ? value.engine === CEL_ENGINE
44
+ ? value.source
45
+ : undefined
46
+ : typeof value === "string"
47
+ ? /^\s*\$\{\{(.+)\}\}\s*$/.exec(value)?.[1]
48
+ : undefined;
49
+ if (typeof source !== "string")
50
+ return undefined;
51
+ const trimmed = source.trim();
52
+ return /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(trimmed)
53
+ ? trimmed
54
+ : undefined;
55
+ }
56
+ /** Both file-embedding tag names, for a consumer holding an engine NAME rather
57
+ * than a value (the analyzer's expression walk reports names). */
58
+ export const INCLUDE_ENGINE_NAMES = new Set([
59
+ INCLUDE_TEXT_ENGINE,
60
+ INCLUDE_BYTES_ENGINE,
61
+ ]);
62
+ /** True when `v` is an `!include-text` / `!include-bytes` sentinel — a file
63
+ * embed marked at parse time and still unresolved.
64
+ *
65
+ * The kernel's creation-time resolution keys off this the way Phase-5
66
+ * injection keys off {@link isRefSentinel}. Both tags survive precompile as
67
+ * markers rather than collapsing to a value, because the read is deferred: a
68
+ * manifest load must not pull payload layers, and the analyzer that types the
69
+ * slot cannot open files at all. */
70
+ export function isIncludeSentinel(v) {
71
+ return (isTaggedSentinel(v) && (v.engine === INCLUDE_TEXT_ENGINE || v.engine === INCLUDE_BYTES_ENGINE));
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/templating",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Telo Templating - Engine registry and shared CEL core for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -44,7 +44,7 @@
44
44
  "@types/node": "^20.0.0",
45
45
  "typescript": "^5.0.0",
46
46
  "vitest": "^2.1.8",
47
- "@telorun/sdk": "0.67.0"
47
+ "@telorun/sdk": "0.73.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@telorun/sdk": "*"
package/src/builtins.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { celEngine } from "./engines/cel.js";
2
+ import { includeBytesEngine, includeTextEngine } from "./engines/include.js";
2
3
  import { literalEngine } from "./engines/literal.js";
3
4
  import { refEngine } from "./engines/ref.js";
4
5
  import { sqlEngine } from "./engines/sql.js";
@@ -13,6 +14,8 @@ import type { TemplatingEngine } from "./engine.js";
13
14
  * another (e.g. `cel + literal`); always ship the same set. */
14
15
  export const builtinEngines: readonly TemplatingEngine[] = [
15
16
  celEngine,
17
+ includeBytesEngine,
18
+ includeTextEngine,
16
19
  literalEngine,
17
20
  refEngine,
18
21
  sqlEngine,
@@ -26,6 +29,20 @@ export function createDefaultRegistry(): TemplatingEngineRegistry {
26
29
  return registry;
27
30
  }
28
31
 
32
+ /**
33
+ * The type a tag always produces, or undefined when its produced type is a
34
+ * function of the slot rather than of the tag (`!cel`, `!ref`).
35
+ *
36
+ * The single reader of `TemplatingEngine.producedType`, so a consumer asks the
37
+ * registry what a tag produces instead of recognising tag names — the same seam
38
+ * `fileClaims` opened for payload membership. Reads the default registry
39
+ * because a produced type is a property of the engine, not of a host's
40
+ * configuration, and every host ships the same built-in set.
41
+ */
42
+ export function producedTypeOf(engineName: string): Record<string, unknown> | undefined {
43
+ return defaultRegistry().get(engineName)?.producedType?.();
44
+ }
45
+
29
46
  let defaultRegistryCache: TemplatingEngineRegistry | undefined;
30
47
 
31
48
  /** Memoized singleton: returns the default registry. Hosts that don't need
@@ -1,4 +1,5 @@
1
1
  import type { ASTNode } from "@marcbachmann/cel-js";
2
+ import { isLiveSlot } from "@telorun/sdk";
2
3
 
3
4
  /**
4
5
  * Extract all member-access chains from a CEL AST.
@@ -291,7 +292,8 @@ function walkNullable(
291
292
  * Check whether a member-access chain accesses only fields declared in a JSON Schema.
292
293
  * Returns an error string if a field is unknown in a schema that declares explicit
293
294
  * properties without `additionalProperties: true`, or if the chain attempts to
294
- * reach inside an `x-telo-stream: true` property.
295
+ * reach inside a `live` value type — one whose consumption has effects, so its
296
+ * contents exist only for a consumer that drains it.
295
297
  * Returns null when the chain is valid or the schema is too open to judge.
296
298
  */
297
299
  export function validateChainAgainstSchema(
@@ -306,12 +308,7 @@ export function validateChainAgainstSchema(
306
308
  if (!props) return null;
307
309
  if (key in props) {
308
310
  const propSchema = props[key];
309
- if (
310
- propSchema &&
311
- typeof propSchema === "object" &&
312
- propSchema["x-telo-stream"] === true &&
313
- i < chain.length - 1
314
- ) {
311
+ if (isLiveSlot(propSchema) && i < chain.length - 1) {
315
312
  const path = chain.slice(0, i + 1).join(".");
316
313
  return `'${path}' yields a stream — pipe it through an Encoder or iterate in a JS.Script step (no member access on stream-typed values)`;
317
314
  }
@@ -613,6 +613,33 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
613
613
  hostBacked: false,
614
614
  build: () => () => BigInt(Math.floor(Date.now() / 1000)),
615
615
  },
616
+ // Timestamp conversions. cel-go's standard library defines both and cel-js
617
+ // ships neither, which is what made an instant a one-way door: timestamp
618
+ // arithmetic and the `getFullYear` / `getHours` family already work, but
619
+ // nothing converted the result back into a value a manifest field accepts,
620
+ // so an expiry could be computed and not stored. Semantics follow cel-go
621
+ // exactly — RFC 3339 and epoch SECONDS — so `int(timestamp)` and
622
+ // `timestamp(int)` round-trip in one unit.
623
+ {
624
+ name: "string",
625
+ signature: "string(timestamp): string",
626
+ register: ["string(google.protobuf.Timestamp): string"],
627
+ category: "conversion",
628
+ summary: "Format an instant as RFC 3339 (ISO-8601, UTC).",
629
+ deterministic: true,
630
+ hostBacked: false,
631
+ build: () => (t: Date) => t.toISOString(),
632
+ },
633
+ {
634
+ name: "int",
635
+ signature: "int(timestamp): int",
636
+ register: ["int(google.protobuf.Timestamp): int"],
637
+ category: "conversion",
638
+ summary: "Epoch seconds of an instant (the unit `timestamp(int)` reads back).",
639
+ deterministic: true,
640
+ hostBacked: false,
641
+ build: () => (t: Date) => BigInt(Math.floor(t.getTime() / 1000)),
642
+ },
616
643
  // UUID
617
644
  {
618
645
  name: "uuidv1",
@@ -0,0 +1,293 @@
1
+ import type { ASTNode, Environment } from "@marcbachmann/cel-js";
2
+ import { CEL_FUNCTIONS } from "./catalog.js";
3
+ import type { CallSite, DiagnosticFix, EngineDiagnostic } from "../engine.js";
4
+
5
+ /** Classifies every function call in a CEL expression against the environment's
6
+ * own function registry.
7
+ *
8
+ * This exists because cel-js reports one sentence for three unrelated mistakes
9
+ * — a name that does not exist, a name called in the wrong form, and a genuine
10
+ * type mismatch all surface as `found no matching overload for 'f(...)'`. Two
11
+ * of those readings actively mislead: the message names argument types, so the
12
+ * repair for `startsWith(key, 'x')` looks like a cast, when the real fix is
13
+ * `key.startsWith('x')` and no cast helps.
14
+ *
15
+ * Nothing here reads cel-js's message text. `Environment.getDefinitions()`
16
+ * reports every registered signature — cel-js builtins and Telo's catalog
17
+ * alike — with its call form and parameters, and the AST distinguishes `f(x)`
18
+ * from `x.f()` structurally. Name existence, call form and arity are therefore
19
+ * decidable by lookup, which is what keeps a cel-js version bump from silently
20
+ * degrading this back into the passthrough it replaced. */
21
+
22
+ /** One registered signature, reduced to what classification needs. */
23
+ interface FnEntry {
24
+ readonly signature: string;
25
+ readonly form: "global" | "receiver";
26
+ /** Parameter count, excluding the receiver for a receiver form. */
27
+ readonly arity: number;
28
+ }
29
+
30
+ export interface FunctionIndex {
31
+ readonly byName: ReadonlyMap<string, readonly FnEntry[]>;
32
+ }
33
+
34
+ /** Determinism is Telo catalog metadata; cel-js builtins carry none. Absent
35
+ * means "no signal", never "deterministic" — consumers of
36
+ * `CallSite.deterministic` must not read undefined as a guarantee. */
37
+ const DETERMINISM: ReadonlyMap<string, boolean> = new Map(
38
+ CEL_FUNCTIONS.map((f) => [f.name, f.deterministic]),
39
+ );
40
+
41
+ const INDEX_CACHE = new WeakMap<Environment, FunctionIndex>();
42
+
43
+ /** Registry view of an environment, memoized: environments are rebuilt per
44
+ * analysis path, but each is immutable once built. */
45
+ export function functionIndex(env: Environment): FunctionIndex {
46
+ const cached = INDEX_CACHE.get(env);
47
+ if (cached) return cached;
48
+
49
+ const byName = new Map<string, FnEntry[]>();
50
+
51
+ for (const fn of env.getDefinitions().functions) {
52
+ const form = fn.receiverType === null ? "global" : "receiver";
53
+ const entry: FnEntry = { signature: fn.signature, form, arity: fn.params?.length ?? 0 };
54
+ const entries = byName.get(fn.name);
55
+ if (entries) entries.push(entry);
56
+ else byName.set(fn.name, [entry]);
57
+ }
58
+
59
+ const index: FunctionIndex = { byName };
60
+ INDEX_CACHE.set(env, index);
61
+ return index;
62
+ }
63
+
64
+ /** A call plus the nodes needed to rewrite it. Internal — `CallSite` is the
65
+ * shape that crosses the engine seam. */
66
+ interface RawCall extends CallSite {
67
+ readonly receiver?: ASTNode;
68
+ readonly args: readonly ASTNode[];
69
+ }
70
+
71
+ /** Macros the parser expands rather than dispatching through the registry, so
72
+ * they never appear in `getDefinitions()` and would otherwise classify as
73
+ * unknown names.
74
+ *
75
+ * Deliberately short: most macros ARE registered, and the caller only reports
76
+ * this audit when the type-checker already rejected the expression, so a macro
77
+ * missing from here degrades to "no extra explanation" rather than to a false
78
+ * error on valid CEL. That is what keeps a cel-js upgrade from turning a new
79
+ * macro into a manifest this analyzer refuses. */
80
+ const MACROS = new Set(["optMap", "optFlatMap"]);
81
+
82
+ function isNode(v: unknown): v is ASTNode {
83
+ return typeof v === "object" && v !== null && "op" in (v as Record<string, unknown>);
84
+ }
85
+
86
+ /** Every non-macro call in the expression, in source order. */
87
+ function collectCalls(root: ASTNode, index: FunctionIndex): RawCall[] {
88
+ const out: RawCall[] = [];
89
+ visit(root);
90
+ return out.sort((a, b) => a.start - b.start);
91
+
92
+ function visit(node: ASTNode): void {
93
+ const args = node.args as unknown;
94
+ if (node.op === "call" || node.op === "rcall") {
95
+ const tuple = args as unknown[];
96
+ const name = tuple[0];
97
+ if (typeof name === "string" && !MACROS.has(name)) {
98
+ const receiver = node.op === "rcall" ? tuple[1] : undefined;
99
+ const rawArgs = node.op === "rcall" ? tuple[2] : tuple[1];
100
+ const callArgs = (Array.isArray(rawArgs) ? rawArgs : []).filter(isNode);
101
+ out.push({
102
+ name,
103
+ form: node.op === "rcall" ? "receiver" : "global",
104
+ arity: callArgs.length,
105
+ start: node.start,
106
+ end: node.end,
107
+ ...(index.byName.has(name) ? { deterministic: DETERMINISM.get(name) } : {}),
108
+ ...(isNode(receiver) ? { receiver } : {}),
109
+ args: callArgs,
110
+ });
111
+ }
112
+ }
113
+ for (const arg of Array.isArray(args) ? args : [args]) {
114
+ if (isNode(arg)) visit(arg);
115
+ else if (Array.isArray(arg)) for (const item of arg) if (isNode(item)) visit(item);
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Node shapes that can carry a `.` on their right without reparsing
121
+ * differently. Everything else — an operator expression, a literal that would
122
+ * sit against the dot — is parenthesized when moved into receiver position. */
123
+ const SELF_DELIMITING = new Set<string>(["id", ".", ".?", "call", "rcall", "[]", "[?]"]);
124
+
125
+ /** Source text of a node. When it is about to become a receiver it may need
126
+ * parentheses: an identifier, member chain, index or call is self-delimiting,
127
+ * but an operator expression or a bare literal against a `.` is not. */
128
+ function nodeText(source: string, node: ASTNode, asReceiver = false): string {
129
+ const text = source.slice(node.start, node.end);
130
+ if (!asReceiver) return text;
131
+ return SELF_DELIMITING.has(node.op) ? text : `(${text})`;
132
+ }
133
+
134
+ /** The same call written in the other form, or undefined when the shape does
135
+ * not allow it (a global call with no arguments has no receiver to move). */
136
+ function transpose(source: string, call: RawCall): string | undefined {
137
+ if (call.form === "global") {
138
+ const [first, ...rest] = call.args;
139
+ if (!first) return undefined;
140
+ const argText = rest.map((a) => nodeText(source, a));
141
+ return `${nodeText(source, first, true)}.${call.name}(${argText.join(", ")})`;
142
+ }
143
+ if (!call.receiver) return undefined;
144
+ const argText = [nodeText(source, call.receiver), ...call.args.map((a) => nodeText(source, a))];
145
+ return `${call.name}(${argText.join(", ")})`;
146
+ }
147
+
148
+ /** Splice a rewritten call back into the full source. The fix always carries
149
+ * the whole corrected source, so a consumer applies it by replacing the
150
+ * scalar. */
151
+ function spliceFix(source: string, call: RawCall, rewritten: string): DiagnosticFix {
152
+ return { replacement: source.slice(0, call.start) + rewritten + source.slice(call.end) };
153
+ }
154
+
155
+ /** Replace only the called name, leaving arguments untouched. The offset is
156
+ * derived rather than searched: a receiver whose own text contains the name
157
+ * (`slice.slice(1)`) would defeat a first-occurrence replace. */
158
+ function renameFix(source: string, call: RawCall, to: string): DiagnosticFix | undefined {
159
+ const searchFrom = call.receiver ? call.receiver.end : call.start;
160
+ const at = source.indexOf(call.name, searchFrom);
161
+ if (at === -1 || at >= call.end) return undefined;
162
+ return { replacement: source.slice(0, at) + to + source.slice(at + call.name.length) };
163
+ }
164
+
165
+ /** Arity the call would need in the other form: moving a receiver in adds an
166
+ * argument, moving it out removes one. */
167
+ function transposedArity(call: RawCall): number {
168
+ return call.form === "global" ? call.arity - 1 : call.arity + 1;
169
+ }
170
+
171
+ function accepts(entries: readonly FnEntry[], form: CallSite["form"], arity: number): boolean {
172
+ return entries.some((e) => e.form === form && e.arity === arity);
173
+ }
174
+
175
+ const listOf = (names: readonly string[]): string => [...new Set(names)].sort().join(", ");
176
+
177
+ /** Names registered in exactly the form and arity the author wrote — the only
178
+ * ones that could replace this call with no further edits. Ranked by shared
179
+ * prefix, which is what reaches `nowIso` from `now`; edit distance never
180
+ * would (3 characters against a 3-character name). Returns [] when nothing
181
+ * shares a prefix, so the caller lists what is legal here instead of
182
+ * guessing. */
183
+ function candidates(call: RawCall, index: FunctionIndex): string[] {
184
+ const written = call.name.toLowerCase();
185
+ return callableHere(call, index)
186
+ .filter((name) => {
187
+ const lower = name.toLowerCase();
188
+ return lower.startsWith(written) || written.startsWith(lower);
189
+ })
190
+ .sort((a, b) => a.length - b.length || a.localeCompare(b));
191
+ }
192
+
193
+ /** Every name callable in exactly the position written — same form, same
194
+ * argument count. Arity is what makes the list usable: a receiver's type is
195
+ * `dyn` at analysis time so every method is nominally reachable, and printing
196
+ * all forty says nothing. */
197
+ function callableHere(call: RawCall, index: FunctionIndex): string[] {
198
+ const names: string[] = [];
199
+ for (const [name, entries] of index.byName) {
200
+ if (name !== call.name && accepts(entries, call.form, call.arity)) names.push(name);
201
+ }
202
+ return names;
203
+ }
204
+
205
+ function signaturesOf(name: string, index: FunctionIndex): string[] {
206
+ return (index.byName.get(name) ?? []).map((e) => e.signature);
207
+ }
208
+
209
+ /** Spread-friendly optional `fix`, so an undecidable rewrite simply omits the
210
+ * field rather than carrying `undefined` into the diagnostic. */
211
+ const withFix = (fix: DiagnosticFix | undefined): { fix?: DiagnosticFix } => (fix ? { fix } : {});
212
+
213
+ export interface CallAudit {
214
+ readonly diagnostics: readonly EngineDiagnostic[];
215
+ readonly calls: readonly CallSite[];
216
+ /** Names that resolve, but that no registered signature accepts as written.
217
+ * The caller appends their signatures to a type-check failure it could not
218
+ * otherwise explain. */
219
+ readonly unresolved: readonly string[];
220
+ }
221
+
222
+ /** Classify every call in `ast`. Runs unconditionally rather than only after a
223
+ * failed type-check, because a type-check reports its first error and stops:
224
+ * an expression with two bad calls would otherwise fix one, re-run, and
225
+ * discover the next. */
226
+ export function auditCalls(source: string, ast: ASTNode, env: Environment): CallAudit {
227
+ const index = functionIndex(env);
228
+ const diagnostics: EngineDiagnostic[] = [];
229
+ const unresolved: string[] = [];
230
+ const calls = collectCalls(ast, index);
231
+
232
+ for (const call of calls) {
233
+ const entries = index.byName.get(call.name);
234
+ const noun = call.form === "receiver" ? "method" : "function";
235
+
236
+ if (!entries) {
237
+ const near = candidates(call, index);
238
+ const hint =
239
+ near.length > 0
240
+ ? `Closest taking ${call.arity} argument${call.arity === 1 ? "" : "s"}: ${near
241
+ .slice(0, 5)
242
+ .map((n) => `\`${n}\``)
243
+ .join(", ")}.`
244
+ : `Every ${noun} taking ${call.arity} argument${call.arity === 1 ? "" : "s"}: ${listOf(callableHere(call, index))}.`;
245
+ diagnostics.push({
246
+ code: "CEL_UNKNOWN_FUNCTION",
247
+ message: `there is no ${noun} \`${call.name}\`. ${hint} Full list: \`telo cel functions\`.`,
248
+ // A single candidate is an unambiguous rename; several are a menu, and
249
+ // applying an arbitrary one would be a guess wearing a fix's clothes.
250
+ ...(near.length === 1 ? withFix(renameFix(source, call, near[0]!)) : {}),
251
+ });
252
+ continue;
253
+ }
254
+
255
+ if (accepts(entries, call.form, call.arity)) continue;
256
+
257
+ const otherForm = call.form === "global" ? "receiver" : "global";
258
+ if (accepts(entries, otherForm, transposedArity(call))) {
259
+ const rewritten = transpose(source, call);
260
+ const written = source.slice(call.start, call.end);
261
+ diagnostics.push({
262
+ code: "CEL_WRONG_CALL_FORM",
263
+ message:
264
+ `\`${call.name}\` is ${
265
+ call.form === "global"
266
+ ? "a method, not a global function — call it on the value"
267
+ : "a global function, not a method — pass the value to it"
268
+ }:` +
269
+ (rewritten ? `\n write: ${rewritten}\n not: ${written}` : "") +
270
+ `\nRegistered: ${listOf(signaturesOf(call.name, index))}.`,
271
+ ...withFix(rewritten ? spliceFix(source, call, rewritten) : undefined),
272
+ });
273
+ continue;
274
+ }
275
+
276
+ unresolved.push(call.name);
277
+ }
278
+
279
+ return {
280
+ diagnostics,
281
+ calls: calls.map(({ receiver: _receiver, args: _args, ...site }) => site),
282
+ unresolved,
283
+ };
284
+ }
285
+
286
+ /** Registered signatures for names a type-check failure mentions, so the
287
+ * residual says what the function actually accepts rather than only echoing
288
+ * what the author wrote. */
289
+ export function explainUnresolved(names: readonly string[], env: Environment): string {
290
+ const index = functionIndex(env);
291
+ const signatures = [...new Set(names)].flatMap((n) => signaturesOf(n, index));
292
+ return signatures.length > 0 ? ` Registered: ${listOf(signatures)}.` : "";
293
+ }
@@ -71,13 +71,29 @@ export function deriveSignatures(signature: string): string[] {
71
71
  });
72
72
  }
73
73
 
74
+ /** cel-go defaults HomogeneousAggregateLiterals OFF: heterogeneous list/map
75
+ * literals unify to `dyn` rather than erroring. cel-js flips that default to
76
+ * strict; we align with cel-go so manifests (dyn-heavy: request, rows, …)
77
+ * don't hit false positives the runtime evaluates fine. */
78
+ const ENVIRONMENT_OPTIONS = {
79
+ unlistedVariablesAreDyn: true,
80
+ enableOptionalTypes: true,
81
+ homogeneousAggregateLiterals: false,
82
+ } as const;
83
+
84
+ /** The environment before any Telo function is registered — i.e. exactly
85
+ * cel-js's own built-ins. Documentation needs to tell the two apart, and
86
+ * subtracting by signature TEXT does not work: cel-js normalizes a declared
87
+ * `list` to `list<dyn>`, so the catalog's documented spelling and the
88
+ * registered one differ for a third of the entries. Asking for the base set
89
+ * directly needs no matching at all. */
90
+ export function celBuiltinFunctions(): ReturnType<Environment["getDefinitions"]>["functions"] {
91
+ return new Environment(ENVIRONMENT_OPTIONS).getDefinitions().functions;
92
+ }
93
+
74
94
  export function buildCelEnvironment(handlers: Partial<CelHandlers> = {}): Environment {
75
95
  const h: CelHandlers = { ...STUB_HANDLERS, ...handlers };
76
- // cel-go defaults HomogeneousAggregateLiterals OFF: heterogeneous list/map
77
- // literals unify to `dyn` rather than erroring. cel-js flips that default to
78
- // strict; we align with cel-go so manifests (dyn-heavy: request, rows, …)
79
- // don't hit false positives the runtime evaluates fine.
80
- let env = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true, homogeneousAggregateLiterals: false });
96
+ let env = new Environment(ENVIRONMENT_OPTIONS);
81
97
  for (const fn of CEL_FUNCTIONS) {
82
98
  const impl = fn.build(h);
83
99
  // `register` lists one cel-js signature per arity (overloaded functions).