@telorun/analyzer 0.47.0 → 0.49.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 (58) hide show
  1. package/dist/analysis-registry.d.ts +22 -11
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +36 -39
  4. package/dist/analyzer.d.ts +38 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +121 -83
  7. package/dist/artifact-layer-index.d.ts +55 -0
  8. package/dist/artifact-layer-index.d.ts.map +1 -0
  9. package/dist/artifact-layer-index.js +116 -0
  10. package/dist/artifact-selector.d.ts +81 -0
  11. package/dist/artifact-selector.d.ts.map +1 -0
  12. package/dist/artifact-selector.js +122 -0
  13. package/dist/builtins.d.ts.map +1 -1
  14. package/dist/builtins.js +130 -20
  15. package/dist/extends-resolution.d.ts +41 -0
  16. package/dist/extends-resolution.d.ts.map +1 -1
  17. package/dist/extends-resolution.js +68 -0
  18. package/dist/index.d.ts +9 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +5 -1
  21. package/dist/invocation-contract.d.ts +100 -0
  22. package/dist/invocation-contract.d.ts.map +1 -0
  23. package/dist/invocation-contract.js +208 -0
  24. package/dist/schema-compat.d.ts +12 -4
  25. package/dist/schema-compat.d.ts.map +1 -1
  26. package/dist/schema-compat.js +185 -9
  27. package/dist/validate-base-mapping.js +11 -1
  28. package/dist/validate-cel-context.d.ts +0 -6
  29. package/dist/validate-cel-context.d.ts.map +1 -1
  30. package/dist/validate-cel-context.js +51 -4
  31. package/dist/validate-invocation-contract.d.ts +30 -0
  32. package/dist/validate-invocation-contract.d.ts.map +1 -0
  33. package/dist/validate-invocation-contract.js +394 -0
  34. package/dist/validate-module-artifact.d.ts +27 -0
  35. package/dist/validate-module-artifact.d.ts.map +1 -0
  36. package/dist/validate-module-artifact.js +131 -0
  37. package/dist/validate-step-inputs.d.ts +24 -0
  38. package/dist/validate-step-inputs.d.ts.map +1 -0
  39. package/dist/validate-step-inputs.js +87 -0
  40. package/dist/validate-throws-coverage.d.ts +1 -1
  41. package/dist/validate-throws-coverage.d.ts.map +1 -1
  42. package/dist/validate-throws-coverage.js +9 -1
  43. package/package.json +2 -2
  44. package/src/analysis-registry.ts +44 -34
  45. package/src/analyzer.ts +177 -100
  46. package/src/artifact-layer-index.ts +162 -0
  47. package/src/artifact-selector.ts +171 -0
  48. package/src/builtins.ts +135 -20
  49. package/src/extends-resolution.ts +86 -0
  50. package/src/index.ts +38 -1
  51. package/src/invocation-contract.ts +275 -0
  52. package/src/schema-compat.ts +191 -8
  53. package/src/validate-base-mapping.ts +14 -1
  54. package/src/validate-cel-context.ts +49 -4
  55. package/src/validate-invocation-contract.ts +450 -0
  56. package/src/validate-module-artifact.ts +141 -0
  57. package/src/validate-step-inputs.ts +117 -0
  58. package/src/validate-throws-coverage.ts +12 -2
package/src/index.ts CHANGED
@@ -38,15 +38,27 @@ export { moduleScopedDefResolver, scopeResolverForModule } from "./alias-resolve
38
38
  export type { ModuleScopes } from "./alias-resolver.js";
39
39
  export {
40
40
  ancestorChain,
41
+ contractDeclarer,
41
42
  controllerBearingAncestor,
42
43
  effectiveAuthorSchema,
44
+ effectiveContractField,
43
45
  effectiveStatusSchema,
44
46
  hasOwnControllerOrTemplate,
45
47
  inheritedCapability,
46
48
  isInheritedDelegation,
49
+ mappingFieldFor,
50
+ needsContractMapping,
47
51
  resolveParent,
48
52
  } from "./extends-resolution.js";
