@telorun/analyzer 0.63.0 → 0.65.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 (64) hide show
  1. package/dist/analysis-registry.d.ts +24 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +35 -0
  4. package/dist/analyzer.d.ts +3 -37
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +76 -470
  7. package/dist/cel-scope-query.d.ts +109 -0
  8. package/dist/cel-scope-query.d.ts.map +1 -0
  9. package/dist/cel-scope-query.js +270 -0
  10. package/dist/cel-scope.d.ts +166 -0
  11. package/dist/cel-scope.d.ts.map +1 -0
  12. package/dist/cel-scope.js +377 -0
  13. package/dist/definition-registry.d.ts +38 -6
  14. package/dist/definition-registry.d.ts.map +1 -1
  15. package/dist/definition-registry.js +66 -22
  16. package/dist/find-manifest.d.ts +10 -0
  17. package/dist/find-manifest.d.ts.map +1 -0
  18. package/dist/find-manifest.js +12 -0
  19. package/dist/index.d.ts +11 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +11 -1
  22. package/dist/invocation-contract.d.ts +11 -0
  23. package/dist/invocation-contract.d.ts.map +1 -1
  24. package/dist/invocation-contract.js +15 -0
  25. package/dist/manifest-analysis.d.ts +73 -0
  26. package/dist/manifest-analysis.d.ts.map +1 -0
  27. package/dist/manifest-analysis.js +78 -0
  28. package/dist/manifest-path.d.ts +18 -0
  29. package/dist/manifest-path.d.ts.map +1 -0
  30. package/dist/manifest-path.js +37 -0
  31. package/dist/schema-compat.d.ts +59 -22
  32. package/dist/schema-compat.d.ts.map +1 -1
  33. package/dist/schema-compat.js +60 -75
  34. package/dist/schema-error-report.d.ts +68 -0
  35. package/dist/schema-error-report.d.ts.map +1 -0
  36. package/dist/schema-error-report.js +356 -0
  37. package/dist/schema-walk.d.ts +25 -0
  38. package/dist/schema-walk.d.ts.map +1 -0
  39. package/dist/schema-walk.js +126 -0
  40. package/dist/telo-version.d.ts +1 -1
  41. package/dist/telo-version.js +1 -1
  42. package/dist/validate-nested-inline.d.ts +22 -1
  43. package/dist/validate-nested-inline.d.ts.map +1 -1
  44. package/dist/validate-nested-inline.js +17 -9
  45. package/dist/validate-step-inputs.d.ts +17 -0
  46. package/dist/validate-step-inputs.d.ts.map +1 -1
  47. package/dist/validate-step-inputs.js +108 -9
  48. package/package.json +2 -2
  49. package/src/analysis-registry.ts +37 -0
  50. package/src/analyzer.ts +83 -587
  51. package/src/cel-scope-query.ts +337 -0
  52. package/src/cel-scope.ts +570 -0
  53. package/src/definition-registry.ts +79 -24
  54. package/src/find-manifest.ts +19 -0
  55. package/src/index.ts +23 -2
  56. package/src/invocation-contract.ts +22 -0
  57. package/src/manifest-analysis.ts +132 -0
  58. package/src/manifest-path.ts +34 -0
  59. package/src/schema-compat.ts +92 -79
  60. package/src/schema-error-report.ts +417 -0
  61. package/src/schema-walk.ts +144 -0
  62. package/src/telo-version.ts +1 -1
  63. package/src/validate-nested-inline.ts +35 -14
  64. package/src/validate-step-inputs.ts +153 -11
