@telorun/templating 0.12.0 → 0.13.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.
- package/README.md +2 -2
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +3 -0
- package/dist/cel/catalog.d.ts.map +1 -1
- package/dist/cel/catalog.js +27 -0
- package/dist/cel/diagnose.d.ts +50 -0
- package/dist/cel/diagnose.d.ts.map +1 -0
- package/dist/cel/diagnose.js +223 -0
- package/dist/cel/environment.d.ts +7 -0
- package/dist/cel/environment.d.ts.map +1 -1
- package/dist/cel/environment.js +19 -5
- package/dist/cel/walk.d.ts +17 -1
- package/dist/cel/walk.d.ts.map +1 -1
- package/dist/cel/walk.js +21 -3
- package/dist/engine.d.ts +91 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engines/cel.d.ts +13 -9
- package/dist/engines/cel.d.ts.map +1 -1
- package/dist/engines/cel.js +86 -28
- package/dist/engines/include.d.ts +25 -0
- package/dist/engines/include.d.ts.map +1 -0
- package/dist/engines/include.js +132 -0
- package/dist/engines/literal.js +1 -1
- package/dist/engines/ref.js +1 -1
- package/dist/engines/sql.d.ts.map +1 -1
- package/dist/engines/sql.js +32 -5
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/sentinel.d.ts +19 -0
- package/dist/sentinel.d.ts.map +1 -1
- package/dist/sentinel.js +22 -0
- package/package.json +2 -2
- package/src/builtins.ts +3 -0
- package/src/cel/catalog.ts +27 -0
- package/src/cel/diagnose.ts +293 -0
- package/src/cel/environment.ts +21 -5
- package/src/cel/walk.ts +37 -4
- package/src/engine.ts +96 -3
- package/src/engines/cel.ts +91 -31
- package/src/engines/include.ts +153 -0
- package/src/engines/literal.ts +1 -1
- package/src/engines/ref.ts +1 -1
- package/src/engines/sql.ts +41 -7
- package/src/index.ts +28 -3
- package/src/sentinel.ts +29 -0
|
@@ -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
|
+
}
|
package/src/cel/environment.ts
CHANGED
|
@@ -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
|
-
|
|
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).
|
package/src/cel/walk.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { isTaggedSentinel } from "../sentinel.js";
|
|
2
2
|
import { TEMPLATE_REGEX } from "./compile.js";
|
|
3
3
|
|
|
4
|
+
/** How an emitted expression sits in the scalar it came from. A repair can be
|
|
5
|
+
* applied by replacing the scalar only when the expression covers all of it;
|
|
6
|
+
* otherwise the literal text around it would be lost.
|
|
7
|
+
*
|
|
8
|
+
* `wrapper` is the delimiter text to restore around a corrected expression —
|
|
9
|
+
* empty for a tagged sentinel (whose scalar *is* the expression), `${{` / `}}`
|
|
10
|
+
* for the legacy interpolation form. Carrying it as data rather than letting
|
|
11
|
+
* each consumer re-derive it is what keeps `${{ … }}` a first-class fix site
|
|
12
|
+
* instead of an exclusion: the two surfaces differ only by these delimiters. */
|
|
13
|
+
export interface CelSurface {
|
|
14
|
+
readonly whole: boolean;
|
|
15
|
+
readonly wrapper?: { readonly prefix: string; readonly suffix: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const OPEN = "${{";
|
|
19
|
+
|
|
4
20
|
/** Walks `value` and emits each templated source segment with its dotted
|
|
5
21
|
* path (e.g. `routes[0].handler.body`) and the engine that owns it.
|
|
6
22
|
*
|
|
@@ -15,15 +31,32 @@ import { TEMPLATE_REGEX } from "./compile.js";
|
|
|
15
31
|
export function walkCelExpressions(
|
|
16
32
|
value: unknown,
|
|
17
33
|
path: string,
|
|
18
|
-
cb: (source: string, path: string, engineName: string) => void,
|
|
34
|
+
cb: (source: string, path: string, engineName: string, surface: CelSurface) => void,
|
|
19
35
|
): void {
|
|
20
36
|
if (isTaggedSentinel(value)) {
|
|
21
|
-
cb(value.source, path, value.engine);
|
|
37
|
+
cb(value.source, path, value.engine, { whole: true });
|
|
22
38
|
return;
|
|
23
39
|
}
|
|
24
40
|
if (typeof value === "string") {
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
const matches = [...value.matchAll(TEMPLATE_REGEX)];
|
|
42
|
+
for (const m of matches) {
|
|
43
|
+
const expr = m[1]!.trim();
|
|
44
|
+
// A string that is nothing but one interpolation is as whole as a
|
|
45
|
+
// tagged scalar — the delimiters are the only difference, and they are
|
|
46
|
+
// handed back as the wrapper.
|
|
47
|
+
const only = matches.length === 1 && m.index === 0 && m[0]!.length === value.length;
|
|
48
|
+
const lead = /^\s*/.exec(m[0]!.slice(OPEN.length))![0]!.length;
|
|
49
|
+
cb(expr, path, "cel", {
|
|
50
|
+
whole: only,
|
|
51
|
+
...(only
|
|
52
|
+
? {
|
|
53
|
+
wrapper: {
|
|
54
|
+
prefix: value.slice(0, OPEN.length + lead),
|
|
55
|
+
suffix: value.slice(OPEN.length + lead + expr.length),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
});
|
|
27
60
|
}
|
|
28
61
|
return;
|
|
29
62
|
}
|
package/src/engine.ts
CHANGED
|
@@ -12,18 +12,95 @@ export interface CompileEnv {
|
|
|
12
12
|
* the path-specific effective context (kernel globals merged in, x-telo-context
|
|
13
13
|
* applied) and hands the engine a single closed schema. The engine validates
|
|
14
14
|
* member-access chains against it. `null` means "open context" — no chain
|
|
15
|
-
* validation possible.
|
|
15
|
+
* validation possible.
|
|
16
|
+
*
|
|
17
|
+
* `celEnv` is the environment **typed for this path**, not the bare base one:
|
|
18
|
+
* the engine type-checks against it, so the caller must not check the same
|
|
19
|
+
* expression again against a different environment. One expression, one
|
|
20
|
+
* verdict. */
|
|
16
21
|
export interface AnalyzeEnv {
|
|
17
22
|
readonly celEnv: Environment;
|
|
18
23
|
readonly contextSchema: Record<string, unknown> | null;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/** A mechanically applicable repair for a diagnostic. `replacement` is the
|
|
27
|
+
* **whole analyzed source**, corrected — never a fragment — so a consumer can
|
|
28
|
+
* apply it by replacing the scalar node without knowing anything about the
|
|
29
|
+
* language inside it.
|
|
30
|
+
*
|
|
31
|
+
* There is deliberately no sub-range narrowing "what changed". Carrying one
|
|
32
|
+
* beside a whole-value replacement offers two readings of the same field, and
|
|
33
|
+
* the minimal-edit reading — splice `replacement` at `range` — produces
|
|
34
|
+
* garbage, since the two measure different strings. No consumer needed it, so
|
|
35
|
+
* the ambiguity bought nothing.
|
|
36
|
+
*
|
|
37
|
+
* Producers emit a fix only when the repair is decidable. A fix that might not
|
|
38
|
+
* compile is worse than none: the field exists so an IDE can apply it without
|
|
39
|
+
* asking, and an agent can take it without re-deriving it from prose. */
|
|
40
|
+
export interface DiagnosticFix {
|
|
41
|
+
readonly replacement: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
/** A single static-analysis finding produced by an engine. Stable codes match
|
|
22
45
|
* the analyzer's existing diagnostic codes so downstream filtering keeps
|
|
23
46
|
* working unchanged across the engine boundary. */
|
|
24
47
|
export interface EngineDiagnostic {
|
|
25
48
|
readonly message: string;
|
|
26
49
|
readonly code?: string;
|
|
50
|
+
readonly fix?: DiagnosticFix;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One function call an engine found in the source it analyzed. Reported
|
|
54
|
+
* regardless of whether the call is valid: consumers apply policy the engine
|
|
55
|
+
* cannot know (an `x-telo-eval: compile` field rejecting a non-deterministic
|
|
56
|
+
* call), and policy that depends on manifest context does not belong in a
|
|
57
|
+
* templating engine. */
|
|
58
|
+
export interface CallSite {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
/** How it was written — `f(x)` vs `x.f()`. */
|
|
61
|
+
readonly form: "global" | "receiver";
|
|
62
|
+
/** Argument count as written; excludes the receiver. */
|
|
63
|
+
readonly arity: number;
|
|
64
|
+
/** Offsets of the whole call within the analyzed source. */
|
|
65
|
+
readonly start: number;
|
|
66
|
+
readonly end: number;
|
|
67
|
+
/** Whether the resolved function re-evaluates per call. `undefined` when the
|
|
68
|
+
* name resolves to nothing, or to a function carrying no determinism
|
|
69
|
+
* metadata — absent is not "deterministic". */
|
|
70
|
+
readonly deterministic?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What one `analyze` call establishes about one source. Everything derivable
|
|
74
|
+
* from the expression alone is derived here, once; everything that needs
|
|
75
|
+
* manifest context (the field's declared type, its eval mode, which verdict
|
|
76
|
+
* outranks which) is left to the caller, which is the only side that has it. */
|
|
77
|
+
export interface AnalyzeResult {
|
|
78
|
+
readonly diagnostics: readonly EngineDiagnostic[];
|
|
79
|
+
/** Type the engine's checker resolved, when it type-checks and succeeded. */
|
|
80
|
+
readonly type?: string;
|
|
81
|
+
/** Every function call in the source, in source order. */
|
|
82
|
+
readonly calls: readonly CallSite[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One module-relative file a tagged node embeds, reported by the engine that
|
|
86
|
+
* owns the tag.
|
|
87
|
+
*
|
|
88
|
+
* `path` is relative to the module root — the directory holding `telo.yaml` —
|
|
89
|
+
* never to the file the tag was written in. That is the rule every other file
|
|
90
|
+
* reference in a manifest already follows (a controller's `path=` qualifier,
|
|
91
|
+
* `files:` / `assets:` patterns), and it is what makes a claim survive publish:
|
|
92
|
+
* publish deletes `include:` and inlines every partial as an extra document
|
|
93
|
+
* into the single published `telo.yaml`, so the declaring file does not exist
|
|
94
|
+
* in the artifact and a per-file-relative path would change meaning there.
|
|
95
|
+
*
|
|
96
|
+
* The path is ALL an engine reports. Which artifact layer the file belongs in
|
|
97
|
+
* is packaging's vocabulary, from a spec this package otherwise knows nothing
|
|
98
|
+
* about, and the analyzer already owns that assignment for controller
|
|
99
|
+
* candidates — so a new layer role stays a change to one package rather than
|
|
100
|
+
* two. An object rather than a bare string so a future hint (eager/lazy, say)
|
|
101
|
+
* costs no consumer a signature change. */
|
|
102
|
+
export interface EngineFileClaim {
|
|
103
|
+
readonly path: string;
|
|
27
104
|
}
|
|
28
105
|
|
|
29
106
|
/** Per-property templating engine. Matches a YAML tag (`!<name>`); the kernel
|
|
@@ -48,6 +125,22 @@ export interface TemplatingEngine {
|
|
|
48
125
|
compile(source: string, env: CompileEnv): CompiledValue | unknown;
|
|
49
126
|
|
|
50
127
|
/** Static analysis hook. Engines that can't statically check (e.g. `literal`)
|
|
51
|
-
* return
|
|
52
|
-
|
|
128
|
+
* return an empty result. The walker accumulates diagnostics across all
|
|
129
|
+
* values and applies its own policy to `calls` / `type`. */
|
|
130
|
+
analyze(source: string, env: AnalyzeEnv): AnalyzeResult;
|
|
131
|
+
|
|
132
|
+
/** Module-relative files this tagged node embeds, if any.
|
|
133
|
+
*
|
|
134
|
+
* The single seam through which payload membership is discovered: publish
|
|
135
|
+
* asks the registry what each tag claims rather than recognising tags by
|
|
136
|
+
* name, so a future tag that embeds files is a one-file change and no
|
|
137
|
+
* consumer downstream grows a second vocabulary for reading a manifest.
|
|
138
|
+
* This is the `ref-slot.ts` / `zone-slot.ts` precedent applied to tags.
|
|
139
|
+
*
|
|
140
|
+
* Optional, and absent on every engine that embeds nothing (`cel`, `ref`,
|
|
141
|
+
* `literal`, `sql`). Pure string work over the source — it must never read
|
|
142
|
+
* the filesystem, because the analyzer that calls it runs in the browser.
|
|
143
|
+
* A source the engine considers malformed claims nothing; `analyze` is what
|
|
144
|
+
* reports why. */
|
|
145
|
+
fileClaims?(source: string): readonly EngineFileClaim[];
|
|
53
146
|
}
|
package/src/engines/cel.ts
CHANGED
|
@@ -4,54 +4,114 @@ import {
|
|
|
4
4
|
validateChainAgainstSchema,
|
|
5
5
|
} from "../cel/analyze.js";
|
|
6
6
|
import { compileExpression } from "../cel/compile.js";
|
|
7
|
-
import
|
|
7
|
+
import { auditCalls, explainUnresolved } from "../cel/diagnose.js";
|
|
8
|
+
import type { AnalyzeEnv, AnalyzeResult, EngineDiagnostic, TemplatingEngine } from "../engine.js";
|
|
8
9
|
|
|
9
10
|
/** Statically analyze one CEL expression against the effective context schema:
|
|
10
|
-
* parse →
|
|
11
|
-
* access. Single source of truth shared by the `!cel` engine
|
|
12
|
-
* and the `!sql` engine (one per `${{ }}` interpolation), so
|
|
13
|
-
* can't drift between them.
|
|
14
|
-
|
|
11
|
+
* parse → classify every call → type-check → validate member-access chains →
|
|
12
|
+
* flag nullable access. Single source of truth shared by the `!cel` engine
|
|
13
|
+
* (one expression) and the `!sql` engine (one per `${{ }}` interpolation), so
|
|
14
|
+
* diagnostic wording can't drift between them.
|
|
15
|
+
*
|
|
16
|
+
* The type-check lives here, not in the analyzer, so one expression produces
|
|
17
|
+
* one verdict against one environment. Splitting them let an opaque
|
|
18
|
+
* "no matching overload" survive next to the diagnostic that actually
|
|
19
|
+
* explained it, and left `${{ }}` interpolations chain-validated but never
|
|
20
|
+
* type-checked at all. */
|
|
21
|
+
export function analyzeCelExpression(source: string, env: AnalyzeEnv): AnalyzeResult {
|
|
15
22
|
const out: EngineDiagnostic[] = [];
|
|
16
23
|
|
|
17
24
|
let parsed: ReturnType<typeof env.celEnv.parse>;
|
|
18
25
|
try {
|
|
19
26
|
parsed = env.celEnv.parse(source);
|
|
20
27
|
} catch (e) {
|
|
21
|
-
|
|
22
|
-
code: "CEL_SYNTAX_ERROR",
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
return out;
|
|
28
|
+
return {
|
|
29
|
+
diagnostics: [{ code: "CEL_SYNTAX_ERROR", message: e instanceof Error ? e.message : String(e) }],
|
|
30
|
+
calls: [],
|
|
31
|
+
};
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
const audit = auditCalls(source, parsed.ast, env.celEnv);
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
let type: string | undefined;
|
|
37
|
+
let checkError: string | undefined;
|
|
38
|
+
try {
|
|
39
|
+
const result = env.celEnv.check(source);
|
|
40
|
+
if (result.valid) type = result.type;
|
|
41
|
+
else if (result.error) {
|
|
42
|
+
checkError = String((result.error as { message?: string }).message ?? result.error)
|
|
43
|
+
.split("\n")[0]!
|
|
44
|
+
.trim();
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// The checker is now the ONLY type verdict for every CEL expression, so a
|
|
48
|
+
// crash here silently retires static typing for that expression. Report it
|
|
49
|
+
// instead: degrading is acceptable, degrading invisibly is not.
|
|
50
|
+
return {
|
|
51
|
+
diagnostics: [
|
|
52
|
+
{
|
|
53
|
+
code: "CEL_TYPE_ERROR",
|
|
54
|
+
message: `the CEL type-checker failed on this expression: ${
|
|
55
|
+
e instanceof Error ? e.message : String(e)
|
|
56
|
+
}`,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
calls: audit.calls,
|
|
60
|
+
};
|
|
34
61
|
}
|
|
35
62
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
// The audit only ever EXPLAINS a rejection — it never overrules acceptance.
|
|
64
|
+
// Its classification is decided from the registry, so a call cel-js accepts
|
|
65
|
+
// but the registry cannot account for (a macro the parser expands and the
|
|
66
|
+
// registry never sees, which a cel-js upgrade can introduce at any time) must
|
|
67
|
+
// not become a hard error on valid CEL. Reporting nothing where cel-js is
|
|
68
|
+
// happy makes an unknown future macro a silent no-op rather than a manifest
|
|
69
|
+
// this analyzer refuses and the kernel would run fine.
|
|
70
|
+
if (checkError !== undefined) {
|
|
71
|
+
// `check()` stops at its first problem; the audit enumerates every bad call,
|
|
72
|
+
// which is the whole reason it exists as more than a message rewriter.
|
|
73
|
+
out.push(...audit.diagnostics);
|
|
74
|
+
if (audit.diagnostics.length === 0) {
|
|
75
|
+
out.push({
|
|
76
|
+
code: "CEL_TYPE_ERROR",
|
|
77
|
+
message: checkError + explainUnresolved(audit.unresolved, env.celEnv) + DYN_HINT(checkError),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
47
80
|
}
|
|
48
|
-
|
|
81
|
+
|
|
82
|
+
if (env.contextSchema) {
|
|
83
|
+
const contextSchema = env.contextSchema as Record<string, any>;
|
|
84
|
+
for (const chain of extractAccessChains(parsed.ast)) {
|
|
85
|
+
const err = validateChainAgainstSchema(chain, contextSchema);
|
|
86
|
+
if (err) out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const issue of findNullableAccessIssues(parsed.ast, contextSchema)) {
|
|
90
|
+
// Index access (member "[index]") attaches without a dot; a named field
|
|
91
|
+
// attaches with one — so the suggested CEL stays valid either way.
|
|
92
|
+
const access = issue.member === "[index]" ? issue.member : `.${issue.member}`;
|
|
93
|
+
out.push({
|
|
94
|
+
code: "CEL_NULLABLE_ACCESS",
|
|
95
|
+
message: `'${issue.path}' may be null — guard it (e.g. '${issue.path} != null && …' or '${issue.path} == null ? … : ${issue.path}${access}') before accessing '${access}'`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { diagnostics: out, calls: audit.calls, ...(type === undefined ? {} : { type }) };
|
|
49
101
|
}
|
|
50
102
|
|
|
103
|
+
/** `dyn` in a checker message means an operand whose type is unknown here —
|
|
104
|
+
* almost always a step result whose invoked resource declares no
|
|
105
|
+
* `outputType:`. Without this, the reader takes `dyn` for a cast problem. */
|
|
106
|
+
const DYN_HINT = (message: string): string =>
|
|
107
|
+
// Word-bounded: "dynamic" appears in unrelated checker messages, and the
|
|
108
|
+
// hint is wrong for those.
|
|
109
|
+
/\bdyn\b/.test(message)
|
|
110
|
+
? " (`dyn` is a value with no static type here — declare `outputType:` on the resource producing it, or convert at the call site.)"
|
|
111
|
+
: "";
|
|
112
|
+
|
|
51
113
|
/** The `!cel` engine. Treats the entire tagged scalar as a single CEL
|
|
52
|
-
* expression — no `${{ }}` wrapping.
|
|
53
|
-
* as the untagged path: parse → extract member-access chains → validate each
|
|
54
|
-
* chain against the effective context schema. */
|
|
114
|
+
* expression — no `${{ }}` wrapping. */
|
|
55
115
|
export const celEngine: TemplatingEngine = {
|
|
56
116
|
name: "cel",
|
|
57
117
|
language: "cel",
|