@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
@@ -35,6 +35,8 @@
35
35
  * derivation can reach.
36
36
  */
37
37
 
38
+ import { isRefSentinel } from "@telorun/templating";
39
+
38
40
  /** How a kind's entry collection projects to an object schema. */
39
41
  export interface SchemaProjection {
40
42
  /** JSON Pointer, from the resource root, to the entries. */
@@ -48,6 +50,33 @@ export interface SchemaProjection {
48
50
  readonly nullable?: string;
49
51
  /** Entry field that wraps the mapped node in an array. */
50
52
  readonly array?: string;
53
+ /**
54
+ * How an entry whose keyed field holds a REFERENCE projects.
55
+ *
56
+ * The map is keyed on the field's VALUE, and a reference is not a key, so a
57
+ * `type:` holding one falls through to this path. It is declared as data by the
58
+ * backend, which is what keeps the analyzer from learning that an enum exists:
59
+ * `from` names the field of the target declaration to read, `keyword` the
60
+ * schema keyword its values become, and `base` / `baseFrom` where the node's
61
+ * own type comes from — a literal for an engine whose named type IS its own
62
+ * base, a field of the target for one that declares a storage class.
63
+ *
64
+ * A backend that declares none projects exactly as it did before.
65
+ */
66
+ readonly reference?: ProjectionReference;
67
+ }
68
+
69
+ /** The reference path of a projection — see {@link SchemaProjection.reference}. */
70
+ export interface ProjectionReference {
71
+ /** Field of the TARGET declaration whose value the keyword takes. */
72
+ readonly from: string;
73
+ /** The JSON Schema keyword those values become (`enum`). */
74
+ readonly keyword: string;
75
+ /** The node the keyword is added to, written literally. */
76
+ readonly base?: Record<string, unknown>;
77
+ /** Field of the target declaration naming a value in the kind's own
78
+ * `x-telo-schema-map`, whose mapped node is the base. */
79
+ readonly baseFrom?: string;
51
80
  }
52
81
 
53
82
  export type SchemaMap = Readonly<Record<string, Record<string, unknown>>>;
@@ -71,6 +100,19 @@ export function readSchemaProjection(definition: unknown): SchemaProjection | un
71
100
  nameField: typeof raw.name === "string" ? raw.name : undefined,
72
101
  nullable: typeof raw.nullable === "string" ? raw.nullable : undefined,
73
102
  array: typeof raw.array === "string" ? raw.array : undefined,
103
+ reference: readProjectionReference(raw.reference),
104
+ };
105
+ }
106
+
107
+ function readProjectionReference(raw: unknown): ProjectionReference | undefined {
108
+ if (!isObject(raw)) return undefined;
109
+ const { from, keyword, base, baseFrom } = raw;
110
+ if (typeof from !== "string" || typeof keyword !== "string") return undefined;
111
+ return {
112
+ from,
113
+ keyword,
114
+ base: isObject(base) ? (base as Record<string, unknown>) : undefined,
115
+ baseFrom: typeof baseFrom === "string" ? baseFrom : undefined,
74
116
  };
75
117
  }
76
118
 
@@ -94,7 +136,35 @@ export function schemaProjectionIsMisplaced(definition: unknown): boolean {
94
136
  return isObject(schema) && schema["x-telo-schema-projection"] !== undefined;
95
137
  }
96
138
 
