@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.
Files changed (114) hide show
  1. package/dist/analyzer.d.ts +1 -1
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +175 -65
  4. package/dist/call-graph.d.ts.map +1 -1
  5. package/dist/call-graph.js +7 -1
  6. package/dist/cel-environment.d.ts +12 -0
  7. package/dist/cel-environment.d.ts.map +1 -1
  8. package/dist/cel-environment.js +35 -19
  9. package/dist/cel-scope.d.ts.map +1 -1
  10. package/dist/cel-scope.js +24 -4
  11. package/dist/definition-registry.d.ts +47 -6
  12. package/dist/definition-registry.d.ts.map +1 -1
  13. package/dist/definition-registry.js +82 -13
  14. package/dist/eval-paths.d.ts +68 -0
  15. package/dist/eval-paths.d.ts.map +1 -1
  16. package/dist/eval-paths.js +138 -0
  17. package/dist/flatten-for-analyzer.d.ts +1 -1
  18. package/dist/flatten-for-analyzer.js +1 -1
  19. package/dist/import-resolution-diagnostics.d.ts +1 -1
  20. package/dist/import-resolution-diagnostics.js +1 -1
  21. package/dist/index.d.ts +13 -9
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +8 -6
  24. package/dist/manifest-visitor.d.ts.map +1 -1
  25. package/dist/manifest-visitor.js +8 -1
  26. package/dist/migrations/entries/index.d.ts.map +1 -1
  27. package/dist/migrations/entries/index.js +2 -0
  28. package/dist/migrations/entries/schema-prepare-bucket.json +27 -0
  29. package/dist/peer-binding.d.ts +232 -0
  30. package/dist/peer-binding.d.ts.map +1 -0
  31. package/dist/peer-binding.js +418 -0
  32. package/dist/ref-slot.d.ts +13 -0
  33. package/dist/ref-slot.d.ts.map +1 -1
  34. package/dist/ref-slot.js +26 -0
  35. package/dist/reference-field-map.d.ts +28 -0
  36. package/dist/reference-field-map.d.ts.map +1 -1
  37. package/dist/reference-field-map.js +25 -0
  38. package/dist/referrer-rule.d.ts +17 -0
  39. package/dist/referrer-rule.d.ts.map +1 -1
  40. package/dist/referrer-rule.js +4 -1
  41. package/dist/release/index.d.ts +1 -1
  42. package/dist/release/index.js +1 -1
  43. package/dist/resource-rule.d.ts +40 -27
  44. package/dist/resource-rule.d.ts.map +1 -1
  45. package/dist/resource-rule.js +61 -7
  46. package/dist/rule-condition.d.ts +11 -0
  47. package/dist/rule-condition.d.ts.map +1 -1
  48. package/dist/rule-condition.js +13 -0
  49. package/dist/schema-compat.d.ts +59 -22
  50. package/dist/schema-compat.d.ts.map +1 -1
  51. package/dist/schema-compat.js +60 -75
  52. package/dist/schema-error-report.d.ts +68 -0
  53. package/dist/schema-error-report.d.ts.map +1 -0
  54. package/dist/schema-error-report.js +356 -0
  55. package/dist/schema-projection.d.ts +106 -4
  56. package/dist/schema-projection.d.ts.map +1 -1
  57. package/dist/schema-projection.js +206 -18
  58. package/dist/telo-version.d.ts +1 -1
  59. package/dist/telo-version.js +1 -1
  60. package/dist/validate-cel-context.d.ts +1 -17
  61. package/dist/validate-cel-context.d.ts.map +1 -1
  62. package/dist/validate-cel-context.js +5 -70
  63. package/dist/validate-nested-inline.d.ts +22 -1
  64. package/dist/validate-nested-inline.d.ts.map +1 -1
  65. package/dist/validate-nested-inline.js +17 -9
  66. package/dist/validate-reference-forms.d.ts +10 -0
  67. package/dist/validate-reference-forms.d.ts.map +1 -1
  68. package/dist/validate-reference-forms.js +23 -0
  69. package/dist/validate-references.d.ts.map +1 -1
  70. package/dist/validate-references.js +9 -2
  71. package/dist/validate-referrer-rules.d.ts +38 -4
  72. package/dist/validate-referrer-rules.d.ts.map +1 -1
  73. package/dist/validate-referrer-rules.js +139 -31
  74. package/dist/validate-resource-rules.d.ts +11 -2
  75. package/dist/validate-resource-rules.d.ts.map +1 -1
  76. package/dist/validate-resource-rules.js +18 -12
  77. package/dist/validate-schema-projection.d.ts.map +1 -1
  78. package/dist/validate-schema-projection.js +33 -2
  79. package/dist/validate-step-inputs.js +11 -3
  80. package/dist/with-synthetic-positions.d.ts +1 -1
  81. package/dist/with-synthetic-positions.js +1 -1
  82. package/package.json +3 -3
  83. package/src/analyzer.ts +211 -68
  84. package/src/call-graph.ts +7 -1
  85. package/src/cel-environment.ts +48 -19
  86. package/src/cel-scope.ts +24 -6
  87. package/src/definition-registry.ts +90 -11
  88. package/src/eval-paths.ts +152 -0
  89. package/src/flatten-for-analyzer.ts +1 -1
  90. package/src/import-resolution-diagnostics.ts +1 -1
  91. package/src/index.ts +55 -6
  92. package/src/manifest-visitor.ts +8 -1
  93. package/src/migrations/entries/index.ts +2 -0
  94. package/src/migrations/entries/schema-prepare-bucket.json +27 -0
  95. package/src/peer-binding.ts +641 -0
  96. package/src/ref-slot.ts +36 -0
  97. package/src/reference-field-map.ts +42 -0
  98. package/src/referrer-rule.ts +20 -1
  99. package/src/release/index.ts +1 -1
  100. package/src/resource-rule.ts +69 -7
  101. package/src/rule-condition.ts +15 -0
  102. package/src/schema-compat.ts +92 -79
  103. package/src/schema-error-report.ts +417 -0
  104. package/src/schema-projection.ts +283 -20
  105. package/src/telo-version.ts +1 -1
  106. package/src/validate-cel-context.ts +6 -71
  107. package/src/validate-nested-inline.ts +35 -14
  108. package/src/validate-reference-forms.ts +22 -0
  109. package/src/validate-references.ts +15 -2
  110. package/src/validate-referrer-rules.ts +215 -28
  111. package/src/validate-resource-rules.ts +26 -12
  112. package/src/validate-schema-projection.ts +50 -1
  113. package/src/validate-step-inputs.ts +10 -4
  114. 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);
