@telorun/templating 0.3.1 → 0.4.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/dist/cel/analyze.d.ts +16 -0
- package/dist/cel/analyze.d.ts.map +1 -1
- package/dist/cel/analyze.js +164 -0
- package/dist/engines/cel.d.ts.map +1 -1
- package/dist/engines/cel.js +10 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/cel/analyze.ts +195 -0
- package/src/engines/cel.ts +18 -1
- package/src/index.ts +6 -1
package/dist/cel/analyze.d.ts
CHANGED
|
@@ -10,6 +10,21 @@ export declare function extractAccessChains(node: ASTNode): string[][];
|
|
|
10
10
|
* member that can't be resolved to a static name. Consumers that attribute
|
|
11
11
|
* chains to declared names treat this as "unknown member". */
|
|
12
12
|
export declare const INDEX_SEGMENT = "[*]";
|
|
13
|
+
interface NullableIssue {
|
|
14
|
+
/** Dotted path of the nullable value being dereferenced (e.g. "error"). */
|
|
15
|
+
path: string;
|
|
16
|
+
/** The member accessed on it (e.g. "code", or "[index]"). */
|
|
17
|
+
member: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Find member accesses on a nullable value that are not null-guarded in the
|
|
21
|
+
* surrounding expression. A context field whose schema admits `null` (e.g. the
|
|
22
|
+
* `error` object inside a `finally` block, typed `["object", "null"]`) must be
|
|
23
|
+
* narrowed before its members are read. Recognised guards: `x == null` /
|
|
24
|
+
* `x != null` flowing through `?:` ternaries and `&&` / `||` short-circuits.
|
|
25
|
+
* Returns one issue per unguarded dereference.
|
|
26
|
+
*/
|
|
27
|
+
export declare function findNullableAccessIssues(node: ASTNode, contextSchema: Record<string, any>): NullableIssue[];
|
|
13
28
|
/**
|
|
14
29
|
* Check whether a member-access chain accesses only fields declared in a JSON Schema.
|
|
15
30
|
* Returns an error string if a field is unknown in a schema that declares explicit
|
|
@@ -18,4 +33,5 @@ export declare const INDEX_SEGMENT = "[*]";
|
|
|
18
33
|
* Returns null when the chain is valid or the schema is too open to judge.
|
|
19
34
|
*/
|
|
20
35
|
export declare function validateChainAgainstSchema(chain: string[], schema: Record<string, any>): string | null;
|
|
36
|
+
export {};
|
|
21
37
|
//# sourceMappingURL=analyze.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../../src/cel/analyze.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAEpD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,EAAE,CAI7D;AA0DD;;+DAE+D;AAC/D,eAAO,MAAM,aAAa,QAAQ,CAAC;AAqBnC;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,MAAM,GAAG,IAAI,CA2Bf"}
|
|
1
|
+
{"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../../src/cel/analyze.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAEpD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,EAAE,CAI7D;AA0DD;;+DAE+D;AAC/D,eAAO,MAAM,aAAa,QAAQ,CAAC;AAqBnC,UAAU,aAAa;IACrB,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;CAChB;AAoFD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,OAAO,EACb,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GACjC,aAAa,EAAE,CAIjB;AA2FD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,MAAM,GAAG,IAAI,CA2Bf"}
|
package/dist/cel/analyze.js
CHANGED
|
@@ -86,6 +86,170 @@ function extractChain(node, boundVars) {
|
|
|
86
86
|
}
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
|
+
/** True when a JSON Schema admits `null` — `type: "null"` or a union that
|
|
90
|
+
* includes it (e.g. `["object", "null"]`). */
|
|
91
|
+
function schemaIsNullable(schema) {
|
|
92
|
+
if (!schema || typeof schema !== "object")
|
|
93
|
+
return false;
|
|
94
|
+
const t = schema.type;
|
|
95
|
+
return t === "null" || (Array.isArray(t) && t.includes("null"));
|
|
96
|
+
}
|
|
97
|
+
/** Navigate `schema` following a member chain, descending through `properties`.
|
|
98
|
+
* Returns the schema node at that path, or undefined when it can't be resolved. */
|
|
99
|
+
function schemaAtChain(chain, schema) {
|
|
100
|
+
let current = schema;
|
|
101
|
+
for (const key of chain) {
|
|
102
|
+
if (!current || typeof current !== "object")
|
|
103
|
+
return undefined;
|
|
104
|
+
const props = current.properties;
|
|
105
|
+
if (!props || !(key in props))
|
|
106
|
+
return undefined;
|
|
107
|
+
current = props[key];
|
|
108
|
+
}
|
|
109
|
+
return current;
|
|
110
|
+
}
|
|
111
|
+
function isNullLiteral(node) {
|
|
112
|
+
return node.op === "value" && node.args === null;
|
|
113
|
+
}
|
|
114
|
+
/** Dotted form of a static member chain rooted at a free identifier, or null
|
|
115
|
+
* when the node isn't such a chain (call result, bound var, index, …). */
|
|
116
|
+
function dottedChain(node, boundVars) {
|
|
117
|
+
const chain = extractChain(node, boundVars);
|
|
118
|
+
if (chain === null || chain.includes(INDEX_SEGMENT))
|
|
119
|
+
return null;
|
|
120
|
+
return chain.join(".");
|
|
121
|
+
}
|
|
122
|
+
const EMPTY_NARROWING = { whenTrue: new Set(), whenFalse: new Set() };
|
|
123
|
+
/** Derive which chains a boolean condition proves non-null in its true / false
|
|
124
|
+
* branches. Handles `x == null` / `x != null`, negation, and `&&` / `||`. */
|
|
125
|
+
function deriveNarrowing(node, boundVars) {
|
|
126
|
+
if (node.op === "==" || node.op === "!=") {
|
|
127
|
+
const [l, r] = node.args;
|
|
128
|
+
const chain = isNullLiteral(r)
|
|
129
|
+
? dottedChain(l, boundVars)
|
|
130
|
+
: isNullLiteral(l)
|
|
131
|
+
? dottedChain(r, boundVars)
|
|
132
|
+
: null;
|
|
133
|
+
if (chain === null)
|
|
134
|
+
return EMPTY_NARROWING;
|
|
135
|
+
const proven = new Set([chain]);
|
|
136
|
+
return node.op === "!="
|
|
137
|
+
? { whenTrue: proven, whenFalse: new Set() }
|
|
138
|
+
: { whenTrue: new Set(), whenFalse: proven };
|
|
139
|
+
}
|
|
140
|
+
if (node.op === "!_") {
|
|
141
|
+
const inner = deriveNarrowing(node.args, boundVars);
|
|
142
|
+
return { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
|
|
143
|
+
}
|
|
144
|
+
if (node.op === "&&") {
|
|
145
|
+
const [a, b] = node.args;
|
|
146
|
+
const na = deriveNarrowing(a, boundVars);
|
|
147
|
+
const nb = deriveNarrowing(b, boundVars);
|
|
148
|
+
return { whenTrue: union(na.whenTrue, nb.whenTrue), whenFalse: new Set() };
|
|
149
|
+
}
|
|
150
|
+
if (node.op === "||") {
|
|
151
|
+
const [a, b] = node.args;
|
|
152
|
+
const na = deriveNarrowing(a, boundVars);
|
|
153
|
+
const nb = deriveNarrowing(b, boundVars);
|
|
154
|
+
return { whenTrue: new Set(), whenFalse: union(na.whenFalse, nb.whenFalse) };
|
|
155
|
+
}
|
|
156
|
+
return EMPTY_NARROWING;
|
|
157
|
+
}
|
|
158
|
+
function union(a, b) {
|
|
159
|
+
return new Set([...a, ...b]);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Find member accesses on a nullable value that are not null-guarded in the
|
|
163
|
+
* surrounding expression. A context field whose schema admits `null` (e.g. the
|
|
164
|
+
* `error` object inside a `finally` block, typed `["object", "null"]`) must be
|
|
165
|
+
* narrowed before its members are read. Recognised guards: `x == null` /
|
|
166
|
+
* `x != null` flowing through `?:` ternaries and `&&` / `||` short-circuits.
|
|
167
|
+
* Returns one issue per unguarded dereference.
|
|
168
|
+
*/
|
|
169
|
+
export function findNullableAccessIssues(node, contextSchema) {
|
|
170
|
+
const issues = [];
|
|
171
|
+
walkNullable(node, new Set(), new Set(), issues, contextSchema);
|
|
172
|
+
return issues;
|
|
173
|
+
}
|
|
174
|
+
function walkNullable(node, nonNull, boundVars, issues, schema) {
|
|
175
|
+
// Dereference of an object/array member — check the receiver's nullability.
|
|
176
|
+
if (node.op === "." || node.op === "[]") {
|
|
177
|
+
const [obj, field] = node.args;
|
|
178
|
+
const objChain = dottedChain(obj, boundVars);
|
|
179
|
+
if (objChain !== null && !nonNull.has(objChain)) {
|
|
180
|
+
const objSchema = schemaAtChain(objChain.split("."), schema);
|
|
181
|
+
if (schemaIsNullable(objSchema)) {
|
|
182
|
+
issues.push({
|
|
183
|
+
path: objChain,
|
|
184
|
+
member: node.op === "." ? String(field) : "[index]",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
walkNullable(obj, nonNull, boundVars, issues, schema);
|
|
189
|
+
// The index expression (`obj[expr]`) is itself evaluated — descend so a
|
|
190
|
+
// nullable deref used as an index (e.g. `items[error.code]`) is caught.
|
|
191
|
+
if (node.op === "[]" && isASTNode(field)) {
|
|
192
|
+
walkNullable(field, nonNull, boundVars, issues, schema);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (node.op === "?:") {
|
|
197
|
+
const [cond, thenB, elseB] = node.args;
|
|
198
|
+
walkNullable(cond, nonNull, boundVars, issues, schema);
|
|
199
|
+
const n = deriveNarrowing(cond, boundVars);
|
|
200
|
+
walkNullable(thenB, union(nonNull, n.whenTrue), boundVars, issues, schema);
|
|
201
|
+
walkNullable(elseB, union(nonNull, n.whenFalse), boundVars, issues, schema);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (node.op === "&&" || node.op === "||") {
|
|
205
|
+
const [a, b] = node.args;
|
|
206
|
+
walkNullable(a, nonNull, boundVars, issues, schema);
|
|
207
|
+
const n = deriveNarrowing(a, boundVars);
|
|
208
|
+
const carried = node.op === "&&" ? n.whenTrue : n.whenFalse;
|
|
209
|
+
walkNullable(b, union(nonNull, carried), boundVars, issues, schema);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// Comprehension macros bind a loop variable; mirror extractAccessChains so a
|
|
213
|
+
// bound var is never mistaken for a nullable context field.
|
|
214
|
+
if (node.op === "rcall" &&
|
|
215
|
+
Array.isArray(node.args) &&
|
|
216
|
+
typeof node.args[0] === "string" &&
|
|
217
|
+
COMPREHENSION_METHODS.has(node.args[0])) {
|
|
218
|
+
const receiver = node.args[1];
|
|
219
|
+
const comprehensionArgs = node.args[2];
|
|
220
|
+
if (isASTNode(receiver))
|
|
221
|
+
walkNullable(receiver, nonNull, boundVars, issues, schema);
|
|
222
|
+
if (Array.isArray(comprehensionArgs) &&
|
|
223
|
+
comprehensionArgs.length >= 2 &&
|
|
224
|
+
isASTNode(comprehensionArgs[0]) &&
|
|
225
|
+
comprehensionArgs[0].op === "id") {
|
|
226
|
+
const inner = new Set(boundVars);
|
|
227
|
+
inner.add(comprehensionArgs[0].args);
|
|
228
|
+
for (let i = 1; i < comprehensionArgs.length; i++) {
|
|
229
|
+
const arg = comprehensionArgs[i];
|
|
230
|
+
if (isASTNode(arg))
|
|
231
|
+
walkNullable(arg, nonNull, inner, issues, schema);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const args = node.args;
|
|
237
|
+
if (Array.isArray(args)) {
|
|
238
|
+
for (const arg of args) {
|
|
239
|
+
if (isASTNode(arg))
|
|
240
|
+
walkNullable(arg, nonNull, boundVars, issues, schema);
|
|
241
|
+
else if (Array.isArray(arg)) {
|
|
242
|
+
for (const item of arg) {
|
|
243
|
+
if (isASTNode(item))
|
|
244
|
+
walkNullable(item, nonNull, boundVars, issues, schema);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else if (isASTNode(args)) {
|
|
250
|
+
walkNullable(args, nonNull, boundVars, issues, schema);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
89
253
|
/**
|
|
90
254
|
* Check whether a member-access chain accesses only fields declared in a JSON Schema.
|
|
91
255
|
* Returns an error string if a field is unknown in a schema that declares explicit
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cel.d.ts","sourceRoot":"","sources":["../../src/engines/cel.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cel.d.ts","sourceRoot":"","sources":["../../src/engines/cel.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAoB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEvE;;;kDAGkD;AAClD,eAAO,MAAM,SAAS,EAAE,gBA4CvB,CAAC"}
|
package/dist/engines/cel.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractAccessChains, validateChainAgainstSchema } from "../cel/analyze.js";
|
|
1
|
+
import { extractAccessChains, findNullableAccessIssues, validateChainAgainstSchema, } from "../cel/analyze.js";
|
|
2
2
|
import { compileExpression } from "../cel/compile.js";
|
|
3
3
|
/** The `!cel` engine. Treats the entire tagged scalar as a single CEL
|
|
4
4
|
* expression — no `${{ }}` wrapping. Analysis runs the same chain validator
|
|
@@ -31,6 +31,15 @@ export const celEngine = {
|
|
|
31
31
|
if (err)
|
|
32
32
|
out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
|
|
33
33
|
}
|
|
34
|
+
for (const issue of findNullableAccessIssues(parsed.ast, env.contextSchema)) {
|
|
35
|
+
// Index access (member "[index]") attaches without a dot; a named field
|
|
36
|
+
// attaches with one — so the suggested CEL stays valid either way.
|
|
37
|
+
const access = issue.member === "[index]" ? issue.member : `.${issue.member}`;
|
|
38
|
+
out.push({
|
|
39
|
+
code: "CEL_NULLABLE_ACCESS",
|
|
40
|
+
message: `'${issue.path}' may be null — guard it (e.g. '${issue.path} != null && …' or '${issue.path} == null ? … : ${issue.path}${access}') before accessing '${access}'`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
34
43
|
return out;
|
|
35
44
|
},
|
|
36
45
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { buildCelEnvironment, type CelHandlers } from "./cel/environment.js";
|
|
2
2
|
export { compileExpression, compileString, TEMPLATE_REGEX, EXACT_TEMPLATE_REGEX, } from "./cel/compile.js";
|
|
3
|
-
export { extractAccessChains, INDEX_SEGMENT, validateChainAgainstSchema } from "./cel/analyze.js";
|
|
3
|
+
export { extractAccessChains, findNullableAccessIssues, INDEX_SEGMENT, validateChainAgainstSchema, } from "./cel/analyze.js";
|
|
4
4
|
export { walkCelExpressions } from "./cel/walk.js";
|
|
5
5
|
export { celEngine } from "./engines/cel.js";
|
|
6
6
|
export { literalEngine } from "./engines/literal.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,aAAa,EACb,0BAA0B,GAC3B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE7C,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACvF,YAAY,EACV,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AACzG,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,uBAAuB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { buildCelEnvironment } from "./cel/environment.js";
|
|
2
2
|
export { compileExpression, compileString, TEMPLATE_REGEX, EXACT_TEMPLATE_REGEX, } from "./cel/compile.js";
|
|
3
|
-
export { extractAccessChains, INDEX_SEGMENT, validateChainAgainstSchema } from "./cel/analyze.js";
|
|
3
|
+
export { extractAccessChains, findNullableAccessIssues, INDEX_SEGMENT, validateChainAgainstSchema, } from "./cel/analyze.js";
|
|
4
4
|
export { walkCelExpressions } from "./cel/walk.js";
|
|
5
5
|
export { celEngine } from "./engines/cel.js";
|
|
6
6
|
export { literalEngine } from "./engines/literal.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/templating",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
"vitest": "^2.1.8"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@telorun/sdk": "0.
|
|
47
|
+
"@telorun/sdk": "0.13.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "tsc -p tsconfig.lib.json",
|
package/src/cel/analyze.ts
CHANGED
|
@@ -92,6 +92,201 @@ function extractChain(node: ASTNode, boundVars: Set<string>): string[] | null {
|
|
|
92
92
|
return null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
interface NullableIssue {
|
|
96
|
+
/** Dotted path of the nullable value being dereferenced (e.g. "error"). */
|
|
97
|
+
path: string;
|
|
98
|
+
/** The member accessed on it (e.g. "code", or "[index]"). */
|
|
99
|
+
member: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when a JSON Schema admits `null` — `type: "null"` or a union that
|
|
103
|
+
* includes it (e.g. `["object", "null"]`). */
|
|
104
|
+
function schemaIsNullable(schema: Record<string, any> | undefined): boolean {
|
|
105
|
+
if (!schema || typeof schema !== "object") return false;
|
|
106
|
+
const t = schema.type;
|
|
107
|
+
return t === "null" || (Array.isArray(t) && t.includes("null"));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Navigate `schema` following a member chain, descending through `properties`.
|
|
111
|
+
* Returns the schema node at that path, or undefined when it can't be resolved. */
|
|
112
|
+
function schemaAtChain(
|
|
113
|
+
chain: string[],
|
|
114
|
+
schema: Record<string, any>,
|
|
115
|
+
): Record<string, any> | undefined {
|
|
116
|
+
let current: Record<string, any> | undefined = schema;
|
|
117
|
+
for (const key of chain) {
|
|
118
|
+
if (!current || typeof current !== "object") return undefined;
|
|
119
|
+
const props = current.properties as Record<string, any> | undefined;
|
|
120
|
+
if (!props || !(key in props)) return undefined;
|
|
121
|
+
current = props[key];
|
|
122
|
+
}
|
|
123
|
+
return current;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isNullLiteral(node: ASTNode): boolean {
|
|
127
|
+
return node.op === "value" && (node.args as unknown) === null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Dotted form of a static member chain rooted at a free identifier, or null
|
|
131
|
+
* when the node isn't such a chain (call result, bound var, index, …). */
|
|
132
|
+
function dottedChain(node: ASTNode, boundVars: Set<string>): string | null {
|
|
133
|
+
const chain = extractChain(node, boundVars);
|
|
134
|
+
if (chain === null || chain.includes(INDEX_SEGMENT)) return null;
|
|
135
|
+
return chain.join(".");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface Narrowing {
|
|
139
|
+
whenTrue: Set<string>;
|
|
140
|
+
whenFalse: Set<string>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const EMPTY_NARROWING: Narrowing = { whenTrue: new Set(), whenFalse: new Set() };
|
|
144
|
+
|
|
145
|
+
/** Derive which chains a boolean condition proves non-null in its true / false
|
|
146
|
+
* branches. Handles `x == null` / `x != null`, negation, and `&&` / `||`. */
|
|
147
|
+
function deriveNarrowing(node: ASTNode, boundVars: Set<string>): Narrowing {
|
|
148
|
+
if (node.op === "==" || node.op === "!=") {
|
|
149
|
+
const [l, r] = node.args as [ASTNode, ASTNode];
|
|
150
|
+
const chain = isNullLiteral(r)
|
|
151
|
+
? dottedChain(l, boundVars)
|
|
152
|
+
: isNullLiteral(l)
|
|
153
|
+
? dottedChain(r, boundVars)
|
|
154
|
+
: null;
|
|
155
|
+
if (chain === null) return EMPTY_NARROWING;
|
|
156
|
+
const proven = new Set([chain]);
|
|
157
|
+
return node.op === "!="
|
|
158
|
+
? { whenTrue: proven, whenFalse: new Set() }
|
|
159
|
+
: { whenTrue: new Set(), whenFalse: proven };
|
|
160
|
+
}
|
|
161
|
+
if (node.op === "!_") {
|
|
162
|
+
const inner = deriveNarrowing(node.args as ASTNode, boundVars);
|
|
163
|
+
return { whenTrue: inner.whenFalse, whenFalse: inner.whenTrue };
|
|
164
|
+
}
|
|
165
|
+
if (node.op === "&&") {
|
|
166
|
+
const [a, b] = node.args as [ASTNode, ASTNode];
|
|
167
|
+
const na = deriveNarrowing(a, boundVars);
|
|
168
|
+
const nb = deriveNarrowing(b, boundVars);
|
|
169
|
+
return { whenTrue: union(na.whenTrue, nb.whenTrue), whenFalse: new Set() };
|
|
170
|
+
}
|
|
171
|
+
if (node.op === "||") {
|
|
172
|
+
const [a, b] = node.args as [ASTNode, ASTNode];
|
|
173
|
+
const na = deriveNarrowing(a, boundVars);
|
|
174
|
+
const nb = deriveNarrowing(b, boundVars);
|
|
175
|
+
return { whenTrue: new Set(), whenFalse: union(na.whenFalse, nb.whenFalse) };
|
|
176
|
+
}
|
|
177
|
+
return EMPTY_NARROWING;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function union(a: Set<string>, b: Set<string>): Set<string> {
|
|
181
|
+
return new Set([...a, ...b]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Find member accesses on a nullable value that are not null-guarded in the
|
|
186
|
+
* surrounding expression. A context field whose schema admits `null` (e.g. the
|
|
187
|
+
* `error` object inside a `finally` block, typed `["object", "null"]`) must be
|
|
188
|
+
* narrowed before its members are read. Recognised guards: `x == null` /
|
|
189
|
+
* `x != null` flowing through `?:` ternaries and `&&` / `||` short-circuits.
|
|
190
|
+
* Returns one issue per unguarded dereference.
|
|
191
|
+
*/
|
|
192
|
+
export function findNullableAccessIssues(
|
|
193
|
+
node: ASTNode,
|
|
194
|
+
contextSchema: Record<string, any>,
|
|
195
|
+
): NullableIssue[] {
|
|
196
|
+
const issues: NullableIssue[] = [];
|
|
197
|
+
walkNullable(node, new Set(), new Set(), issues, contextSchema);
|
|
198
|
+
return issues;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function walkNullable(
|
|
202
|
+
node: ASTNode,
|
|
203
|
+
nonNull: Set<string>,
|
|
204
|
+
boundVars: Set<string>,
|
|
205
|
+
issues: NullableIssue[],
|
|
206
|
+
schema: Record<string, any>,
|
|
207
|
+
): void {
|
|
208
|
+
// Dereference of an object/array member — check the receiver's nullability.
|
|
209
|
+
if (node.op === "." || node.op === "[]") {
|
|
210
|
+
const [obj, field] = node.args as [ASTNode, unknown];
|
|
211
|
+
const objChain = dottedChain(obj, boundVars);
|
|
212
|
+
if (objChain !== null && !nonNull.has(objChain)) {
|
|
213
|
+
const objSchema = schemaAtChain(objChain.split("."), schema);
|
|
214
|
+
if (schemaIsNullable(objSchema)) {
|
|
215
|
+
issues.push({
|
|
216
|
+
path: objChain,
|
|
217
|
+
member: node.op === "." ? String(field) : "[index]",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
walkNullable(obj, nonNull, boundVars, issues, schema);
|
|
222
|
+
// The index expression (`obj[expr]`) is itself evaluated — descend so a
|
|
223
|
+
// nullable deref used as an index (e.g. `items[error.code]`) is caught.
|
|
224
|
+
if (node.op === "[]" && isASTNode(field)) {
|
|
225
|
+
walkNullable(field as ASTNode, nonNull, boundVars, issues, schema);
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (node.op === "?:") {
|
|
231
|
+
const [cond, thenB, elseB] = node.args as [ASTNode, ASTNode, ASTNode];
|
|
232
|
+
walkNullable(cond, nonNull, boundVars, issues, schema);
|
|
233
|
+
const n = deriveNarrowing(cond, boundVars);
|
|
234
|
+
walkNullable(thenB, union(nonNull, n.whenTrue), boundVars, issues, schema);
|
|
235
|
+
walkNullable(elseB, union(nonNull, n.whenFalse), boundVars, issues, schema);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (node.op === "&&" || node.op === "||") {
|
|
240
|
+
const [a, b] = node.args as [ASTNode, ASTNode];
|
|
241
|
+
walkNullable(a, nonNull, boundVars, issues, schema);
|
|
242
|
+
const n = deriveNarrowing(a, boundVars);
|
|
243
|
+
const carried = node.op === "&&" ? n.whenTrue : n.whenFalse;
|
|
244
|
+
walkNullable(b, union(nonNull, carried), boundVars, issues, schema);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Comprehension macros bind a loop variable; mirror extractAccessChains so a
|
|
249
|
+
// bound var is never mistaken for a nullable context field.
|
|
250
|
+
if (
|
|
251
|
+
node.op === "rcall" &&
|
|
252
|
+
Array.isArray(node.args) &&
|
|
253
|
+
typeof node.args[0] === "string" &&
|
|
254
|
+
COMPREHENSION_METHODS.has(node.args[0])
|
|
255
|
+
) {
|
|
256
|
+
const receiver = node.args[1];
|
|
257
|
+
const comprehensionArgs = node.args[2];
|
|
258
|
+
if (isASTNode(receiver)) walkNullable(receiver, nonNull, boundVars, issues, schema);
|
|
259
|
+
if (
|
|
260
|
+
Array.isArray(comprehensionArgs) &&
|
|
261
|
+
comprehensionArgs.length >= 2 &&
|
|
262
|
+
isASTNode(comprehensionArgs[0]) &&
|
|
263
|
+
(comprehensionArgs[0] as ASTNode).op === "id"
|
|
264
|
+
) {
|
|
265
|
+
const inner = new Set(boundVars);
|
|
266
|
+
inner.add((comprehensionArgs[0] as ASTNode).args as string);
|
|
267
|
+
for (let i = 1; i < comprehensionArgs.length; i++) {
|
|
268
|
+
const arg = comprehensionArgs[i];
|
|
269
|
+
if (isASTNode(arg)) walkNullable(arg as ASTNode, nonNull, inner, issues, schema);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const args = node.args;
|
|
276
|
+
if (Array.isArray(args)) {
|
|
277
|
+
for (const arg of args) {
|
|
278
|
+
if (isASTNode(arg)) walkNullable(arg, nonNull, boundVars, issues, schema);
|
|
279
|
+
else if (Array.isArray(arg)) {
|
|
280
|
+
for (const item of arg) {
|
|
281
|
+
if (isASTNode(item)) walkNullable(item, nonNull, boundVars, issues, schema);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} else if (isASTNode(args)) {
|
|
286
|
+
walkNullable(args, nonNull, boundVars, issues, schema);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
95
290
|
/**
|
|
96
291
|
* Check whether a member-access chain accesses only fields declared in a JSON Schema.
|
|
97
292
|
* Returns an error string if a field is unknown in a schema that declares explicit
|
package/src/engines/cel.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
extractAccessChains,
|
|
3
|
+
findNullableAccessIssues,
|
|
4
|
+
validateChainAgainstSchema,
|
|
5
|
+
} from "../cel/analyze.js";
|
|
2
6
|
import { compileExpression } from "../cel/compile.js";
|
|
3
7
|
import type { EngineDiagnostic, TemplatingEngine } from "../engine.js";
|
|
4
8
|
|
|
@@ -35,6 +39,19 @@ export const celEngine: TemplatingEngine = {
|
|
|
35
39
|
const err = validateChainAgainstSchema(chain, env.contextSchema as Record<string, any>);
|
|
36
40
|
if (err) out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
|
|
37
41
|
}
|
|
42
|
+
|
|
43
|
+
for (const issue of findNullableAccessIssues(
|
|
44
|
+
parsed.ast,
|
|
45
|
+
env.contextSchema as Record<string, any>,
|
|
46
|
+
)) {
|
|
47
|
+
// Index access (member "[index]") attaches without a dot; a named field
|
|
48
|
+
// attaches with one — so the suggested CEL stays valid either way.
|
|
49
|
+
const access = issue.member === "[index]" ? issue.member : `.${issue.member}`;
|
|
50
|
+
out.push({
|
|
51
|
+
code: "CEL_NULLABLE_ACCESS",
|
|
52
|
+
message: `'${issue.path}' may be null — guard it (e.g. '${issue.path} != null && …' or '${issue.path} == null ? … : ${issue.path}${access}') before accessing '${access}'`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
38
55
|
return out;
|
|
39
56
|
},
|
|
40
57
|
};
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,12 @@ export {
|
|
|
5
5
|
TEMPLATE_REGEX,
|
|
6
6
|
EXACT_TEMPLATE_REGEX,
|
|
7
7
|
} from "./cel/compile.js";
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
extractAccessChains,
|
|
10
|
+
findNullableAccessIssues,
|
|
11
|
+
INDEX_SEGMENT,
|
|
12
|
+
validateChainAgainstSchema,
|
|
13
|
+
} from "./cel/analyze.js";
|
|
9
14
|
export { walkCelExpressions } from "./cel/walk.js";
|
|
10
15
|
|
|
11
16
|
export { celEngine } from "./engines/cel.js";
|