49
- export type { DefResolver } from "./extends-resolution.js";
53
+ export type { ContractDirection, DefResolver } from "./extends-resolution.js";
54
+ export {
55
+ defaultBearingPaths,
56
+ PERMISSIVE_CONTRACT,
57
+ resolveContract,
58
+ resolveContractSchema,
59
+ withStreamPropertiesSkipped,
60
+ } from "./invocation-contract.js";
61
+ export type { ContractOrigin, ContractScope, ResolvedContract } from "./invocation-contract.js";
50
62
  export {
51
63
  hasIntermediateWildcard,
52
64
  parseRedactionPath,
@@ -120,6 +132,31 @@ export {
120
132
  urlManifestCacheCoords,
121
133
  } from "./sources/manifest-cache.js";
122
134
  export type { ManifestCacheCoords } from "./sources/manifest-cache.js";
135
+ export {
136
+ LAYER_ROLES,
137
+ PLATFORM_AXES,
138
+ ArtifactSelectorError,
139
+ describeSelector,
140
+ isLayerRole,
141
+ normalizeSelector,
142
+ selectorFromQualifiers,
143
+ selectorKey,
144
+ selectorMatches,
145
+ } from "./artifact-selector.js";
146
+ export type {
147
+ ArtifactSelector,
148
+ LayerRole,
149
+ PlatformAxis,
150
+ PlatformTarget,
151
+ } from "./artifact-selector.js";
152
+ export {
153
+ LayerIndexError,
154
+ matchControllerLayers,
155
+ parseLayerIndex,
156
+ singletonLayer,
157
+ } from "./artifact-layer-index.js";
158
+ export type { ArtifactLayer } from "./artifact-layer-index.js";
159
+ export { validateModuleArtifact } from "./validate-module-artifact.js";
123
160
  export { withSyntheticPositions } from "./with-synthetic-positions.js";
124
161
  export { documentToAst, parseToAst } from "./yaml-ast.js";
125
162
  export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";
@@ -0,0 +1,275 @@
1
+ import type { ResourceDefinition } from "@telorun/sdk";
2
+ import {
3
+ type ContractDirection,
4
+ contractDeclarer,
5
+ type DefResolver,
6
+ effectiveContractField,
7
+ } from "./extends-resolution.js";
8
+ import { resolveTypeFieldToSchema } from "./validate-cel-context.js";
9
+
10
+ export type { ContractDirection };
11
+
12
+ /**
13
+ * The one answer to "what is this target's input / output schema".
14
+ *
15
+ * Both halves of Telo consume it: `telo check` validates call sites against it,
16
+ * and the kernel binds it to the instance at creation. It lives here rather than
17
+ * in the kernel because it must be browser-safe and because a second
18
+ * implementation would drift — the same split already used for
19
+ * `buildEvalPaths` / `evalPathCovers` and the redaction path parser. Before this
20
+ * there were three one-hop lookups (the analysis registry's editor helpers, the
21
+ * template-body `inputs` typing, the step-context definition fallback) and a
22
+ * runtime that consulted only an explicitly-passed type ref, so static analysis
23
+ * and dispatch could disagree about the very contract they were both checking.
24
+ */
25
+
26
+ /** Where a resolved contract was declared. Instance-level declarations let one
27
+ * call site narrow a kind's contract (`JS.Script` does this); kind-level ones
28
+ * are the kind's own signature. */
29
+ export type ContractOrigin = "instance" | "kind";
30
+
31
+ export interface ResolvedContract {
32
+ /** The JSON Schema a value is validated against. */
33
+ schema: Record<string, any>;
34
+ origin: ContractOrigin;
35
+ /** The definition that declared it, when `origin` is `"kind"` — the scope its
36
+ * named type references resolved in, and what a diagnostic should name. */
37
+ declaredBy?: ResourceDefinition;
38
+ }
39
+
40
+ export interface ContractScope {
41
+ /** Resolves a kind to its definition. Must be scoped to the module that
42
+ * DECLARED the definition being walked — `extends` aliases are lexical, so a
43
+ * chain crossing module boundaries re-scopes at each hop. */
44
+ resolveDefinition: DefResolver;
45
+ /**
46
+ * Manifests a named `telo#Type` reference resolves against, given the
47
+ * definition that DECLARED the type field.
48
+ *
49
+ * The parameter exists for a caller that keeps types per module. The analyzer
50
+ * does not: it works from one flattened list where names are already unique
51
+ * per module, so it ignores the argument and returns that list. A caller
52
+ * holding several scopes uses it to avoid resolving a bare name against the
53
+ * wrong module's type of the same name.
54
+ */
55
+ typeManifestsFor(def: ResourceDefinition | undefined): Record<string, any>[];
56
+ }
57
+
58
+ /** The fallback for a target that declares no contract: anything goes. Not
59
+ * `additionalProperties: false` — an undeclared contract is an absence of a
60
+ * claim, not a claim of emptiness. */
61
+ export const PERMISSIVE_CONTRACT: Record<string, any> = {
62
+ type: "object",
63
+ additionalProperties: true,
64
+ };
65
+
66
+ /**
67
+ * Resolve a dispatch target's contract, layering:
68
+ * 1. the **instance manifest's** own `inputType:` / `outputType:` — per-call-site
69
+ * narrowing, opted into simply by the kind declaring the property;
70
+ * 2. the **kind's** contract, resolved to the nearest declaration along
71
+ * `extends` (see {@link effectiveContractField} — nearest wins, no merge);
72
+ * 3. undefined — the caller decides whether that means permissive.
73
+ *
74
+ * Returns undefined rather than {@link PERMISSIVE_CONTRACT} so a caller can tell
75
+ * "declared nothing" from "declared anything", which the wiring rule and the
76
+ * `run()` guard both need.
77
+ */
78
+ export function resolveContract(
79
+ direction: ContractDirection,
80
+ manifest: Record<string, any> | undefined,
81
+ definition: ResourceDefinition | undefined,
82
+ scope: ContractScope,
83
+ ): ResolvedContract | undefined {
84
+ const own = manifest?.[direction];
85
+ if (own !== undefined && own !== null) {
86
+ const schema = resolveTypeFieldToSchema(own, scope.typeManifestsFor(definition));
87
+ if (schema) return { schema, origin: "instance" };
88
+ }
89
+
90
+ const declared = effectiveContractField(definition, scope.resolveDefinition, direction);
91
+ if (declared === undefined || declared === null) return undefined;
92
+ const declarer = contractDeclarer(definition, scope.resolveDefinition, direction);
93
+ const schema = resolveTypeFieldToSchema(declared, scope.typeManifestsFor(declarer));
94
+ if (!schema) return undefined;
95
+ return { schema, origin: "kind", declaredBy: declarer };
96
+ }
97
+
98
+ /** {@link resolveContract}, falling back to {@link PERMISSIVE_CONTRACT}. For
99
+ * callers that need a schema unconditionally (CEL context typing), as opposed
100
+ * to needing to know whether one was declared. */
101
+ export function resolveContractSchema(
102
+ direction: ContractDirection,
103
+ manifest: Record<string, any> | undefined,
104
+ definition: ResourceDefinition | undefined,
105
+ scope: ContractScope,
106
+ ): Record<string, any> {
107
+ return resolveContract(direction, manifest, definition, scope)?.schema ?? PERMISSIVE_CONTRACT;
108
+ }
109
+
110
+ /**
111
+ * A copy of `schema` with every `x-telo-stream`-marked property removed from
112
+ * `properties` and `required`, for validating a runtime value against.
113
+ *
114
+ * Streams travel in BOTH directions — `Codec.Encoder` marks `input` on its
115
+ * `inputType` and lists it in `required`, and `Record.Stream`, `Ai`, `Tar` and
116
+ * `Console` do the same — so a one-directional skip would walk a live `Stream`
117
+ * with AJV on the hottest path in the runtime. That is the same defect as
118
+ * `stripCompiledValues` walking a live `ResourceInstance` in a ref slot: a live
119
+ * object in a declared slot is not data to be traversed. The annotation already
120
+ * marks exactly the properties to leave alone.
121
+ *
122
+ * Structural (returns a new object, never mutates), and shared so the analyzer
123
+ * and the kernel exempt the same set.
124
+ */
125
+ export function withStreamPropertiesSkipped(
126
+ schema: Record<string, any>,
127
+ /** Resolves a `$ref` to the schema it names. Required to see through the
128
+ * reference form the runtime deliberately KEEPS intact for its validator: a
129
+ * contract written as `{ $ref: "telo:mod/Type" }` has none of its own
130
+ * properties, so a walk that cannot follow the reference exempts nothing and
131
+ * the stream is traversed after all. */
132
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
133
+ ): Record<string, any> {
134
+ return stripStreams(schema, [], resolveRef);
135
+ }
136
+
137
+ function stripStreams(
138
+ node: unknown,
139
+ // A PATH-scoped guard, not a global memo: a schema object reached twice from
140
+ // different parents must be stripped twice (a global `seen` would hand the
141
+ // second parent the unstripped original), while a cycle must still terminate.
142
+ path: readonly object[],
143
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
144
+ ): any {
145
+ if (Array.isArray(node)) {
146
+ let changed = false;
147
+ const items = node.map((item) => {
148
+ const next = stripStreams(item, path, resolveRef);
149
+ if (next !== item) changed = true;
150
+ return next;
151
+ });
152
+ return changed ? items : node;
153
+ }
154
+ if (!node || typeof node !== "object") return node;
155
+ let schema = node as Record<string, any>;
156
+ if (path.includes(schema)) return schema;
157
+
158
+ // Follow a whole-document reference to SEE the annotations behind it, but
159
+ // return the original node when nothing behind it was stripped. Substituting
160
+ // the resolved target unconditionally would break schema identity — the
161
+ // compiled-validator cache is keyed on it — and would move the target out of
162
+ // the document whose `$defs` its own internal `$ref`s resolve against.
163
+ if (resolveRef && typeof schema.$ref === "string") {
164
+ const target = resolveRef(schema.$ref);
165
+ if (target && !path.includes(target)) {
166
+ const stripped = stripStreams(target, [...path, schema], resolveRef);
167
+ if (stripped === target) return node;
168
+ const { $ref: _ref, ...siblings } = schema;
169
+ return Object.keys(siblings).length > 0 ? { ...stripped, ...siblings } : stripped;
170
+ }
171
+ }
172
+ const here = [...path, schema];
173
+
174
+ let out: Record<string, any> = schema;
175
+ const properties = schema.properties as Record<string, any> | undefined;
176
+ if (properties) {
177
+ // A stream can be contributed by an `allOf` branch too (how type inheritance
178
+ // is expressed before the branches are merged), so the marked set is read
179
+ // from the folded view while the removal is applied here.
180
+ const streamed = Object.keys(properties).filter(
181
+ (key) => (properties[key] as Record<string, any> | undefined)?.["x-telo-stream"],
182
+ );
183
+ if (streamed.length > 0) {
184
+ const kept: Record<string, any> = {};
185
+ for (const [key, value] of Object.entries(properties)) {
186
+ if (!streamed.includes(key)) kept[key] = value;
187
+ }
188
+ // The key stays DECLARED but unconstrained, rather than being deleted.
189
+ // Deleting it would force `additionalProperties: false` open, and a closed
190
+ // contract would stop rejecting unknown keys the moment it grew a stream —
191
+ // trading one exemption for a hole across the whole shape.
192
+ for (const key of streamed) kept[key] = {};
193
+ out = { ...schema, properties: kept };
194
+ }
195
+ }
196
+
197
+ // Recurse: a stream one level down (an item, a branch, a nested object) is as
198
+ // live as one at the root, and walking it with AJV is the same defect.
199
+ //
200
+ // `properties` and `$defs` are MAPS of schemas, not schemas — descending into
201
+ // them as if they were would visit nothing, since a map has none of the
202
+ // keywords this walk looks for.
203
+ let changed = out !== schema;
204
+ const result: Record<string, any> = { ...out };
205
+ for (const key of ["properties", "$defs"] as const) {
206
+ const map = out[key] as Record<string, any> | undefined;
207
+ if (!map || typeof map !== "object") continue;
208
+ let mapChanged = false;
209
+ const next: Record<string, any> = {};
210
+ for (const [name, child] of Object.entries(map)) {
211
+ const stripped = stripStreams(child, here, resolveRef);
212
+ if (stripped !== child) mapChanged = true;
213
+ next[name] = stripped;
214
+ }
215
+ if (mapChanged) {
216
+ result[key] = next;
217
+ changed = true;
218
+ }
219
+ }
220
+ for (const key of ["items", "allOf", "anyOf", "oneOf"] as const) {
221
+ const child = out[key];
222
+ if (child === undefined) continue;
223
+ const next = stripStreams(child, here, resolveRef);
224
+ if (next !== child) {
225
+ result[key] = next;
226
+ changed = true;
227
+ }
228
+ }
229
+ return changed ? result : schema;
230
+ }
231
+
232
+ /** Every property path in `schema` that can receive a `default:` — the paths a
233
+ * defaults pass may write to, and therefore exactly how far the caller's inputs
234
+ * must be copied before AJV's `useDefaults` runs. A flat shallow copy would not
235
+ * do: `useDefaults` writes at every level it finds a default, so a nested
236
+ * default would mutate the structure the caller still holds. Bounded by the
237
+ * schema's defaults rather than by the size of the payload. */
238
+ export function defaultBearingPaths(
239
+ schema: Record<string, any>,
240
+ /** See {@link withStreamPropertiesSkipped} — a contract kept in `$ref` form
241
+ * declares its defaults behind the reference, and a walk that cannot follow
242
+ * it would report none, leaving the caller's data shared where a fill lands. */
243
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
244
+ ): string[][] {
245
+ const out: string[][] = [];
246
+
247
+ // Path-scoped, for the same reason as the stream walk: a shared subschema
248
+ // reached from two parents contributes a path under each.
249
+ const walk = (node: unknown, path: string[], chain: readonly object[]): void => {
250
+ if (!node || typeof node !== "object") return;
251
+ let s = node as Record<string, any>;
252
+ if (chain.includes(s)) return;
253
+ if (resolveRef && typeof s.$ref === "string") {
254
+ const target = resolveRef(s.$ref);
255
+ if (!target || chain.includes(target)) return;
256
+ s = { ...target, ...s, $ref: undefined };
257
+ }
258
+ const here = [...chain, node as object];
259
+
260
+ if ("default" in s && path.length > 0) out.push(path);
261
+
262
+ const properties = s.properties as Record<string, any> | undefined;
263
+ if (properties) {
264
+ for (const [key, child] of Object.entries(properties)) walk(child, [...path, key], here);
265
+ }
266
+ for (const branch of ["allOf", "anyOf", "oneOf"] as const) {
267
+ const list = s[branch];
268
+ if (Array.isArray(list)) for (const child of list) walk(child, path, here);
269
+ }
270
+ if (s.items) walk(s.items, [...path, "[]"], here);
271
+ };
272
+
273
+ walk(schema, [], []);
274
+ return out;
275
+ }
@@ -306,7 +306,71 @@ export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<strin
306
306
  }