@@ -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,
@@ -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 the telo editor can
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
@@ -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
- return isObject(value) && (value.__compiled === true || value.__tagged === true);
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
- * Path of the first CEL leaf inside a value, or `undefined` when every leaf is
184
- * literal. A rule reading an expression would be evaluating a placeholder, so
185
- * the subject is skipped — and the skip is reported, never silent.
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 = ""): string | undefined {
251
+ export function findDynamicLeaf(value: unknown, base = ""): DynamicLeaf | undefined {
191
252
  if (isObject(value)) {
192
- if (isCelNode(value)) return base || "(value)";
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}`);
@@ -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),
@@ -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 function formatSingleError(err: any): string {
259
- const p = err.instancePath || "/";
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 ?? []).map((err: any) => ({
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
- /** Resolve a `$ref` (only `#/$defs/...` form) against the root schema. */
644
- export function resolveRef(schema: Record<string, any>, root: Record<string, any>): Record<string, any> {
645
- if (schema.$ref && typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
646
- const defName = schema.$ref.slice("#/$defs/".length);
647
- const resolved = root.$defs?.[defName];
648
- if (resolved) return resolved;
649
- }
650
- return schema;
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
- export function substituteCelFields(
737
- data: unknown,
738
- schema: Record<string, any>,
739
- rootSchema?: Record<string, any>,
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 root = rootSchema ?? schema;
753
- const resolved = selectUnionBranch(resolveRef(schema, root), data, root);
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 itemSchema = resolveRef((resolved.items ?? {}) as Record<string, any>, root);
793
- return data.map((item, i) =>
794
- substituteCelFields(item, itemSchema, root, onSubstitute, `${path}[${i}]`),
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
  }