139
+ /**
140
+ * The schema node that CARRIES the value vocabulary — the node itself, or the
141
+ * branch of a union that declares the map.
142
+ *
143
+ * A slot unioning a closed value vocabulary with a reference keeps its map on the
144
+ * value branch, exactly as the ref-slot reader peels the same union for its
145
+ * constraint. Exported because the strict half checks the map against the same
146
+ * branch's `enum`, and two implementations of "which branch is the value one"
147
+ * would eventually disagree — silently, since the failure of missing one is a
148
+ * completeness check that quietly stops running.
149
+ */
150
+ export function schemaMapBranch(node: unknown): Record<string, unknown> | undefined {
151
+ if (!isObject(node)) return undefined;
152
+ if (node["x-telo-schema-map"] !== undefined) return node;
153
+ for (const key of ["oneOf", "anyOf"] as const) {
154
+ const branches = node[key];
155
+ if (!Array.isArray(branches)) continue;
156
+ for (const branch of branches) {
157
+ if (isObject(branch) && branch["x-telo-schema-map"] !== undefined) return branch;
158
+ }
159
+ }
160
+ return undefined;
161
+ }
162
+
97
163
  export function readSchemaMap(node: unknown): SchemaMap | undefined {
164
+ return ownSchemaMap(schemaMapBranch(node));
165
+ }
166
+
167
+ function ownSchemaMap(node: unknown): SchemaMap | undefined {
98
168
  if (!isObject(node)) return undefined;
99
169
  const raw = node["x-telo-schema-map"];
100
170
  if (!isObject(raw)) return undefined;
@@ -146,6 +216,76 @@ export function projectionKeyMap(
146
216
  return readSchemaMap(entry.properties[projection.key]);
147
217
  }
148
218
 
219
+ /**
220
+ * The node an entry whose keyed field holds a REFERENCE projects to.
221
+ *
222
+ * This is the one place a projection crosses to another declaration, and it is
223
+ * a deliberate exception to the projection's lossiness: length, precision and
224
+ * collation stop at the boundary because the database enforces them, while a
225
+ * domain crosses because it IS the type at the granularity a consumer acts on —
226
+ * the enum in a CRUD model's OpenAPI operation, a completion list in the editor,
227
+ * a filter a repository can reject before the query.
228
+ *
229
+ * **A reference that cannot be read projects OPEN, never to nothing**, and that
230
+ * is the opposite of the rule an unmapped VALUE follows. The two failures are
231
+ * not the same failure: an unmapped value is a gap in the kind's own vocabulary,
232
+ * so there is no entry to speak of, while an unreadable reference names an entry
233
+ * the declaration plainly HAS and only leaves its type unknown. Dropping it made
234
+ * the projection deny the entry exists — a table whose enum reference had a typo
235
+ * reported `'status' is not allowed` against a column declared three lines up,
236
+ * blaming the seed row for the reference's mistake. Open is the honest
237
+ * under-approximation, and the reason is reported alongside.
238
+ */
239
+ function referencedNode(
240
+ value: unknown,
241
+ entryName: string,
242
+ projection: SchemaProjection,
243
+ map: SchemaMap,
244
+ options?: {
245
+ readonly scope?: ProjectionScope;
246
+ readonly pointer?: string;
247
+ readonly failures?: ProjectionFailure[];
248
+ },
249
+ ): Record<string, unknown> | undefined {
250
+ const reference = projection.reference;
251
+ if (!reference || !isObject(value)) return undefined;
252
+ // Through the single reader, so the name in the diagnostic is the one the
253
+ // author wrote whichever shape the slot holds — reading `value.name` here
254
+ // reported `<unnamed>` for an unresolved `!ref`, which is precisely the case
255
+ // that produces the diagnostic.
256
+ const name = readProjectionRef(value)?.name ?? "<unnamed>";
257
+ const report = (): Record<string, unknown> => {
258
+ options?.failures?.push({
259
+ reason: "entry-reference",
260
+ // The EMPTY pointer means the projected declaration is the one carrying
261
+ // the diagnostic, so the entry's own path is a real anchor in that file —
262
+ // the column, not the document root. Any other pointer names a slot
263
+ // holding a reference to a DIFFERENT manifest, whose entry paths mean
264
+ // nothing here, so the slot stays the anchor.
265
+ pointer:
266
+ options?.pointer === ""
267
+ ? `${projection.entries}/${entryName}`
268
+ : (options?.pointer ?? projection.entries),
269
+ entry: entryName,
270
+ name,
271
+ });
272
+ return {};
273
+ };
274
+ const found = options?.scope?.resolveManifest(value);
275
+ if (!found || "ambiguous" in found) return report();
276
+
277
+ const values = (found.manifest as Record<string, unknown>)[reference.from];
278
+ if (!Array.isArray(values) || values.length === 0) return report();
279
+
280
+ let base: Record<string, unknown> | undefined = reference.base;
281
+ if (reference.baseFrom !== undefined) {
282
+ const declared = (found.manifest as Record<string, unknown>)[reference.baseFrom];
283
+ base = typeof declared === "string" ? map[declared] : undefined;
284
+ }
285
+ if (!base) return report();
286
+ return { ...base, [reference.keyword]: values };
287
+ }
288
+
149
289
  /**
150
290
  * Project one declaration to an object schema.
151
291
  *
@@ -164,6 +304,15 @@ export function projectEntries(
164
304
  manifest: unknown,
165
305
  projection: SchemaProjection,
166
306
  map: SchemaMap,
307
+ /** What a REFERENCE at the keyed field is resolved through, and where a
308
+ * failure to resolve one is reported. A caller with no scope cannot resolve
309
+ * one, so such an entry projects OPEN — present, untyped — rather than
310
+ * vanishing from the row. */
311
+ options?: {
312
+ readonly scope?: ProjectionScope;
313
+ readonly pointer?: string;
314
+ readonly failures?: ProjectionFailure[];
315
+ },
167
316
  ): Record<string, unknown> | undefined {
168
317
  const entries = navigate(manifest, projection.entries);
169
318
  if (entries === undefined) return undefined;
@@ -172,7 +321,10 @@ export function projectEntries(
172
321
  const consider = (name: string | undefined, entry: unknown): void => {
173
322
  if (!isObject(entry) || name === undefined) return;
174
323
  const key = entry[projection.key];
175
- const mapped = typeof key === "string" ? map[key] : undefined;
324
+ const mapped =
325
+ typeof key === "string"
326
+ ? map[key]
327
+ : referencedNode(key, name, projection, map, options);
176
328
  // A value with no map entry projects to nothing rather than to `any`: the
177
329
  // vocabulary is the kind's own enum, so an unmapped value is a gap in the
178
330
  // kind's declaration, not a shape to guess at.
@@ -215,10 +367,25 @@ export interface ProjectionRef {
215
367
  readonly alias?: string;
216
368
  }
217
369
 
218
- /** The `{kind, name, alias?}` reference a value holds, or undefined. Exported so
219
- * a host whose slot may hold EITHER shape can fall back to this reading. */
370
+ /**
371
+ * The `{kind, name, alias?}` reference a value holds, or undefined. Exported so
372
+ * a host whose slot may hold EITHER shape can fall back to this reading.
373
+ *
374
+ * The unresolved `!ref` SENTINEL is read too. `resolveRefSentinels` normally
375
+ * rewrites one before this pass, but not when the reference names nothing — and
376
+ * that is exactly when a projection failure is reported, so reading only the
377
+ * resolved shape made the diagnostic name the target `<unnamed>`, which is the
378
+ * one fact the author needed from it. A round-trip host (`compile` off) carries
379
+ * the sentinel for every reference, resolved or not.
380
+ */
220
381
  export function readProjectionRef(value: unknown): ProjectionRef | undefined {
221
382
  if (!isObject(value)) return undefined;
383
+ if (isRefSentinel(value)) {
384
+ const dot = value.source.indexOf(".");
385
+ return dot > 0
386
+ ? { name: value.source.slice(dot + 1), alias: value.source.slice(0, dot) }
387
+ : { name: value.source };
388
+ }
222
389
  const name = value.name;
223
390
  if (typeof name !== "string") return undefined;
224
391
  return {
@@ -306,11 +473,42 @@ export function manifestListScope(
306
473
  };
307
474
  }
308
475
 
476
+ /**
477
+ * Why a slot could not be typed from a projection.
478
+ *
479
+ * Each reason is a DIFFERENT repair, which is why the three ways a target can
480
+ * carry no usable projection are kept apart rather than collapsed into
481
+ * `no-projection`: that one message ("declares no 'x-telo-schema-projection'")
482
+ * was printed for a kind that declares one whose key field carries no map, and
483
+ * for a declaration whose entry collection is simply absent — accusing the wrong
484
+ * author of the wrong omission in both.
485
+ */
309
486
  export type ProjectionFailure =
310
487
  | { readonly reason: "no-ref"; readonly pointer: string }
311
488
  | { readonly reason: "unresolved"; readonly pointer: string; readonly name: string }
312
489
  | { readonly reason: "ambiguous"; readonly pointer: string; readonly name: string }
313
- | { readonly reason: "no-projection"; readonly pointer: string; readonly kind: string };
490
+ /** The target's KIND declares no `x-telo-schema-projection` at all. */
491
+ | { readonly reason: "no-projection"; readonly pointer: string; readonly kind: string }
492
+ /** It declares one, but the field it keys on carries no `x-telo-schema-map`. */
493
+ | { readonly reason: "no-projection-map"; readonly pointer: string; readonly kind: string }
494
+ /** Both are declared and the DECLARATION holds no entry collection to project
495
+ * — an absent `columns:`, or a value that is not a collection. */
496
+ | {
497
+ readonly reason: "no-entries";
498
+ readonly pointer: string;
499
+ readonly kind: string;
500
+ readonly entries: string;
501
+ }
502
+ /** An ENTRY of the projected declaration references a shape that could not be
503
+ * read. Reported rather than dropped: the entry would silently vanish from
504
+ * the projected row, so a consumer naming it would be told the property does
505
+ * not exist. */
506
+ | {
507
+ readonly reason: "entry-reference";
508
+ readonly pointer: string;
509
+ readonly entry: string;
510
+ readonly name: string;
511
+ };
314
512
 
315
513
  function refTarget(
316
514
  value: unknown,
@@ -331,10 +529,25 @@ function refTarget(
331
529
  return { manifest, definition };
332
530
  }
333
531
 
532
+ /** The declaration the annotation is written on, as a projection target. */
533
+ function ownTarget(
534
+ manifest: Record<string, any>,
535
+ scope: ProjectionScope,
536
+ ): { manifest: Record<string, any>; definition: Record<string, any> } | ProjectionFailure {
537
+ if (typeof manifest.kind !== "string") {
538
+ return { reason: "no-ref", pointer: "" };
539
+ }
540
+ const definition = scope.resolveDefinition(manifest.kind);
541
+ if (!definition) return { reason: "no-projection", pointer: "", kind: manifest.kind };
542
+ return { manifest, definition };
543
+ }
544
+
334
545
  export function describeProjectionFailure(failure: ProjectionFailure): string {
335
546
  switch (failure.reason) {
336
547
  case "no-ref":
337
- return `'${failure.pointer}' does not hold a reference, so there is no declaration to project.`;
548
+ return failure.pointer === ""
549
+ ? "this resource declares no 'kind:', so there is no definition to project it through."
550
+ : `'${failure.pointer}' does not hold a reference, so there is no declaration to project.`;
338
551
  case "unresolved":
339
552
  return `'${failure.pointer}' references '${failure.name}', which resolves to no resource.`;
340
553
  case "ambiguous":
@@ -347,6 +560,23 @@ export function describeProjectionFailure(failure: ProjectionFailure): string {
347
560
  `'${failure.pointer}' references a resource of kind '${failure.kind}', which declares no ` +
348
561
  `'x-telo-schema-projection' — so there is nothing for this slot to be typed from.`
349
562
  );
563
+ case "no-projection-map":
564
+ return (
565
+ `kind '${failure.kind}' declares an 'x-telo-schema-projection' whose key field carries ` +
566
+ `no 'x-telo-schema-map', so there is no vocabulary to project its entries through and ` +
567
+ `'${failure.pointer || "this declaration"}' cannot be typed from it.`
568
+ );
569
+ case "no-entries":
570
+ return (
571
+ `'${failure.entries}' holds no entry collection on this ${failure.kind}, so the ` +
572
+ `projection has nothing to type '${failure.pointer || "this declaration"}' from.`
573
+ );
574
+ case "entry-reference":
575
+ return (
576
+ `entry '${failure.entry}' at '${failure.pointer}' references '${failure.name}', which ` +
577
+ `resolves to no declaration this analysis can read — so that entry is projected as an ` +
578
+ `open value and nothing typed from it is checked against the shape it was meant to have.`
579
+ );
350
580
  }
351
581
  }
352
582
 
@@ -360,6 +590,16 @@ export function describeProjectionFailure(failure: ProjectionFailure): string {
360
590
  * `failures`, because degrading SILENTLY is the failure this whole mechanism
361
591
  * exists to move earlier: the consumer's contract quietly reopens and a
362
592
  * misspelled field passes `telo check` exactly as it did before.
593
+ *
594
+ * **A node that projected NOTHING is returned by IDENTITY**, and that is a
595
+ * correctness property of the caller rather than a micro-optimization:
596
+ * `DefinitionRegistry` memoizes a compiled AJV validator per schema OBJECT,
597
+ * because every resource of a kind is checked against the same one at keystroke
598
+ * time. Rebuilding each node unconditionally — which this did — misses that memo
599
+ * for every resource in the analysis, so AJV recompiled the whole kind schema
600
+ * once per resource: on `apps/hub` that was 197 compiles instead of 54, and 723
601
+ * ms instead of 97. Returning the input where nothing changed restores it for
602
+ * every kind that declares no projection at all, which is nearly all of them.
363
603
  */
364
604
  export function resolveSchemaProjections(
365
605
  schema: unknown,
@@ -368,36 +608,59 @@ export function resolveSchemaProjections(
368
608
  failures?: ProjectionFailure[],
369
609
  ): unknown {
370
610
  if (Array.isArray(schema)) {
371
- return schema.map((item) => resolveSchemaProjections(item, manifest, scope, failures));
611
+ let moved = false;
612
+ const items = schema.map((item) => {
613
+ const next = resolveSchemaProjections(item, manifest, scope, failures);
614
+ if (next !== item) moved = true;
615
+ return next;
616
+ });
617
+ return moved ? items : schema;
372
618
  }
373
619
  if (!isObject(schema)) return schema;
374
620
 
375
621
  const pointer = readProjectionFrom(schema);
376
- if (pointer && manifest) {
377
- const target = refTarget(navigate(manifest, pointer), scope, pointer);
622
+ if (pointer !== undefined && manifest) {
623
+ // The EMPTY pointer names the declaration this annotation is written on,
624
+ // rather than a slot holding a reference to another. Resolution is skipped
625
+ // because the declaration is already in hand — which is what lets a kind
626
+ // type its own data against its own entries (a table's seed rows against its
627
+ // columns), something no reference could reach.
628
+ const target =
629
+ pointer === ""
630
+ ? ownTarget(manifest, scope)
631
+ : refTarget(navigate(manifest, pointer), scope, pointer);
378
632
  if ("reason" in target) {
379
633
  failures?.push(target);
380
634
  } else {
635
+ const kind = String(target.manifest.kind ?? "<unknown>");
381
636
  const projection = readSchemaProjection(target.definition);
382
637
  const map = projection && projectionKeyMap(target.definition.schema, projection);
383
638
  const projected =
384
- projection && map ? projectEntries(target.manifest, projection, map) : undefined;
639
+ projection && map
640
+ ? projectEntries(target.manifest, projection, map, { scope, pointer, failures })
641
+ : undefined;
385
642
  if (projected) {
386
643
  const { ["x-telo-schema-projection-from"]: _dropped, ...rest } = schema;
387
644
  return { ...rest, ...projected };
388
645
  }
389
- failures?.push({
390
- reason: "no-projection",
391
- pointer,
392
- kind: String(target.manifest.kind ?? "<unknown>"),
393
- });
646
+ // Three distinct omissions, three repairs by three different authors: the
647
+ // kind declares no projection, the kind declares one the key field has no
648
+ // vocabulary for, or this DECLARATION simply lists no entries.
649
+ if (!projection) failures?.push({ reason: "no-projection", pointer, kind });
650
+ else if (!map) failures?.push({ reason: "no-projection-map", pointer, kind });
651
+ else {
652
+ failures?.push({ reason: "no-entries", pointer, kind, entries: projection.entries });
653
+ }
394
654
  }
395
655
  }
396
656
 
397
- return Object.fromEntries(
398
- Object.entries(schema).map(([key, value]) => [
399
- key,
400
- key.startsWith("x-telo-") ? value : resolveSchemaProjections(value, manifest, scope, failures),
401
- ]),
402
- );
657
+ let moved = false;
658
+ const entries = Object.entries(schema).map(([key, value]) => {
659
+ const next = key.startsWith("x-telo-")
660
+ ? value
661
+ : resolveSchemaProjections(value, manifest, scope, failures);
662
+ if (next !== value) moved = true;
663
+ return [key, next] as const;
664
+ });
665
+ return moved ? Object.fromEntries(entries) : schema;
403
666
  }
@@ -6,4 +6,4 @@
6
6
  // written against, and every kernel in every language reports the same scale.
7
7
 
8
8
  /** The surface generation this analyzer implements. */
9
- export const TELO_SURFACE_VERSION = "0.80.0";
9
+ export const TELO_SURFACE_VERSION = "0.82.1";
@@ -6,8 +6,13 @@ import {
6
6
  parseCanonicalTypeSchemaId,
7
7
  } from "@telorun/sdk";
8
8
  import { KERNEL_BUILTINS } from "./builtins.js";
9
- import { isStepSlot } from "./step-slot.js";
10
9
  import { withRefSlotsAsReadings } from "./ref-slot-reading.js";
10
+ // Where CEL is evaluated is one reader (`eval-paths.ts`), and the region half of
11
+ // it moved there so the scope walk and the `x-telo-eval` walk answer the same
12
+ // question in one place. Re-exported: this module is where every existing
13
+ // consumer imports them from.
14
+ export { extractCelRegionScopes, pathMatchesScope } from "./eval-paths.js";
15
+ import { pathMatchesScope } from "./eval-paths.js";
11
16
 
12
17
  export interface ContextResolveOpts {
13
18
  /** When provided, used to resolve `x-telo-context-from-root` annotations against the
@@ -282,35 +287,6 @@ function collectionBindingWithheld(
282
287
  return isLiveSlot(resolveCollectionSchema(manifestRoot, from, allManifests));
283
288
  }
284
289
 
285
- /**
286
- * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
287
- * falls within the scope of a context (e.g. "$.routes[*].inputs").
288
- *
289
- * The scope is matched directly (no sibling sharing): a context at "$.routes[*].inputs" only
290
- * applies to expressions whose path starts with "routes[N].inputs", not to other sibling fields.
291
- */
292
- export function pathMatchesScope(exprPath: string, scope: string): boolean {
293
- const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
294
- if (!stripped) return false;
295
-
296
- // Split on wildcard array segments; each [*] must match a concrete [N] in exprPath
297
- const parts = stripped.split("[*]");
298
- let remaining = exprPath;
299
- for (let i = 0; i < parts.length; i++) {
300
- const part = parts[i]!;
301
- if (!remaining.startsWith(part)) return false;
302
- remaining = remaining.slice(part.length);
303
- if (i < parts.length - 1) {
304
- // Expect a concrete array index like [0], [12], ...
305
- const m = remaining.match(/^\[\d+\]/);
306
- if (!m) return false;
307
- remaining = remaining.slice(m[0].length);
308
- }
309
- }
310
- // Expression must end here or continue into a child path
311
- return remaining === "" || remaining[0] === "." || remaining[0] === "[";
312
- }
313
-
314
290
  /**
315
291
  * Resolves `x-telo-context-*` annotations in a context schema using the concrete
316
292
  * manifest item (per-scope) and the manifest root.
@@ -579,47 +555,6 @@ export function extractContextsFromSchema(
579
555
  return all.sort((a, b) => b.scope.length - a.scope.length);
580
556
  }
581
557
 
582
- /** Schema keys that declare a CEL-bearing region: a field carrying any of these
583
- * is evaluated at runtime, so a `!cel` inside it (or a descendant) is live. A
584
- * STEP BODY is one too, and says so through the grammar its items point at
585
- * rather than through a key — {@link isStepSlot} reads either spelling. */
586
- const CEL_REGION_KEYS = [
587
- "x-telo-context",
588
- "x-telo-step-context",
589
- "x-telo-error-context",
590
- ] as const;
591
-
592
- /**
593
- * Walk a JSON Schema tree and collect the JSONPath scopes of every field that
594
- * declares a CEL-bearing region (`x-telo-context` / `x-telo-step-context` /
595
- * `x-telo-error-context`). Used — alongside `x-telo-eval` paths — to decide
596
- * whether a `!cel` expression sits in a slot the runtime actually evaluates.
597
- * Scopes use the same `$.a.b[*]` form as `extractContextsFromSchema`, matched
598
- * against expression paths with `pathMatchesScope`.
599
- */
600
- export function extractCelRegionScopes(schema: Record<string, any>, path = "$"): string[] {
601
- if (!schema || typeof schema !== "object") return [];
602
- const out: string[] = [];
603
-
604
- if (CEL_REGION_KEYS.some((k) => schema[k]) || isStepSlot(schema)) out.push(path);
605
-
606
- if (schema.properties) {
607
- for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
608
- out.push(...extractCelRegionScopes(value, `${path}.${key}`));
609
- }
610
- }
611
- if (schema.items && typeof schema.items === "object") {
612
- out.push(...extractCelRegionScopes(schema.items, `${path}[*]`));
613
- }
614
- for (const key of ["oneOf", "anyOf", "allOf"] as const) {
615
- if (Array.isArray(schema[key])) {
616
- for (const subschema of schema[key]) out.push(...extractCelRegionScopes(subschema, path));
617
- }
618
- }
619
-
620
- return out;
621
- }
622
-
623
558
  function collectContexts(
624
559
  schema: Record<string, any>,
625
560
  path: string,
@@ -1,11 +1,8 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { collectRefs, isInlineResource } from "./reference-field-map.js";
3
- import {
4
- collectProperties,
5
- resolveRef,
6
- substituteCelFields,
7
- validateAgainstSchema,
8
- } from "./schema-compat.js";
3
+ import type { ExternalSchemaResolver } from "./schema-compat.js";
4
+ import { collectProperties, resolveRef, substituteCelFields } from "./schema-compat.js";
5
+ import type { SchemaIssue } from "./schema-error-report.js";
9
6
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
10
7
  import { collectValueSchemaIssues } from "./validate-value-schema.js";
11
8
 
@@ -16,6 +13,22 @@ export interface InlineDefinitionLookup {
16
13
  (kind: string): { schema?: Record<string, any> } | undefined;
17
14
  }
18
15
 
16
+ /**
17
+ * The validator this pass checks an inline resource's config with, and the
18
+ * resolver that lets both it and the stand-in walk see through a named shape.
19
+ *
20
+ * Passed in rather than reached for: the module-level AJV this used has no
21
+ * registered shapes, so a kind whose `schema:` references one compiled nowhere
22
+ * and every inline declaration of it was silently unchecked — while the
23
+ * identical resource written standalone was checked, and the kernel rejected
24
+ * both at boot. Two validators answering one question is what allowed that, so
25
+ * the caller supplies the one that holds the shapes.
26
+ */
27
+ export interface InlineConfigValidator {
28
+ validate(data: unknown, schema: Record<string, any>): SchemaIssue[];
29
+ external: ExternalSchemaResolver;
30
+ }
31
+
19
32
  /**
20
33
  * Validates inline resources nested inside a resource body against their kind's
21
34
  * config schema. The per-resource walk in `analyze()` validates a resource's
@@ -41,7 +54,11 @@ export function validateNestedInlineResources(
41
54
  rootSchema: Record<string, any>,
42
55
  lookupDefinition: InlineDefinitionLookup,
43
56
  /** Needed to resolve a `telo#Type` field a value slot is validated against. */
44
- allManifests: Record<string, any>[] = [],
57
+ allManifests: Record<string, any>[],
58
+ /** REQUIRED, and deliberately not defaulted: a default would be a second
59
+ * validator answering the same question, and omitting it would silently stop
60
+ * checking rather than fail. */
61
+ validator: InlineConfigValidator,
45
62
  ): AnalysisDiagnostic[] {
46
63
  const diagnostics: AnalysisDiagnostic[] = [];
47
64
  const resource = { kind: manifest.kind, name: manifest.metadata?.name as string };
@@ -89,14 +106,18 @@ export function validateNestedInlineResources(
89
106
  ? (inline.metadata as Record<string, unknown>)
90
107
  : {};
91
108
  const data = { ...inline, metadata: { name: "__inline__", ...existingMeta } };
92
- const substituted = substituteCelFields(data, effectiveSchema, effectiveSchema);
93
- // The same two passes the top-level resource loop runs, so a kind's
94
- // guarantees don't depend on whether the author wrote it standalone or
95
- // inline (under a step's `invoke:`, or in a `with:` scope). `data` carries
96
- // the synthesized metadata; `x-telo-value-schema-from` reads sibling fields
97
- // off the resource, which are present either way.
109
+ const substituted = substituteCelFields(data, effectiveSchema, effectiveSchema, {
110
+ external: validator.external,
111
+ });
112
+ // The same two passes the top-level resource loop runs, on the same
113
+ // validator, so a kind's guarantees don't depend on whether the author wrote
114
+ // it standalone or inline (under a step's `invoke:`, or in a `with:` scope)
115
+ // — and CLAUDE.md mandates inline for a single-use resource, so inline is
116
+ // the common shape rather than the exception. `data` carries the synthesized
117
+ // metadata; `x-telo-value-schema-from` reads sibling fields off the
118
+ // resource, which are present either way.
98
119
  const inlineIssues = [
99
- ...validateAgainstSchema(substituted, effectiveSchema),
120
+ ...validator.validate(substituted, effectiveSchema),
100
121
  ...collectValueSchemaIssues(data, schema, allManifests),
101
122
  ];
102
123
  for (const issue of inlineIssues) {
@@ -3,6 +3,7 @@ import { isTaggedSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
5
  import { visitManifest } from "./manifest-visitor.js";
6
+ import { satisfiesValueBranch } from "./reference-field-map.js";
6
7
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
8
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
8
9
 
@@ -29,6 +30,16 @@ const SOURCE = "telo-analyzer";
29
30
  * Rejected, each with an actionable diagnostic pointing at `!ref`:
30
31
  * - the object form `{ kind, name }` (the old reference object), and
31
32
  * - a bare string (the old name / dotted-FQN reference).
33
+ *
34
+ * **A value-or-reference union slot is the one exception**, and it is decided by
35
+ * the value rather than by a new spelling: where the reference constraint is a
36
+ * *branch* of a union (`type:` on a column, holding either a storage class or a
37
+ * `!ref` to a declared enum), a SCALAR is a value and is left to that branch,
38
+ * while an object is still checked for the removed `{kind, name}` form unless a
39
+ * branch describes it. The rule guards a REMOVED spelling — a bare string is
40
+ * simply not a reference in Telo — so admitting one takes nothing away: what it
41
+ * costs is the answer when an author meant a reference and wrote a bare name,
42
+ * which against a closed branch AJV reports as an unknown value.
32
43
  */
33
44
  export function validateReferenceForms(
34
45
  resources: ResourceManifest[],
@@ -52,6 +63,17 @@ export function validateReferenceForms(
52
63
  // `!ref` and `!cel`/`${{ }}` sentinels are the supported shapes.
53
64
  if (isTaggedSentinel(value)) return;
54
65
 
66
+ // A SCALAR at a slot whose union has a value branch is a value, and the
67
+ // value branch is what judges it. Reporting a mistyped storage class as
68
+ // a malformed reference — "write it as '!ref txt'" — instructs the author
69
+ // to convert a typo into a reference, and AJV has already said what the
70
+ // value must be one of. The object form below is not ambiguous the same
71
+ // way: `{kind, name}` is the removed reference object, so it is still
72
+ // reported unless a branch genuinely describes that shape.
73
+ const hasValueBranch = (e.entry.valueBranches?.length ?? 0) > 0;
74
+ if (hasValueBranch && typeof value !== "object") return;
75
+ if (satisfiesValueBranch(value, e.entry.valueBranches, registry)) return;
76
+
55
77
  const r = e.source;
56
78
  const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
57
79
  const resourceData = { kind: r.kind, name: r.metadata!.name as string };