@@ -0,0 +1,417 @@
1
+ /**
2
+ * The one renderer for AJV validation failures.
3
+ *
4
+ * Browser-safe and re-imported by the kernel — the split `buildEvalPaths` and
5
+ * the redaction path parser already use — so a failure is phrased identically
6
+ * under `telo check` and at runtime. Three implementations used to answer this
7
+ * (the analyzer's keyword prose, the kernel's raw `instancePath + message`
8
+ * join, and observed state's own inline variant), so a developer who fixed what
9
+ * the analyzer told them met a different sentence describing the same thing.
10
+ *
11
+ * UNION REDUCTION is the second half. A union must attempt every branch, and
12
+ * AJV cannot know which one was intended — `discriminator: true` works only
13
+ * against an explicit OpenAPI-style discriminator property, which would mean
14
+ * changing what every module's authors write. So branch selection is a
15
+ * reporting concern and lives here.
16
+ *
17
+ * It narrows the error SET, never just the sentence: every consumer maps the
18
+ * surviving errors to manifest paths to anchor a diagnostic, so reducing at the
19
+ * prose layer alone would move the soup out of the message and into the
20
+ * problems list, one entry per branch on a different line.
21
+ *
22
+ * Selection is made from the ERRORS ALONE, never from the schema. A branch
23
+ * whose discriminating key is present emits no complaint at the union's own
24
+ * instancePath; one whose key is absent says `required`, and one that forbids a
25
+ * key the value carries says `additionalProperties`. That is the whole signal,
26
+ * and reading it off the errors is what lets reduction work across a `$ref`
27
+ * into another registered schema, where navigating to the branch subschema
28
+ * would mean re-implementing AJV's resolution.
29
+ */
30
+
31
+ /** An AJV error object. Structurally typed — the analyzer and the kernel hand
32
+ * over errors from their own AJV instances. */
33
+ export interface AjvErrorLike {
34
+ keyword?: string;
35
+ instancePath?: string;
36
+ schemaPath?: string;
37
+ message?: string;
38
+ params?: Record<string, any>;
39
+ data?: unknown;
40
+ }
41
+
42
+ /** A schema validation issue with a dotted-path pointer to the offending field. */
43
+ export interface SchemaIssue {
44
+ message: string;
45
+ /** Dotted path to the field (e.g. "config.handler"). Empty string means root. */
46
+ path: string;
47
+ }
48
+
49
+ const UNION_KEYWORDS = new Set(["anyOf", "oneOf"]);
50
+
51
+ /** Keywords a branch raises at the union's OWN instancePath when the value is
52
+ * not of that branch's shape at all — as opposed to being that shape and wrong
53
+ * further in. These are what make a branch implausible. */
54
+ const SHAPE_KEYWORDS = new Set(["required", "type", "additionalProperties", "enum", "const"]);
55
+
56
+ /* ------------------------------------------------------------------ prose */
57
+
58
+ export function formatSingleError(err: AjvErrorLike): string {
59
+ const p = err.instancePath || "/";
60
+ const params = err.params ?? {};
61
+ switch (err.keyword) {
62
+ case "additionalProperties":
63
+ return `${p} must NOT have additional properties ('${params.additionalProperty}' is not allowed)`;
64
+ case "required":
65
+ return `${p} is missing required property '${params.missingProperty}'`;
66
+ case "enum":
67
+ return `${p} ${err.message ?? "is invalid"} (${(params.allowedValues as unknown[])?.join(" | ")})`;
68
+ case "type":
69
+ return `${p} must be ${params.type}${describeActual(err)}`;
70
+ default:
71
+ return `${p} ${err.message ?? "is invalid"}`;
72
+ }
73
+ }
74
+
75
+ /** ` (got string)`, or nothing when the value is not in hand. AJV carries
76
+ * `data` only under `verbose`, and a reducer that navigated the root value
77
+ * would have to be given it at every call site; an absent actual type is worth
78
+ * less than a wrong one. */
79
+ function describeActual(err: AjvErrorLike): string {
80
+ if (!("data" in err)) return "";
81
+ const d = err.data;
82
+ if (d === null) return " (got null)";
83
+ if (Array.isArray(d)) return " (got array)";
84
+ return ` (got ${typeof d})`;
85
+ }
86
+
87
+ /* -------------------------------------------------------------- reduction */
88
+
89
+ /** The branch index a `schemaPath` sits under, for a union whose own schemaPath
90
+ * is `unionPath` (`…/anyOf`): a child is `…/anyOf/<i>/…` and nothing else can
91
+ * collide with it. */
92
+ function branchIndexUnder(unionPath: string, schemaPath: string | undefined): number | undefined {
93
+ if (!schemaPath || !schemaPath.startsWith(unionPath + "/")) return undefined;
94
+ const rest = schemaPath.slice(unionPath.length + 1);
95
+ const slash = rest.indexOf("/");
96
+ const head = slash === -1 ? rest : rest.slice(0, slash);
97
+ const index = Number(head);
98
+ return Number.isInteger(index) ? index : undefined;
99
+ }
100
+
101
+ function instanceDepth(path: string | undefined): number {
102
+ if (!path) return 0;
103
+ return path.split("/").filter((s) => s !== "").length;
104
+ }
105
+
106
+ /**
107
+ * How the alternatives at a union node are described, one phrase each.
108
+ *
109
+ * Read off the complaints made at the union's own node, and deliberately ONE
110
+ * PHRASE PER MISSING KEY rather than one per error group. AJV inlines most
111
+ * `$ref` branches and reports them all under the same bare `schemaPath`, so
112
+ * several branches are genuinely indistinguishable in the error set — joining
113
+ * their keys into a single phrase would read as one alternative demanding all
114
+ * of them, which is a claim about the schema that is simply false. Listing them
115
+ * separately under-specifies a branch that requires two keys at once, and each
116
+ * clause is still a true necessary condition; asserting a conjunction that does
117
+ * not exist is not.
118
+ */
119
+ function describeAlternatives(errors: AjvErrorLike[], unionInstancePath: string): string[] {
120
+ const own = errors.filter((e) => (e.instancePath || "") === unionInstancePath);
121
+ const phrases: string[] = [];
122
+ for (const e of own) {
123
+ if (e.keyword === "required") phrases.push(`one with '${e.params?.missingProperty}'`);
124
+ else if (e.keyword === "type") phrases.push(`a ${e.params?.type}`);
125
+ else if (e.keyword === "enum") {
126
+ phrases.push(`one of ${(e.params?.allowedValues as unknown[])?.join(" | ")}`);
127
+ }
128
+ }
129
+ return phrases.length > 0 ? phrases : ["another shape"];
130
+ }
131
+
132
+ /** Is this branch a plausible reading of the value — does it accept the value's
133
+ * shape at the union node itself, and only disagree further in? */
134
+ function isPlausible(errors: AjvErrorLike[], unionInstancePath: string): boolean {
135
+ return !errors.some(
136
+ (e) => (e.instancePath || "") === unionInstancePath && SHAPE_KEYWORDS.has(e.keyword ?? ""),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * A union OCCURRENCE — one union node reached at one place in the value.
142
+ *
143
+ * `schemaPath` alone does not identify one. A self-recursive shape (`$ref` back
144
+ * to the carrier root, which is how a container node holds children) reaches the
145
+ * SAME union schema at every depth, so every level's errors carry the identical
146
+ * `#/anyOf/<i>/…`. What separates them is `instancePath`, and an occurrence is
147
+ * therefore the pair.
148
+ */
149
+ interface Occurrence {
150
+ error: AjvErrorLike;
151
+ schemaPath: string;
152
+ instancePath: string;
153
+ /** Errors this occurrence's own branches raised. */
154
+ owned: AjvErrorLike[];
155
+ /** Occurrences reached THROUGH one of this one's branches. */
156
+ children: Occurrence[];
157
+ }
158
+
159
+ function isUnder(child: string, parent: string): boolean {
160
+ return parent === "" ? child !== "" : child.startsWith(parent + "/");
161
+ }
162
+
163
+ /** The value path one level up, or undefined at the root. `""` is the root, so
164
+ * a non-empty path with no separator has the root as its parent. */
165
+ function parentPath(path: string): string | undefined {
166
+ if (path === "") return undefined;
167
+ const cut = path.lastIndexOf("/");
168
+ return cut <= 0 ? "" : path.slice(0, cut);
169
+ }
170
+
171
+ /**
172
+ * Replace each failing union with the errors of the branch the author plainly
173
+ * meant, recursively, outside in.
174
+ *
175
+ * Attribution runs to the DEEPEST occurrence that could own an error, which is
176
+ * what keeps a container's own complaint apart from its child's when both carry
177
+ * the same `schemaPath`. An occurrence reached through a branch becomes a
178
+ * candidate branch of its own — it raised nothing at the parent's node, so it is
179
+ * plausible exactly when the value really did take that shape and fail further
180
+ * in, and reducing it recursively is what stops an inner union's alternatives
181
+ * from surviving inside the outer one's selection.
182
+ */
183
+ export function reduceSchemaErrors(errors: AjvErrorLike[] | null | undefined): AjvErrorLike[] {
184
+ if (!errors || errors.length === 0) return [];
185
+
186
+ const occurrences: Occurrence[] = errors
187
+ .filter((e) => UNION_KEYWORDS.has(e.keyword ?? "") && typeof e.schemaPath === "string")
188
+ .map((e) => ({
189
+ error: e,
190
+ schemaPath: e.schemaPath!,
191
+ instancePath: e.instancePath || "",
192
+ owned: [],
193
+ children: [],
194
+ }));
195
+ if (occurrences.length === 0) return errors;
196
+
197
+ // The VALUE NODE is the claim, not the schemaPath. A branch written as a
198
+ // `$ref` is reported by AJV under the TARGET's schemaPath — and AJV inlines
199
+ // some of them, reporting several branches under one identical path — so
200
+ // nothing in such an error points back at the union that dispatched to it. A
201
+ // large union is written exactly that way, a branch per `$defs` entry, so
202
+ // claiming by schemaPath alone would leave the biggest unions unreduced.
203
+ //
204
+ // Indexed by instancePath rather than scanned: an error is claimed by the
205
+ // DEEPEST occurrence enclosing it, which is found by walking that error's own
206
+ // path upwards — bounded by the path's depth instead of by the number of
207
+ // unions. The scan this replaced was O(errors × occurrences), and both grow
208
+ // with nesting depth on a recursive shape, on a path the editor runs per
209
+ // keystroke.
210
+ const byPath = new Map<string, Occurrence>();
211
+ const isOccurrence = new Set<AjvErrorLike>();
212
+ for (const o of occurrences) {
213
+ isOccurrence.add(o.error);
214
+ // Several unions can occur at ONE value node (a union inside a union
215
+ // branch); the first is kept, and the rest nest under it below.
216
+ if (!byPath.has(o.instancePath)) byPath.set(o.instancePath, o);
217
+ }
218
+
219
+ /** The nearest occurrence at or above `path`, excluding `path` itself when
220
+ * `strict` — which is how an occurrence finds its parent rather than itself. */
221
+ const enclosing = (path: string, strict: boolean): Occurrence | undefined => {
222
+ let current = strict ? parentPath(path) : path;
223
+ while (current !== undefined) {
224
+ const hit = byPath.get(current);
225
+ if (hit) return hit;
226
+ current = parentPath(current);
227
+ }
228
+ return undefined;
229
+ };
230
+
231
+ const owner = new Map<AjvErrorLike, Occurrence>();
232
+ for (const err of errors) {
233
+ if (isOccurrence.has(err)) continue;
234
+ const best = enclosing(err.instancePath || "", false);
235
+ if (best) {
236
+ best.owned.push(err);
237
+ owner.set(err, best);
238
+ }
239
+ }
240
+
241
+ // Nest occurrences the same way: an occurrence deeper in the value was reached
242
+ // through some branch of the nearest one enclosing it.
243
+ const roots: Occurrence[] = [];
244
+ for (const o of occurrences) {
245
+ const parent = o === byPath.get(o.instancePath)
246
+ ? enclosing(o.instancePath, true)
247
+ : byPath.get(o.instancePath);
248
+ if (parent && parent !== o) parent.children.push(o);
249
+ else roots.push(o);
250
+ }
251
+
252
+ const replaced = new Map<AjvErrorLike, AjvErrorLike[]>();
253
+ for (const root of roots) replaced.set(root.error, resolveOccurrence(root));
254
+
255
+ const out: AjvErrorLike[] = [];
256
+ for (const err of errors) {
257
+ const replacement = replaced.get(err);
258
+ if (replacement) {
259
+ out.push(...replacement);
260
+ continue;
261
+ }
262
+ // Everything an occurrence owns is spoken for by whichever branch survived,
263
+ // and a nested occurrence is carried inside its parent's selection.
264
+ if (owner.has(err)) continue;
265
+ if (occurrences.some((o) => o.error === err)) continue;
266
+ out.push(err);
267
+ }
268
+ return out;
269
+ }
270
+
271
+ interface Branch {
272
+ /** Declaration order, or `Infinity` for a nested occurrence, which has none. */
273
+ index: number;
274
+ errors: AjvErrorLike[];
275
+ /** True when this candidate is a nested occurrence rather than a branch that
276
+ * complained here — it is already reduced, so it is not reduced again. */
277
+ nested: boolean;
278
+ }
279
+
280
+ /** Groups one union's complaints into candidate readings of the value.
281
+ *
282
+ * The branch INDEX is used wherever the error carries it. It does not when the
283
+ * branch is a `$ref` — AJV reports under the target's schemaPath — so the
284
+ * fallback groups by the VALUE NODE each complaint is about: everything said
285
+ * about the union node itself is one candidate (those are the branches that
286
+ * rejected the value's shape outright), and each child node complained about is
287
+ * its own. That is the same question asked of the data instead of the schema,
288
+ * and it is what the ordering below actually reads. */
289
+ function groupCandidates(occurrence: Occurrence): Branch[] {
290
+ const byIndex = new Map<number, AjvErrorLike[]>();
291
+ const byNode = new Map<string, AjvErrorLike[]>();
292
+ for (const err of occurrence.owned) {
293
+ const index = branchIndexUnder(occurrence.schemaPath, err.schemaPath);
294
+ const bucket =
295
+ index === undefined
296
+ ? mapBucket(byNode, childSegment(err.instancePath || "", occurrence.instancePath))
297
+ : mapBucket(byIndex, index);
298
+ bucket.push(err);
299
+ }
300
+ return [...byIndex]
301
+ .map(([index, errs]) => ({ index, errors: errs, nested: false }))
302
+ .concat([...byNode].map(([, errs]) => ({ index: Number.MAX_SAFE_INTEGER, errors: errs, nested: false })))
303
+ .concat(
304
+ occurrence.children.map((child) => ({
305
+ index: Number.POSITIVE_INFINITY,
306
+ errors: resolveOccurrence(child),
307
+ nested: true,
308
+ })),
309
+ );
310
+ }
311
+
312
+ function mapBucket<K>(map: Map<K, AjvErrorLike[]>, key: K): AjvErrorLike[] {
313
+ const existing = map.get(key);
314
+ if (existing) return existing;
315
+ const created: AjvErrorLike[] = [];
316
+ map.set(key, created);
317
+ return created;
318
+ }
319
+
320
+ /** The first value-path segment below `parent`, or "" for the node itself. */
321
+ function childSegment(instancePath: string, parent: string): string {
322
+ if (!isUnder(instancePath, parent)) return "";
323
+ const rest = instancePath.slice(parent.length + 1);
324
+ const slash = rest.indexOf("/");
325
+ return slash === -1 ? rest : rest.slice(0, slash);
326
+ }
327
+
328
+ function resolveOccurrence(occurrence: Occurrence): AjvErrorLike[] {
329
+ const candidates = groupCandidates(occurrence);
330
+
331
+ // `oneOf` matching SEVERAL branches emits the union error with no branch
332
+ // errors at all — nothing was rejected, so there is no branch to select.
333
+ if (candidates.length === 0) return [occurrence.error];
334
+
335
+ const plausible = candidates.filter(
336
+ (c) => c.nested || isPlausible(c.errors, occurrence.instancePath),
337
+ );
338
+ if (plausible.length === 0) {
339
+ return [alternativesError(candidates, occurrence)];
340
+ }
341
+
342
+ // Deepest agreement first — a branch that matched further into the value is
343
+ // the one the author was writing — then the fewest complaints, then the
344
+ // declaration order, so the choice is stable.
345
+ plausible.sort((a, b) => {
346
+ const depth = maxDepth(b.errors) - maxDepth(a.errors);
347
+ if (depth !== 0) return depth;
348
+ if (a.errors.length !== b.errors.length) return a.errors.length - b.errors.length;
349
+ return a.index - b.index;
350
+ });
351
+ const winner = plausible[0];
352
+ return winner.nested ? winner.errors : reduceSchemaErrors(winner.errors);
353
+ }
354
+
355
+ function maxDepth(errors: AjvErrorLike[]): number {
356
+ let max = 0;
357
+ for (const e of errors) max = Math.max(max, instanceDepth(e.instancePath));
358
+ return max;
359
+ }
360
+
361
+ /** One error anchored at the union node, listing what could have gone there.
362
+ * The honest fallback: a confident wrong message is worse than the
363
+ * concatenation this replaces, so when no branch is a plausible reading the
364
+ * reader is told what the alternatives are rather than shown one branch's
365
+ * complaints as if it were the intended one. */
366
+ function alternativesError(candidates: Branch[], occurrence: Occurrence): AjvErrorLike {
367
+ const seen = new Set<string>();
368
+ const described: string[] = [];
369
+ for (const text of describeAlternatives(
370
+ candidates.flatMap((c) => c.errors),
371
+ occurrence.instancePath,
372
+ )) {
373
+ if (seen.has(text)) continue;
374
+ seen.add(text);
375
+ described.push(text);
376
+ }
377
+ return {
378
+ ...occurrence.error,
379
+ instancePath: occurrence.instancePath,
380
+ message: `matches no alternative — expected ${described.join(", or ")}`,
381
+ };
382
+ }
383
+
384
+ /* --------------------------------------------------------------- rendering */
385
+
386
+ /** Converts an AJV error to a dotted path compatible with PositionIndex keys.
387
+ * e.g. instancePath "/config/routes/0/handler" → "config.routes[0].handler"
388
+ * For "required" keyword errors, appends the missing property to the parent path. */
389
+ export function ajvErrorToPath(err: AjvErrorLike): string {
390
+ const instancePath = err.instancePath ?? "";
391
+ const parts = instancePath.split("/").filter((p) => p !== "");
392
+ let result = "";
393
+ for (const part of parts) {
394
+ if (/^\d+$/.test(part)) result += `[${part}]`;
395
+ else result += result ? `.${part}` : part;
396
+ }
397
+ if (err.keyword === "required" && err.params?.missingProperty) {
398
+ const missing = err.params.missingProperty as string;
399
+ result += result ? `.${missing}` : missing;
400
+ }
401
+ return result;
402
+ }
403
+
404
+ /** Reduced, path-anchored issues — what a diagnostic list is built from. */
405
+ export function schemaIssues(errors: AjvErrorLike[] | null | undefined): SchemaIssue[] {
406
+ return reduceSchemaErrors(errors).map((err) => ({
407
+ message: formatSingleError(err),
408
+ path: ajvErrorToPath(err),
409
+ }));
410
+ }
411
+
412
+ /** Reduced, rendered as one sentence — what a thrown runtime error carries. */
413
+ export function formatAjvErrors(errors: AjvErrorLike[] | null | undefined): string {
414
+ const reduced = reduceSchemaErrors(errors);
415
+ if (reduced.length === 0) return "Unknown schema error";
416
+ return reduced.map(formatSingleError).join("; ");
417
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Structural traversal over a kind's JSON Schema and the step arrays it
3
+ * declares. Nothing here analyzes: these answer "what does this schema node
4
+ * point at" and "how do steps nest", the two questions every analyzer pass
5
+ * asks before it can say anything.
6
+ *
7
+ * Its own file so the CEL scope rule (`cel-scope.ts`) and the analysis pass
8
+ * (`analyzer.ts`) can both reach it without either importing the other — the
9
+ * scope rule is consumed by the IDE, which must not pull the pass in behind it.
10
+ */
11
+ import { MANIFEST_SCHEMA_URI, ManifestRootSchema } from "./manifest-schemas.js";
12
+
13
+ /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
14
+ * Non-refs and unresolved refs pass through unchanged. */
15
+ export function resolveLocalRef(
16
+ schema: Record<string, any> | undefined,
17
+ root: Record<string, any>,
18
+ ): Record<string, any> | undefined {
19
+ if (!schema) return undefined;
20
+ const ref = schema.$ref;
21
+ if (typeof ref === "string" && ref.startsWith("#/$defs/")) {
22
+ const defName = ref.slice("#/$defs/".length);
23
+ const resolved = root.$defs?.[defName];
24
+ if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
25
+ }
26
+ // A kernel-owned structural fragment (`telo://manifest#/$defs/InvokeStep`).
27
+ // Resolved HERE rather than by each walker: this is the one chokepoint every
28
+ // structural walk already goes through — the step-array walks, the call graph,
29
+ // the zone projection, the eval-path collector — so a composer that points at a
30
+ // shared shape stays legible to all of them at once. Nothing is inlined into
31
+ // the stored schema, which keeps validator-cache identity stable and matches
32
+ // what `resolveSchemaTypeRefs` does for a named user type.
33
+ if (typeof ref === "string" && ref.startsWith(BUILTIN_FRAGMENT_PREFIX)) {
34
+ const defName = ref.slice(BUILTIN_FRAGMENT_PREFIX.length);
35
+ const resolved = (ManifestRootSchema.$defs as Record<string, unknown>)[defName];
36
+ if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
37
+ }
38
+ return schema;
39
+ }
40
+
41
+ const BUILTIN_FRAGMENT_PREFIX = `${MANIFEST_SCHEMA_URI}#/$defs/`;
42
+
43
+ /** Gather property schemas from a (possibly variant-bearing) object schema:
44
+ * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch.
45
+ *
46
+ * Each branch is resolved through {@link resolveLocalRef} first, so a branch
47
+ * that points at a shared shape — a `oneOf` arm that IS the kernel's dispatch
48
+ * site — contributes its properties like an inline one. Without that, pointing a
49
+ * composer at a shared shape would silently empty every role-driven lookup that
50
+ * reads this (the inputs slot, the retry policy, the eval paths), which is a
51
+ * failure with no diagnostic attached to it. */
52
+ export function gatherPropertySchemas(
53
+ schema: Record<string, any>,
54
+ root?: Record<string, any>,
55
+ ): Array<[string, Record<string, any>]> {
56
+ const out: Array<[string, Record<string, any>]> = [];
57
+ const base = resolveLocalRef(schema, root ?? schema) ?? schema;
58
+ if (base.properties && typeof base.properties === "object") {
59
+ for (const [k, v] of Object.entries(base.properties as Record<string, any>)) {
60
+ out.push([k, v as Record<string, any>]);
61
+ }
62
+ }
63
+ for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
64
+ const arr = base[variantKey];
65
+ if (!Array.isArray(arr)) continue;
66
+ for (const raw of arr) {
67
+ if (!raw || typeof raw !== "object") continue;
68
+ const variant = resolveLocalRef(raw as Record<string, any>, root ?? schema) ?? raw;
69
+ if (variant.properties) {
70
+ for (const [k, v] of Object.entries(variant.properties as Record<string, any>)) {
71
+ out.push([k, v as Record<string, any>]);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Generic, role-driven walk over a step array. Calls
81
+ * `visit(step, stepPath)` for every step — top-level and nested through the
82
+ * `x-telo-topology-role` forms (`branch`, `branch-list`, `case-map`). This is
83
+ * the single definition of how steps nest, shared by `buildStepContextSchema`
84
+ * (which types `steps.<name>.result`) and `validateStepInvokeReferences` (which
85
+ * checks invoke refs), so the topology contract lives in one place — adding a
86
+ * role or nesting form updates both consumers at once. No resource kind is
87
+ * hardcoded; recursion is driven entirely by the schema annotations.
88
+ */
89
+ export function walkStepArray(
90
+ steps: unknown[],
91
+ stepItemSchema: Record<string, any> | undefined,
92
+ rootSchema: Record<string, any>,
93
+ basePath: string,
94
+ visit: (step: Record<string, any>, stepPath: string) => void,
95
+ ): void {
96
+ const dispatchRole = (
97
+ data: unknown,
98
+ role: string,
99
+ itemsSchema: Record<string, any> | undefined,
100
+ path: string,
101
+ ): void => {
102
+ if (role === "branch" && Array.isArray(data)) {
103
+ walkStepArray(data, stepItemSchema, rootSchema, path, visit);
104
+ } else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
105
+ for (const [caseKey, arr] of Object.entries(data as Record<string, unknown>)) {
106
+ if (Array.isArray(arr)) walkStepArray(arr, stepItemSchema, rootSchema, `${path}.${caseKey}`, visit);
107
+ }
108
+ } else if (role === "branch-list" && Array.isArray(data)) {
109
+ const entrySchema = resolveLocalRef(itemsSchema, rootSchema);
110
+ if (!entrySchema) return;
111
+ data.forEach((entry, i) => {
112
+ if (!entry || typeof entry !== "object") return;
113
+ for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
114
+ const subRole = subSchema["x-telo-topology-role"];
115
+ if (typeof subRole !== "string") continue;
116
+ dispatchRole(
117
+ (entry as Record<string, any>)[subKey],
118
+ subRole,
119
+ subSchema.items as Record<string, any> | undefined,
120
+ `${path}[${i}].${subKey}`,
121
+ );
122
+ }
123
+ });
124
+ }
125
+ };
126
+
127
+ steps.forEach((step, i) => {
128
+ if (!step || typeof step !== "object") return;
129
+ const s = step as Record<string, any>;
130
+ const stepPath = `${basePath}[${i}]`;
131
+ visit(s, stepPath);
132
+ if (!stepItemSchema) return;
133
+ for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
134
+ const role = propSchema["x-telo-topology-role"];
135
+ if (typeof role !== "string") continue;
136
+ dispatchRole(
137
+ s[propKey],
138
+ role,
139
+ propSchema.items as Record<string, any> | undefined,
140
+ `${stepPath}.${propKey}`,
141
+ );
142
+ }
143
+ });
144
+ }
@@ -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.79.0";
9
+ export const TELO_SURFACE_VERSION = "0.82.0";
@@ -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) {