307
307
 
308
308
  /** Return a literal placeholder value of the correct schema type for AJV. */
309
- export function celPlaceholderForSchema(schema: Record<string, any>): unknown {
309
+ /** A number inside the schema's declared bounds. The placeholder stands in for a
310
+ * value only known at runtime, so its single job is to be ACCEPTABLE — a bare 0
311
+ * into an `exclusiveMinimum: 0` field (a scale, a positive dimension) would
312
+ * report a violation against a value the author never wrote. Bounds are read in
313
+ * the order that pins the value: an inclusive minimum is usable as-is, an
314
+ * exclusive one needs a step past it, and a wholly-negative range needs the
315
+ * maximum end instead. */
316
+ function numericPlaceholder(schema: Record<string, any>): number {
317
+ const isInteger = schema.type === "integer";
318
+ // One step past an exclusive bound. Integral for both `integer` and `number`:
319
+ // any value inside the band will do, and a whole number is inside it whenever
320
+ // a fractional one is (the narrow-band case below handles when it is not).
321
+ const step = 1;
322
+ if (typeof schema.minimum === "number") return schema.minimum;
323
+ if (typeof schema.exclusiveMinimum === "number") {
324
+ const candidate = schema.exclusiveMinimum + step;
325
+ if (typeof schema.maximum === "number" && candidate > schema.maximum) {
326
+ // A narrow band (0 < x <= 0.5) has no integral step; take the midpoint,
327
+ // which the band's own definition guarantees is inside it.
328
+ return isInteger ? schema.maximum : (schema.exclusiveMinimum + schema.maximum) / 2;
329
+ }
330
+ return candidate;
331
+ }
332
+ if (typeof schema.maximum === "number" && schema.maximum < 0) return schema.maximum;
333
+ if (typeof schema.exclusiveMaximum === "number" && schema.exclusiveMaximum <= 0) {
334
+ return schema.exclusiveMaximum - step;
335
+ }
336
+ return 0;
337
+ }
338
+
339
+ /** The constraints a placeholder must satisfy, folded across `allOf` branches.
340
+ * Inheritance between types is expressed by intersecting `allOf`, so a bound a
341
+ * parent declared lives in a branch rather than on the property itself — a
342
+ * placeholder that reads only the top level would violate it and report against
343
+ * a value the author never wrote. The tightest bound wins, which is what the
344
+ * intersection means. */
345
+ function foldedConstraints(schema: Record<string, any>): Record<string, any> {
346
+ const branches = Array.isArray(schema.allOf) ? (schema.allOf as Record<string, any>[]) : [];
347
+ if (branches.length === 0) return schema;
348
+ const out: Record<string, any> = { ...schema };
349
+ for (const branch of branches) {
350
+ const folded = foldedConstraints(branch);
351
+ for (const key of ["minimum", "exclusiveMinimum", "minLength", "minItems"] as const) {
352
+ if (typeof folded[key] === "number" && (typeof out[key] !== "number" || folded[key] > out[key])) {
353
+ out[key] = folded[key];
354
+ }
355
+ }
356
+ for (const key of ["maximum", "exclusiveMaximum"] as const) {
357
+ if (typeof folded[key] === "number" && (typeof out[key] !== "number" || folded[key] < out[key])) {
358
+ out[key] = folded[key];
359
+ }
360
+ }
361
+ if (out.type === undefined && folded.type !== undefined) out.type = folded.type;
362
+ if (out.enum === undefined && folded.enum !== undefined) out.enum = folded.enum;
363
+ if (out.default === undefined && folded.default !== undefined) out.default = folded.default;
364
+ if (folded.required) {
365
+ out.required = [...new Set([...(out.required ?? []), ...folded.required])];
366
+ }
367
+ if (folded.properties) out.properties = { ...folded.properties, ...(out.properties ?? {}) };
368
+ }
369
+ return out;
370
+ }
371
+
372
+ export function celPlaceholderForSchema(rawSchema: Record<string, any>): unknown {
373
+ const schema = foldedConstraints(rawSchema);
310
374
  if (schema.default !== undefined) return schema.default;
311
375
  // An enum-constrained field needs a placeholder drawn from the enum: the
312
376
  // type-based fallbacks below ("" for a string, 0 for a number) satisfy `type`
@@ -318,20 +382,49 @@ export function celPlaceholderForSchema(schema: Record<string, any>): unknown {
318
382
  switch (schema.type) {
319
383
  case "integer":
320
384
  case "number":
321
- return schema.minimum ?? 0;
385
+ return numericPlaceholder(schema);
322
386
  case "string":
323
- return "";
387
+ // `minLength` is the string analogue of `minimum`: a bare "" into a
388
+ // `minLength: 1` field would report a violation against a value the author
389
+ // never wrote. Any string of the right length will do.
390
+ return typeof schema.minLength === "number" && schema.minLength > 0
391
+ ? "x".repeat(schema.minLength)
392
+ : "";
324
393
  case "boolean":
325
394
  return false;
326
395
  case "array":
327
- return [];
396
+ // `minItems` is the array analogue of `minimum` / `minLength`: an empty
397
+ // array into a `minItems: 1` field would report a violation against a
398
+ // value the author never wrote.
399
+ return typeof schema.minItems === "number" && schema.minItems > 0
400
+ ? Array.from({ length: schema.minItems }, () =>
401
+ celPlaceholderForSchema((schema.items ?? {}) as Record<string, any>),
402
+ )
403
+ : [];
328
404
  case "object":
329
- return {};
405
+ return objectPlaceholder(schema);
330
406
  default:
331
407
  return null;
332
408
  }
333
409
  }
334
410
 
411
+ /** An object satisfying the schema's `required` list. A bare `{}` would report
412
+ * every required property as missing against a value the author never wrote —
413
+ * the case where a whole map is produced by one expression (`inputs: !cel
414
+ * "buildRequest(...)"`), which is exactly when the analyzer knows least and
415
+ * should say least. Members are filled recursively by the same rule, so a
416
+ * required nested object is satisfied too. */
417
+ function objectPlaceholder(schema: Record<string, any>): Record<string, unknown> {
418
+ const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
419
+ if (required.length === 0) return {};
420
+ const properties = (schema.properties ?? {}) as Record<string, Record<string, any>>;
421
+ const out: Record<string, unknown> = {};
422
+ for (const key of required) {
423
+ out[key] = celPlaceholderForSchema(properties[key] ?? {});
424
+ }
425
+ return out;
426
+ }
427
+
335
428
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
336
429
 
337
430
  /** Resolve a `$ref` (only `#/$defs/...` form) against the root schema. */
@@ -345,8 +438,76 @@ export function resolveRef(schema: Record<string, any>, root: Record<string, any
345
438
  }
346
439
 
347
440
  /** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
441
+ /**
442
+ * The `oneOf` / `anyOf` branch a value is written against, when exactly one fits.
443
+ *
444
+ * A union carries no `type` / `properties` / `items` of its own, so a walker that
445
+ * ignores it descends with an empty schema and hands every CEL leaf underneath a
446
+ * `null` placeholder — which then fails every branch and reports a pile of
447
+ * violations against a value that is perfectly valid. Picking the branch first
448
+ * is what lets the leaves be typed.
449
+ *
450
+ * Selection is structural and conservative: a branch must agree with the data's
451
+ * kind, and for an object every `required` key must be present (which is what
452
+ * separates a `{type, text}` part from a `{type, data, mediaType}` one). If that
453
+ * leaves anything other than exactly one branch, the union is returned unchanged
454
+ * — an ambiguous union is one the analyzer should not resolve on the author's
455
+ * behalf.
456
+ */
457
+ function selectUnionBranch(
458
+ schema: Record<string, any>,
459
+ data: unknown,
460
+ root: Record<string, any>,
461
+ ): Record<string, any> {
462
+ const branches = (schema.oneOf ?? schema.anyOf) as Record<string, any>[] | undefined;
463
+ if (!Array.isArray(branches) || branches.length === 0) return schema;
464
+ if (schema.type !== undefined || schema.properties !== undefined) return schema;
465
+
466
+ const kind = Array.isArray(data)
467
+ ? "array"
468
+ : data === null
469
+ ? "null"
470
+ : typeof data === "object"
471
+ ? "object"
472
+ : typeof data === "string"
473
+ ? "string"
474
+ : typeof data === "number"
475
+ ? "number"
476
+ : typeof data === "boolean"
477
+ ? "boolean"
478
+ : undefined;
479
+ if (!kind) return schema;
480
+
481
+ const fits = branches
482
+ .map((b) => resolveRef(b, root))
483
+ .filter((b) => {
484
+ const types = Array.isArray(b.type) ? b.type : b.type ? [b.type] : [];
485
+ if (types.length > 0 && !types.includes(kind)) return false;
486
+ if (kind === "object" && Array.isArray(b.required)) {
487
+ const keys = Object.keys(data as Record<string, unknown>);
488
+ if (!(b.required as string[]).every((r) => keys.includes(r))) return false;
489
+ }
490
+ return true;
491
+ });
492
+ return fits.length === 1 ? fits[0]! : schema;
493
+ }
494
+
348
495
  export function collectProperties(schema: Record<string, any>): Record<string, any> {
349
496
  const props: Record<string, any> = { ...(schema.properties ?? {}) };
497
+ // `allOf` INTERSECTS, so a branch constraining a property constrains the
498
+ // property itself — type inheritance expresses an inherited bound exactly this
499
+ // way (`allOf: [{ properties: { score: { minimum: 10 } } }]`). Merging the
500
+ // branch's constraints into the property is what lets a placeholder for that
501
+ // property be built from the bound the value must actually satisfy; reading
502
+ // only the top level would produce one that violates it.
503
+ for (const sub of (schema.allOf ?? []) as Record<string, any>[]) {
504
+ if (!sub || typeof sub !== "object" || !sub.properties) continue;
505
+ for (const [k, v] of Object.entries(sub.properties as Record<string, any>)) {
506
+ props[k] = k in props ? { ...(props[k] as object), ...(v as object) } : v;
507
+ }
508
+ }
509
+ // `oneOf` / `anyOf` are alternatives, not constraints: a property seen in one
510
+ // branch is contributed only when no branch already declared it.
350
511
  for (const sub of schema.oneOf ?? schema.anyOf ?? []) {
351
512
  if (sub && typeof sub === "object" && sub.properties) {
352
513
  for (const [k, v] of Object.entries(sub.properties as Record<string, any>)) {
@@ -363,11 +524,24 @@ export function substituteCelFields(
363
524
  data: unknown,
364
525
  schema: Record<string, any>,
365
526
  rootSchema?: Record<string, any>,
527
+ /** Called with the dotted path of every value replaced by a placeholder.
528
+ *
529
+ * A placeholder is a stand-in for something only known at runtime, so its
530
+ * VALUE says nothing: a caller that judges constraints at these paths reports
531
+ * against a value no author wrote. Some constraints cannot be satisfied by
532
+ * construction at all (`pattern`, `format`, a `oneOf` of unrelated shapes),
533
+ * so making every placeholder acceptable is not achievable in general —
534
+ * knowing where not to look is. Structural findings survive because they are
535
+ * located at the CONTAINER, not at the substituted leaf. */
536
+ onSubstitute?: (path: string) => void,
537
+ path = "",
366
538
  ): unknown {
367
539
  const root = rootSchema ?? schema;
368
- const resolved = resolveRef(schema, root);
540
+ const resolved = selectUnionBranch(resolveRef(schema, root), data, root);
541
+ const mark = () => onSubstitute?.(path);
369
542
 
370
543
  if (typeof data === "string" && CEL_PURE_RE.test(data)) {
544
+ mark();
371
545
  return celPlaceholderForSchema(resolved);
372
546
  }
373
547
  // `!ref <name>` sentinels are identity markers, not runtime values —
@@ -381,11 +555,14 @@ export function substituteCelFields(
381
555
  return data;
382
556
  }
383
557
  if (isTaggedSentinel(data)) {
558
+ mark();
384
559
  return celPlaceholderForSchema(resolved);
385
560
  }
386
561
  if (Array.isArray(data)) {
387
562
  const itemSchema = resolveRef((resolved.items ?? {}) as Record<string, any>, root);
388
- return data.map((item) => substituteCelFields(item, itemSchema, root));
563
+ return data.map((item, i) =>
564
+ substituteCelFields(item, itemSchema, root, onSubstitute, `${path}[${i}]`),
565
+ );
389
566
  }
390
567
  if (data !== null && typeof data === "object") {
391
568
  const props = collectProperties(resolved);
@@ -395,7 +572,13 @@ export function substituteCelFields(
395
572
  : undefined;
396
573
  const result: Record<string, unknown> = {};
397
574
  for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
398
- result[k] = substituteCelFields(v, (props[k] ?? addlProps ?? {}) as Record<string, any>, root);
575
+ result[k] = substituteCelFields(
576
+ v,
577
+ (props[k] ?? addlProps ?? {}) as Record<string, any>,
578
+ root,
579
+ onSubstitute,
580
+ path ? `${path}.${k}` : k,
581
+ );
399
582
  }
400
583
  return result;
401
584
  }
@@ -92,6 +92,16 @@ interface CheckCtx {
92
92
  filePath: string | undefined;
93
93
  }
94
94
 
95
+ /** Keys the inherited controller supplies itself when it builds the parent
96
+ * manifest (`{ kind, metadata, ...base }`), so `base:` neither has to set them
97
+ * nor may. Without this exclusion a parent whose schema lists `metadata` in
98
+ * `required` — `JS.Script` does, the most natural parent for a remapping child —
99
+ * is unusable: omitting it is BASE_MISSING_REQUIRED and setting it is
100
+ * BASE_UNKNOWN_FIELD, since `metadata` is required but never a declared
101
+ * property. Only at the top level; a nested `metadata` field is the parent's
102
+ * own and is checked normally. */
103
+ const CONTROLLER_SUPPLIED_KEYS = new Set(["kind", "metadata"]);
104
+
95
105
  function checkObject(
96
106
  value: Record<string, unknown>,
97
107
  schema: Record<string, any>,
@@ -99,7 +109,10 @@ function checkObject(
99
109
  ctx: CheckCtx,
100
110
  ): void {
101
111
  const properties = (schema.properties ?? {}) as Record<string, Record<string, any>>;
102
- const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
112
+ const isRoot = path === "base";
113
+ const required = (Array.isArray(schema.required) ? (schema.required as string[]) : []).filter(
114
+ (name) => !(isRoot && CONTROLLER_SUPPLIED_KEYS.has(name)),
115
+ );
103
116
  const additionalFalse = schema.additionalProperties === false;
104
117
 
105
118
  for (const req of required) {