@telorun/analyzer 0.64.0 → 0.66.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/analyzer.d.ts +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +175 -65
- package/dist/call-graph.d.ts.map +1 -1
- package/dist/call-graph.js +7 -1
- package/dist/cel-environment.d.ts +12 -0
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +35 -19
- package/dist/cel-scope.d.ts.map +1 -1
- package/dist/cel-scope.js +24 -4
- package/dist/definition-registry.d.ts +47 -6
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +82 -13
- package/dist/eval-paths.d.ts +68 -0
- package/dist/eval-paths.d.ts.map +1 -1
- package/dist/eval-paths.js +138 -0
- package/dist/flatten-for-analyzer.d.ts +1 -1
- package/dist/flatten-for-analyzer.js +1 -1
- package/dist/import-resolution-diagnostics.d.ts +1 -1
- package/dist/import-resolution-diagnostics.js +1 -1
- package/dist/index.d.ts +13 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +8 -1
- package/dist/migrations/entries/index.d.ts.map +1 -1
- package/dist/migrations/entries/index.js +2 -0
- package/dist/migrations/entries/schema-prepare-bucket.json +27 -0
- package/dist/peer-binding.d.ts +232 -0
- package/dist/peer-binding.d.ts.map +1 -0
- package/dist/peer-binding.js +418 -0
- package/dist/ref-slot.d.ts +13 -0
- package/dist/ref-slot.d.ts.map +1 -1
- package/dist/ref-slot.js +26 -0
- package/dist/reference-field-map.d.ts +28 -0
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +25 -0
- package/dist/referrer-rule.d.ts +17 -0
- package/dist/referrer-rule.d.ts.map +1 -1
- package/dist/referrer-rule.js +4 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.js +1 -1
- package/dist/resource-rule.d.ts +40 -27
- package/dist/resource-rule.d.ts.map +1 -1
- package/dist/resource-rule.js +61 -7
- package/dist/rule-condition.d.ts +11 -0
- package/dist/rule-condition.d.ts.map +1 -1
- package/dist/rule-condition.js +13 -0
- package/dist/schema-compat.d.ts +59 -22
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +60 -75
- package/dist/schema-error-report.d.ts +68 -0
- package/dist/schema-error-report.d.ts.map +1 -0
- package/dist/schema-error-report.js +356 -0
- package/dist/schema-projection.d.ts +106 -4
- package/dist/schema-projection.d.ts.map +1 -1
- package/dist/schema-projection.js +206 -18
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/validate-cel-context.d.ts +1 -17
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +5 -70
- package/dist/validate-nested-inline.d.ts +22 -1
- package/dist/validate-nested-inline.d.ts.map +1 -1
- package/dist/validate-nested-inline.js +17 -9
- package/dist/validate-reference-forms.d.ts +10 -0
- package/dist/validate-reference-forms.d.ts.map +1 -1
- package/dist/validate-reference-forms.js +23 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +9 -2
- package/dist/validate-referrer-rules.d.ts +38 -4
- package/dist/validate-referrer-rules.d.ts.map +1 -1
- package/dist/validate-referrer-rules.js +139 -31
- package/dist/validate-resource-rules.d.ts +11 -2
- package/dist/validate-resource-rules.d.ts.map +1 -1
- package/dist/validate-resource-rules.js +18 -12
- package/dist/validate-schema-projection.d.ts.map +1 -1
- package/dist/validate-schema-projection.js +33 -2
- package/dist/validate-step-inputs.js +11 -3
- package/dist/with-synthetic-positions.d.ts +1 -1
- package/dist/with-synthetic-positions.js +1 -1
- package/package.json +3 -3
- package/src/analyzer.ts +211 -68
- package/src/call-graph.ts +7 -1
- package/src/cel-environment.ts +48 -19
- package/src/cel-scope.ts +24 -6
- package/src/definition-registry.ts +90 -11
- package/src/eval-paths.ts +152 -0
- package/src/flatten-for-analyzer.ts +1 -1
- package/src/import-resolution-diagnostics.ts +1 -1
- package/src/index.ts +55 -6
- package/src/manifest-visitor.ts +8 -1
- package/src/migrations/entries/index.ts +2 -0
- package/src/migrations/entries/schema-prepare-bucket.json +27 -0
- package/src/peer-binding.ts +641 -0
- package/src/ref-slot.ts +36 -0
- package/src/reference-field-map.ts +42 -0
- package/src/referrer-rule.ts +20 -1
- package/src/release/index.ts +1 -1
- package/src/resource-rule.ts +69 -7
- package/src/rule-condition.ts +15 -0
- package/src/schema-compat.ts +92 -79
- package/src/schema-error-report.ts +417 -0
- package/src/schema-projection.ts +283 -20
- package/src/telo-version.ts +1 -1
- package/src/validate-cel-context.ts +6 -71
- package/src/validate-nested-inline.ts +35 -14
- package/src/validate-reference-forms.ts +22 -0
- package/src/validate-references.ts +15 -2
- package/src/validate-referrer-rules.ts +215 -28
- package/src/validate-resource-rules.ts +26 -12
- package/src/validate-schema-projection.ts +50 -1
- package/src/validate-step-inputs.ts +10 -4
- package/src/with-synthetic-positions.ts +1 -1
|
@@ -19,6 +19,10 @@ export interface RefFieldEntry {
|
|
|
19
19
|
inputs?: string;
|
|
20
20
|
/** True when the field path traversed through at least one array (path contains "[]"). */
|
|
21
21
|
isArray: boolean;
|
|
22
|
+
/** The slot's non-reference branches, when the reference constraint is a
|
|
23
|
+
* branch of a union — see {@link RefSlot.valueBranches}. A value satisfying
|
|
24
|
+
* one of these is a value, not a malformed reference. */
|
|
25
|
+
valueBranches?: Record<string, any>[];
|
|
22
26
|
/** x-telo-context schema declared on this ref slot, if any. Describes the CEL invocation
|
|
23
27
|
* context available to resources placed in this slot. */
|
|
24
28
|
context?: Record<string, any>;
|
|
@@ -62,6 +66,43 @@ export function isRefEntry(entry: FieldMapEntry): entry is RefFieldEntry {
|
|
|
62
66
|
return "refs" in entry;
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/** The half of a definition registry this question needs — structural, so the
|
|
70
|
+
* field map keeps depending on nothing. */
|
|
71
|
+
export interface ValueBranchValidator {
|
|
72
|
+
schemaCompileError(schema: Record<string, any>): string | undefined;
|
|
73
|
+
validateWithRefs(data: unknown, schema: Record<string, any>): string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* True when a value at a ref slot satisfies one of the slot's VALUE branches —
|
|
78
|
+
* a storage class beside a `!ref`, so it is a value and not a malformed
|
|
79
|
+
* reference.
|
|
80
|
+
*
|
|
81
|
+
* One implementation, because BOTH reference passes have to narrow the same way:
|
|
82
|
+
* `validateReferenceForms` would otherwise call it a removed string reference,
|
|
83
|
+
* and `validateReferences` a reference missing `kind` and `name`. Two copies of
|
|
84
|
+
* the rule would eventually disagree about which of the two reported a value.
|
|
85
|
+
*
|
|
86
|
+
* A branch AJV cannot COMPILE is not a branch the value satisfies.
|
|
87
|
+
* `validateWithRefs` returns no issues for one — it swallows the compile failure
|
|
88
|
+
* by design, so one bad schema does not abort the pass — and reading that as
|
|
89
|
+
* "no issues, therefore a value" would switch the reference-form rule off for
|
|
90
|
+
* the slot silently. The uncompilable schema is reported on its own definition
|
|
91
|
+
* by `schemaCompileError`.
|
|
92
|
+
*/
|
|
93
|
+
export function satisfiesValueBranch(
|
|
94
|
+
value: unknown,
|
|
95
|
+
branches: readonly Record<string, any>[] | undefined,
|
|
96
|
+
registry: ValueBranchValidator,
|
|
97
|
+
): boolean {
|
|
98
|
+
if (!branches?.length) return false;
|
|
99
|
+
return branches.some(
|
|
100
|
+
(branch) =>
|
|
101
|
+
registry.schemaCompileError(branch) === undefined &&
|
|
102
|
+
registry.validateWithRefs(value, branch).length === 0,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
65
106
|
export function isScopeEntry(entry: FieldMapEntry): entry is ScopeFieldEntry {
|
|
66
107
|
return "scope" in entry;
|
|
67
108
|
}
|
|
@@ -234,6 +275,7 @@ function traverseNode(
|
|
|
234
275
|
};
|
|
235
276
|
if (slot.useCases) entry.useCases = slot.useCases;
|
|
236
277
|
if (slot.inputs !== undefined) entry.inputs = slot.inputs;
|
|
278
|
+
if (slot.valueBranches.length > 0) entry.valueBranches = slot.valueBranches;
|
|
237
279
|
if (node["x-telo-context"]) entry.context = node["x-telo-context"] as Record<string, any>;
|
|
238
280
|
if (slot.inline) entry.inline = true;
|
|
239
281
|
map.set(path, entry);
|
package/src/referrer-rule.ts
CHANGED
|
@@ -37,6 +37,23 @@ export interface ReferrerRule {
|
|
|
37
37
|
* relation the rule is about, so a kind should write it.
|
|
38
38
|
*/
|
|
39
39
|
readonly referrer?: string;
|
|
40
|
+
/**
|
|
41
|
+
* JSON Pointer to a collection OF THE REFERRER to resolve — the binding that
|
|
42
|
+
* lets a rule state a relation between SIBLING declarations, which neither a
|
|
43
|
+
* resource rule (one resource) nor a plain referrer rule (a pair joined by one
|
|
44
|
+
* reference) can reach.
|
|
45
|
+
*
|
|
46
|
+
* Declaring it binds `peers` (the collection's OTHER entries) and `entry` (my
|
|
47
|
+
* own), and makes the rule evaluate once per entry rather than once per
|
|
48
|
+
* referrer: a rule reading `entry` is about the entry, and a resource listed
|
|
49
|
+
* twice has two entries to answer for.
|
|
50
|
+
*
|
|
51
|
+
* Entries bind AS WRITTEN with the references *inside them* resolved one level
|
|
52
|
+
* — `p` is the declaration where the entry is a bare `!ref`, `p.mount` is the
|
|
53
|
+
* declaration with `p.prefix` beside it where it is not. Nothing is guessed
|
|
54
|
+
* from the item schema and there is no second pointer to write.
|
|
55
|
+
*/
|
|
56
|
+
readonly peers?: string;
|
|
40
57
|
/** CEL source. TRUE when the rule holds. */
|
|
41
58
|
readonly condition: string;
|
|
42
59
|
/** The rule's own name, carried in `data.rule`. Never a diagnostic code —
|
|
@@ -68,15 +85,17 @@ export function readReferrerRules(schema: unknown): ReferrerRule[] {
|
|
|
68
85
|
raw.forEach((entry, index) => {
|
|
69
86
|
if (!isObject(entry)) return;
|
|
70
87
|
const condition = celSourceOf(entry.condition);
|
|
71
|
-
const { code, message, referrer } = entry;
|
|
88
|
+
const { code, message, referrer, peers } = entry;
|
|
72
89
|
if (!condition || typeof code !== "string" || typeof message !== "string") return;
|
|
73
90
|
if (code.length === 0 || message.length === 0) return;
|
|
74
91
|
if (referrer !== undefined && typeof referrer !== "string") return;
|
|
92
|
+
if (peers !== undefined && typeof peers !== "string") return;
|
|
75
93
|
if (entry.severity !== undefined && entry.severity !== "warning" && entry.severity !== "error") {
|
|
76
94
|
return;
|
|
77
95
|
}
|
|
78
96
|
rules.push({
|
|
79
97
|
...(referrer === undefined ? {} : { referrer }),
|
|
98
|
+
...(peers === undefined ? {} : { peers }),
|
|
80
99
|
condition,
|
|
81
100
|
code,
|
|
82
101
|
message,
|
package/src/release/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The release model: module identity, fragments, the ledger, the edge graph,
|
|
3
3
|
* level propagation and version planning.
|
|
4
4
|
*
|
|
5
|
-
* Browser-safe by construction — pure data in, plan out — so
|
|
5
|
+
* Browser-safe by construction — pure data in, plan out — so telo studio can
|
|
6
6
|
* answer "what does changing this library bump?" from the same model the CLI
|
|
7
7
|
* releases from. Everything Node-shaped (finding the workspace, running the
|
|
8
8
|
* controller builder, building payloads, reading git, writing files) is
|
package/src/resource-rule.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Browser-safe: no Node built-ins.
|
|
23
23
|
*/
|
|
24
|
+
import { CEL_ENGINE, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
24
25
|
|
|
25
26
|
export const RESOURCE_RULES_ANNOTATION = "x-telo-resource-rules";
|
|
26
27
|
|
|
@@ -58,9 +59,42 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
58
59
|
* Both markers are tested because they are not always both present: a
|
|
59
60
|
* registered definition's schema reaches the analyzer with `call` and
|
|
60
61
|
* `__compiled` dropped, keeping only `__tagged` + `source`. Testing one would
|
|
61
|
-
* make a rule readable on some paths and invisible on others.
|
|
62
|
+
* make a rule readable on some paths and invisible on others.
|
|
63
|
+
*
|
|
64
|
+
* A tagged sentinel of ANOTHER engine is not one. `__tagged` marks every tag
|
|
65
|
+
* the loader parses — `!ref` above all — so testing it alone read a reference
|
|
66
|
+
* as an expression: a column whose `type:` holds a `!ref` skipped every rule
|
|
67
|
+
* that touched `self.columns`, and said "the value holds a CEL expression" about
|
|
68
|
+
* a manifest containing none. A reference names a declaration and is a
|
|
69
|
+
* perfectly comparable value; what a rule cannot compare is a value COMPUTED at
|
|
70
|
+
* create time, which is what this predicate exists to find. */
|
|
62
71
|
function isCelNode(value: unknown): value is { source?: unknown } {
|
|
63
|
-
|
|
72
|
+
if (!isObject(value)) return false;
|
|
73
|
+
if (value.__compiled === true) return true;
|
|
74
|
+
return value.__tagged === true && value.engine === CEL_ENGINE;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The engine of a non-CEL tagged sentinel — a `!ref`, an `!include-*` — or
|
|
78
|
+
* `undefined`. A reference is comparable and never blocks a rule; the other
|
|
79
|
+
* tags hold a value only known once the resource is created, so they do, and
|
|
80
|
+
* the diagnostic has to name the tag rather than claim CEL. */
|
|
81
|
+
export function deferredTagOf(value: unknown): string | undefined {
|
|
82
|
+
if (!isTaggedSentinel(value) || isRefSentinel(value)) return undefined;
|
|
83
|
+
return value.engine === CEL_ENGINE ? undefined : value.engine;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when a condition was written with the `!cel` tag.
|
|
88
|
+
*
|
|
89
|
+
* The readers stay lenient and take a bare string — a rule still runs either
|
|
90
|
+
* way. What an untagged condition loses is everything outside evaluation: to the
|
|
91
|
+
* editor's colouring, completion and hover it is a plain string, so a rule author
|
|
92
|
+
* writes CEL with no help and gets none of the checks a `!cel` scalar gets.
|
|
93
|
+
* Losing that silently is exactly what a strict half exists to move earlier, so
|
|
94
|
+
* the tag is reported by the strict halves and never enforced by the readers.
|
|
95
|
+
*/
|
|
96
|
+
export function isTaggedCondition(value: unknown): boolean {
|
|
97
|
+
return isCelNode(value);
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
/** A precompiled `!cel` node keeps its author-written text on `source`; a plain
|
|
@@ -179,17 +213,45 @@ export function resolveRuleSubjects(
|
|
|
179
213
|
return undefined;
|
|
180
214
|
}
|
|
181
215
|
|
|
216
|
+
/** The first leaf a rule cannot compare, and what it is. `what` is a noun
|
|
217
|
+
* phrase the diagnostic quotes verbatim, because "a CEL expression" printed
|
|
218
|
+
* over an `!include-bytes` embed sends its author looking for an expression
|
|
219
|
+
* that is not there. */
|
|
220
|
+
export interface DynamicLeaf {
|
|
221
|
+
readonly path: string;
|
|
222
|
+
readonly what: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Classify ONE node, without descending. Exported because a caller that draws
|
|
226
|
+
* its own bound on how far to look (`peer-binding`'s top-level-scalar scan)
|
|
227
|
+
* must classify by the same rule as the recursive walk, or a `!ref` is a
|
|
228
|
+
* reference to one of them and an expression to the other. */
|
|
229
|
+
export function dynamicNode(value: unknown, path: string): DynamicLeaf | undefined {
|
|
230
|
+
const at = path || "(value)";
|
|
231
|
+
if (isCelNode(value)) return { path: at, what: "a CEL expression" };
|
|
232
|
+
const tag = deferredTagOf(value);
|
|
233
|
+
return tag ? { path: at, what: `an !${tag} embed` } : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
182
236
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* the subject is skipped — and the
|
|
237
|
+
* The first leaf inside a value whose contents are not known until the resource
|
|
238
|
+
* is created, or `undefined` when every leaf is literal. A rule reading one
|
|
239
|
+
* would be comparing against a placeholder, so the subject is skipped — and the
|
|
240
|
+
* skip is reported, never silent.
|
|
241
|
+
*
|
|
242
|
+
* A `!ref` is NOT one of them. It is a tagged sentinel like `!cel`, and testing
|
|
243
|
+
* `__tagged` alone read every reference as an expression: a column whose `type:`
|
|
244
|
+
* holds a `!ref` switched off every rule touching `self.columns` and reported a
|
|
245
|
+
* CEL expression in a manifest containing none. A reference names a declaration
|
|
246
|
+
* — a value a rule compares perfectly well, and the one peer rules are built on.
|
|
186
247
|
*
|
|
187
248
|
* Stops at nested inline `{ kind }` declarations for the reason every other walk
|
|
188
249
|
* does: that CEL belongs to the nested kind, evaluated in its own scope.
|
|
189
250
|
*/
|
|
190
|
-
export function findDynamicLeaf(value: unknown, base = ""):
|
|
251
|
+
export function findDynamicLeaf(value: unknown, base = ""): DynamicLeaf | undefined {
|
|
191
252
|
if (isObject(value)) {
|
|
192
|
-
|
|
253
|
+
const own = dynamicNode(value, base);
|
|
254
|
+
if (own) return own;
|
|
193
255
|
if (typeof value.kind === "string" && base !== "") return undefined;
|
|
194
256
|
for (const [key, child] of Object.entries(value)) {
|
|
195
257
|
const found = findDynamicLeaf(child, base === "" ? key : `${base}.${key}`);
|
package/src/rule-condition.ts
CHANGED
|
@@ -32,6 +32,21 @@ import {
|
|
|
32
32
|
*/
|
|
33
33
|
export const RULE_BUDGET_MS = 50;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The one message for an untagged `condition:`, shared by every rule family so
|
|
37
|
+
* all of them say the same thing about the same defect.
|
|
38
|
+
*
|
|
39
|
+
* The readers stay lenient and a bare string still runs. What it loses is
|
|
40
|
+
* everything outside evaluation — to the editor's colouring, completion and
|
|
41
|
+
* hover an untagged condition is a plain string, so its author writes CEL with
|
|
42
|
+
* no help and none of the checks a `!cel` scalar gets. Losing that silently is
|
|
43
|
+
* the failure a strict half exists to move earlier.
|
|
44
|
+
*/
|
|
45
|
+
export const UNTAGGED_CONDITION =
|
|
46
|
+
"Write 'condition' with the !cel tag. The reader is lenient and a bare string still " +
|
|
47
|
+
"runs, but untagged the expression is not CEL to the editor's colouring, completion " +
|
|
48
|
+
"or hover, so a rule silently stops being CEL to every surface but this one.";
|
|
49
|
+
|
|
35
50
|
const HOST_BACKED = new Set(CEL_FUNCTIONS.filter((f) => f.hostBacked).map((f) => f.name));
|
|
36
51
|
const NON_DETERMINISTIC = new Set(
|
|
37
52
|
CEL_FUNCTIONS.filter((f) => !f.deterministic).map((f) => f.name),
|
package/src/schema-compat.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
valueTypePlaceholder,
|
|
15
15
|
} from "@telorun/sdk";
|
|
16
16
|
import { ManifestRootSchema } from "./manifest-schemas.js";
|
|
17
|
+
import { schemaIssues, type SchemaIssue } from "./schema-error-report.js";
|
|
17
18
|
import { registerTeloKeywords } from "./value-type-keyword.js";
|
|
18
19
|
|
|
19
20
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|
|
@@ -255,55 +256,8 @@ function compare(
|
|
|
255
256
|
}
|
|
256
257
|
}
|
|
257
258
|
|
|
258
|
-
export
|
|
259
|
-
|
|
260
|
-
const params = err.params ?? {};
|
|
261
|
-
switch (err.keyword) {
|
|
262
|
-
case "additionalProperties":
|
|
263
|
-
return `${p} must NOT have additional properties ('${params.additionalProperty}' is not allowed)`;
|
|
264
|
-
case "required":
|
|
265
|
-
return `${p} is missing required property '${params.missingProperty}'`;
|
|
266
|
-
case "enum":
|
|
267
|
-
return `${p} ${err.message ?? "is invalid"} (${(params.allowedValues as unknown[])?.join(" | ")})`;
|
|
268
|
-
case "type":
|
|
269
|
-
return `${p} must be ${params.type} (got ${typeof err.data})`;
|
|
270
|
-
default:
|
|
271
|
-
return `${p} ${err.message ?? "is invalid"}`;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export function formatAjvErrors(errors: any[] | null | undefined): string {
|
|
276
|
-
if (!errors || errors.length === 0) return "Unknown schema error";
|
|
277
|
-
return errors.map(formatSingleError).join("; ");
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** Converts an AJV error object to a dotted path string compatible with PositionIndex keys.
|
|
281
|
-
* e.g. instancePath "/config/routes/0/handler" → "config.routes[0].handler"
|
|
282
|
-
* For "required" keyword errors, appends the missing property to the parent path. */
|
|
283
|
-
function ajvErrorToPath(err: any): string {
|
|
284
|
-
const instancePath = (err.instancePath ?? "") as string;
|
|
285
|
-
const parts = instancePath.split("/").filter((p) => p !== "");
|
|
286
|
-
let result = "";
|
|
287
|
-
for (const part of parts) {
|
|
288
|
-
if (/^\d+$/.test(part)) {
|
|
289
|
-
result += `[${part}]`;
|
|
290
|
-
} else {
|
|
291
|
-
result += result ? `.${part}` : part;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (err.keyword === "required" && err.params?.missingProperty) {
|
|
295
|
-
const missing = err.params.missingProperty as string;
|
|
296
|
-
result += result ? `.${missing}` : missing;
|
|
297
|
-
}
|
|
298
|
-
return result;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** A schema validation issue with a dotted-path pointer to the offending field. */
|
|
302
|
-
export interface SchemaIssue {
|
|
303
|
-
message: string;
|
|
304
|
-
/** Dotted path to the field (e.g. "config.handler"). Empty string means root. */
|
|
305
|
-
path: string;
|
|
306
|
-
}
|
|
259
|
+
export { formatAjvErrors, formatSingleError } from "./schema-error-report.js";
|
|
260
|
+
export type { SchemaIssue } from "./schema-error-report.js";
|
|
307
261
|
|
|
308
262
|
/** Does `schema` compile as-authored? Used to tell a malformed module schema
|
|
309
263
|
* (the author's problem) apart from a fault we introduced while normalizing it. */
|
|
@@ -335,10 +289,7 @@ export function validateAgainstSchema(data: unknown, schema: Record<string, any>
|
|
|
335
289
|
compiledSchemaValidators.set(schema, validate);
|
|
336
290
|
}
|
|
337
291
|
if (validate(data)) return [];
|
|
338
|
-
return (validate.errors
|
|
339
|
-
message: formatSingleError(err),
|
|
340
|
-
path: ajvErrorToPath(err),
|
|
341
|
-
}));
|
|
292
|
+
return schemaIssues(validate.errors);
|
|
342
293
|
}
|
|
343
294
|
|
|
344
295
|
/** Resolves a JSON Pointer (RFC 6901, must start with "/") into a schema object.
|
|
@@ -640,16 +591,55 @@ function objectPlaceholder(schema: Record<string, any>): Record<string, unknown>
|
|
|
640
591
|
|
|
641
592
|
const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
|
|
642
593
|
|
|
643
|
-
/**
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
594
|
+
/**
|
|
595
|
+
* Resolve a `$ref` — the document-local `#/$defs/...` form against `root`, and
|
|
596
|
+
* anything else through `external` when a caller supplies one.
|
|
597
|
+
*
|
|
598
|
+
* A named shape is addressed by a registered id (`telo:<module>/<Type>`), which
|
|
599
|
+
* lives in a schema store rather than in this document, so without the hook a
|
|
600
|
+
* walk stops at the reference and treats a described value as undescribed:
|
|
601
|
+
* every CEL leaf under it is handed the schema-unaware `""` placeholder and
|
|
602
|
+
* then rejected against a branch it was never measured against. The caller
|
|
603
|
+
* supplies the store because only the caller has one.
|
|
604
|
+
*/
|
|
605
|
+
export function resolveRef(
|
|
606
|
+
schema: Record<string, any>,
|
|
607
|
+
root: Record<string, any>,
|
|
608
|
+
external?: ExternalSchemaResolver,
|
|
609
|
+
): Record<string, any> {
|
|
610
|
+
return resolveRefIn(schema, root, external).schema;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* {@link resolveRef}, reporting the ROOT the result's own `#/...` references
|
|
615
|
+
* resolve against.
|
|
616
|
+
*
|
|
617
|
+
* Following an external reference enters another document, and a `$ref` inside
|
|
618
|
+
* it is relative to THAT document — which is the whole of how a shape declares
|
|
619
|
+
* its own vocabulary (`anyOf: [{$ref: "#/$defs/Text"}, …]`). Resolving those
|
|
620
|
+
* against the referring document finds nothing, and a walker that then treats
|
|
621
|
+
* the branches as unconstrained accepts every one of them, resolves the union
|
|
622
|
+
* to nothing, and hands the values underneath an untyped stand-in. So the base
|
|
623
|
+
* travels with the schema.
|
|
624
|
+
*/
|
|
625
|
+
export function resolveRefIn(
|
|
626
|
+
schema: Record<string, any>,
|
|
627
|
+
root: Record<string, any>,
|
|
628
|
+
external?: ExternalSchemaResolver,
|
|
629
|
+
): { schema: Record<string, any>; root: Record<string, any> } {
|
|
630
|
+
if (!schema.$ref || typeof schema.$ref !== "string") return { schema, root };
|
|
631
|
+
if (schema.$ref === "#") return { schema: root, root };
|
|
632
|
+
if (schema.$ref.startsWith("#/$defs/")) {
|
|
633
|
+
const resolved = root.$defs?.[schema.$ref.slice("#/$defs/".length)];
|
|
634
|
+
return resolved ? { schema: resolved, root } : { schema, root };
|
|
635
|
+
}
|
|
636
|
+
const target = external?.(schema.$ref);
|
|
637
|
+
return target ? { schema: target, root: target } : { schema, root };
|
|
651
638
|
}
|
|
652
639
|
|
|
640
|
+
/** Looks a registered schema up by its `$id`. */
|
|
641
|
+
export type ExternalSchemaResolver = (ref: string) => Record<string, any> | undefined;
|
|
642
|
+
|
|
653
643
|
/** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
|
|
654
644
|
/**
|
|
655
645
|
* The `oneOf` / `anyOf` branch a value is written against, when exactly one fits.
|
|
@@ -671,6 +661,7 @@ export function selectUnionBranch(
|
|
|
671
661
|
schema: Record<string, any>,
|
|
672
662
|
data: unknown,
|
|
673
663
|
root: Record<string, any>,
|
|
664
|
+
external?: ExternalSchemaResolver,
|
|
674
665
|
): Record<string, any> {
|
|
675
666
|
const branches = (schema.oneOf ?? schema.anyOf) as Record<string, any>[] | undefined;
|
|
676
667
|
if (!Array.isArray(branches) || branches.length === 0) return schema;
|
|
@@ -692,7 +683,7 @@ export function selectUnionBranch(
|
|
|
692
683
|
if (!kind) return schema;
|
|
693
684
|
|
|
694
685
|
const fits = branches
|
|
695
|
-
.map((b) => resolveRef(b, root))
|
|
686
|
+
.map((b) => resolveRef(b, root, external))
|
|
696
687
|
.filter((b) => {
|
|
697
688
|
const types = Array.isArray(b.type) ? b.type : b.type ? [b.type] : [];
|
|
698
689
|
if (types.length > 0 && !types.includes(kind)) return false;
|
|
@@ -733,10 +724,13 @@ export function collectProperties(schema: Record<string, any>): Record<string, a
|
|
|
733
724
|
|
|
734
725
|
/** Deep-clone `data`, replacing every pure CEL template string (`${{ expr }}`) with a
|
|
735
726
|
* schema-appropriate placeholder so AJV can validate non-CEL fields without false positives. */
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
727
|
+
/** Everything {@link substituteCelFields} does beyond walking the value.
|
|
728
|
+
*
|
|
729
|
+
* One object rather than trailing positionals: the resolver is the parameter a
|
|
730
|
+
* caller most needs and was the LAST of six, so reaching it meant counting
|
|
731
|
+
* `undefined`s — and a caller that stopped counting one short simply got the
|
|
732
|
+
* old blind behaviour, silently. Two of them did. */
|
|
733
|
+
export interface SubstituteOptions {
|
|
740
734
|
/** Called with the dotted path of every value replaced by a placeholder.
|
|
741
735
|
*
|
|
742
736
|
* A placeholder is a stand-in for something only known at runtime, so its
|
|
@@ -746,11 +740,28 @@ export function substituteCelFields(
|
|
|
746
740
|
* so making every placeholder acceptable is not achievable in general —
|
|
747
741
|
* knowing where not to look is. Structural findings survive because they are
|
|
748
742
|
* located at the CONTAINER, not at the substituted leaf. */
|
|
749
|
-
onSubstitute?: (path: string) => void
|
|
750
|
-
path
|
|
743
|
+
onSubstitute?: (path: string) => void;
|
|
744
|
+
/** Dotted path of `data` within the resource, for `onSubstitute`. */
|
|
745
|
+
path?: string;
|
|
746
|
+
/** Resolves a named shape (`telo:<module>/<Type>`) to its schema. Without it
|
|
747
|
+
* a slot described by one reads as undescribed and every CEL leaf beneath it
|
|
748
|
+
* is handed the typeless `""` stand-in — which the shape then rejects, so a
|
|
749
|
+
* perfectly valid expression is reported as a violation. */
|
|
750
|
+
external?: ExternalSchemaResolver;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function substituteCelFields(
|
|
754
|
+
data: unknown,
|
|
755
|
+
schema: Record<string, any>,
|
|
756
|
+
rootSchema?: Record<string, any>,
|
|
757
|
+
options: SubstituteOptions = {},
|
|
751
758
|
): unknown {
|
|
752
|
-
const
|
|
753
|
-
const
|
|
759
|
+
const { onSubstitute, external } = options;
|
|
760
|
+
const path = options.path ?? "";
|
|
761
|
+
const base = rootSchema ?? schema;
|
|
762
|
+
const entered = resolveRefIn(schema, base, external);
|
|
763
|
+
const root = entered.root;
|
|
764
|
+
const resolved = selectUnionBranch(entered.schema, data, root, external);
|
|
754
765
|
const mark = () => onSubstitute?.(path);
|
|
755
766
|
|
|
756
767
|
if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
@@ -789,9 +800,13 @@ export function substituteCelFields(
|
|
|
789
800
|
return celPlaceholderForSchema(resolved);
|
|
790
801
|
}
|
|
791
802
|
if (Array.isArray(data)) {
|
|
792
|
-
const
|
|
793
|
-
return data.map((
|
|
794
|
-
substituteCelFields(
|
|
803
|
+
const item = resolveRefIn((resolved.items ?? {}) as Record<string, any>, root, external);
|
|
804
|
+
return data.map((element, i) =>
|
|
805
|
+
substituteCelFields(element, item.schema, item.root, {
|
|
806
|
+
onSubstitute,
|
|
807
|
+
path: `${path}[${i}]`,
|
|
808
|
+
external,
|
|
809
|
+
}),
|
|
795
810
|
);
|
|
796
811
|
}
|
|
797
812
|
if (data !== null && typeof data === "object") {
|
|
@@ -802,13 +817,11 @@ export function substituteCelFields(
|
|
|
802
817
|
: undefined;
|
|
803
818
|
const result: Record<string, unknown> = {};
|
|
804
819
|
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
|
805
|
-
result[k] = substituteCelFields(
|
|
806
|
-
v,
|
|
807
|
-
(props[k] ?? addlProps ?? {}) as Record<string, any>,
|
|
808
|
-
root,
|
|
820
|
+
result[k] = substituteCelFields(v, (props[k] ?? addlProps ?? {}) as Record<string, any>, root, {
|
|
809
821
|
onSubstitute,
|
|
810
|
-
path ? `${path}.${k}` : k,
|
|
811
|
-
|
|
822
|
+
path: path ? `${path}.${k}` : k,
|
|
823
|
+
external,
|
|
824
|
+
});
|
|
812
825
|
}
|
|
813
826
|
return result;
|
|
814
827
|
}
|