@telorun/analyzer 0.48.0 → 0.49.1

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 (50) 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 +115 -83
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +72 -1
  9. package/dist/extends-resolution.d.ts +41 -0
  10. package/dist/extends-resolution.d.ts.map +1 -1
  11. package/dist/extends-resolution.js +68 -0
  12. package/dist/index.d.ts +4 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/invocation-contract.d.ts +100 -0
  16. package/dist/invocation-contract.d.ts.map +1 -0
  17. package/dist/invocation-contract.js +208 -0
  18. package/dist/manifest-loader.d.ts +11 -0
  19. package/dist/manifest-loader.d.ts.map +1 -1
  20. package/dist/manifest-loader.js +20 -0
  21. package/dist/schema-compat.d.ts +12 -4
  22. package/dist/schema-compat.d.ts.map +1 -1
  23. package/dist/schema-compat.js +185 -9
  24. package/dist/validate-base-mapping.js +11 -1
  25. package/dist/validate-cel-context.d.ts +0 -6
  26. package/dist/validate-cel-context.d.ts.map +1 -1
  27. package/dist/validate-cel-context.js +51 -4
  28. package/dist/validate-invocation-contract.d.ts +30 -0
  29. package/dist/validate-invocation-contract.d.ts.map +1 -0
  30. package/dist/validate-invocation-contract.js +394 -0
  31. package/dist/validate-step-inputs.d.ts +24 -0
  32. package/dist/validate-step-inputs.d.ts.map +1 -0
  33. package/dist/validate-step-inputs.js +87 -0
  34. package/dist/validate-throws-coverage.d.ts +1 -1
  35. package/dist/validate-throws-coverage.d.ts.map +1 -1
  36. package/dist/validate-throws-coverage.js +9 -1
  37. package/package.json +3 -3
  38. package/src/analysis-registry.ts +44 -34
  39. package/src/analyzer.ts +171 -100
  40. package/src/builtins.ts +74 -1
  41. package/src/extends-resolution.ts +86 -0
  42. package/src/index.ts +13 -1
  43. package/src/invocation-contract.ts +275 -0
  44. package/src/manifest-loader.ts +20 -0
  45. package/src/schema-compat.ts +191 -8
  46. package/src/validate-base-mapping.ts +14 -1
  47. package/src/validate-cel-context.ts +49 -4
  48. package/src/validate-invocation-contract.ts +450 -0
  49. package/src/validate-step-inputs.ts +117 -0
  50. package/src/validate-throws-coverage.ts +12 -2
@@ -4,9 +4,10 @@ import { KERNEL_BUILTINS } from "./builtins.js";
4
4
  import { DefinitionRegistry } from "./definition-registry.js";
5
5
  import { computeSuggestKind, computeValidUserFacingKinds } from "./kind-suggest.js";
6
6
  import { visitManifest as runVisitManifest, type ManifestVisitor } from "./manifest-visitor.js";
7
+ import type { ContractDirection, DefResolver } from "./extends-resolution.js";
8
+ import { resolveContract } from "./invocation-contract.js";
7
9
  import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
8
10
  import type { AnalysisContext } from "./types.js";
9
- import { resolveTypeFieldToSchema } from "./validate-cel-context.js";
10
11
 
11
12
  /** One reference field declared by a resource's definition, derived purely from
12
13
  * the schema field map (independent of whether the manifest fills it). Editor
@@ -133,46 +134,44 @@ export class AnalysisRegistry {
133
134
  }
134
135
 
135
136
  /** Resolves the JSON Schema for a kind's `invoke()` inputs, for editor hosts
136
- * that render a typed inputs form. Two-layer fallback mirroring the analyzer's
137
- * template inputs typing: the definition's own `inputType`, then the
138
- * `extends`-declared abstract's `inputType`. Resolves the inline
139
- * (`{ kind: Type.JsonSchema, schema }`) and raw-schema forms; a bare named
140
- * type reference is left unresolved (returns undefined) so the caller can fall
141
- * back to a freeform map. Undefined when the kind declares no input contract. */
137
+ * that render a typed inputs form. A thin wrapper over the shared contract
138
+ * resolver, so the form an editor renders is the contract `telo check` and the
139
+ * kernel enforce — nearest declaration along `extends`, replacing rather than
140
+ * merging. A bare named type reference is left unresolved (returns undefined,
141
+ * no manifests are in scope here) so the caller can fall back to a freeform
142
+ * map. Undefined when the kind declares no input contract. */
142
143
  inputTypeForKind(kind: string): Record<string, unknown> | undefined {
143
- const def = this.resolveDefinition(kind);
144
- if (!def) return undefined;
145
- const own = resolveTypeFieldToSchema(def.inputType, []);
146
- if (own) return own;
147
- if (def.extends) {
148
- const abstractDef = this.resolveDefinition(def.extends);
149
- if (abstractDef) {
150
- const inherited = resolveTypeFieldToSchema(abstractDef.inputType, []);
151
- if (inherited) return inherited;
152
- }
153
- }
154
- return undefined;
144
+ return this.contractForKind(kind, "inputType");
155
145
  }
156
146
 
157
- /** Resolves the JSON Schema for a kind's `invoke()` / `run()` output, for
147
+ /** Resolves the JSON Schema for a kind's `invoke()` / `provide()` output, for
158
148
  * editor hosts that render a typed output signature. Mirrors
159
- * {@link inputTypeForKind}: the definition's own `outputType`, then the
160
- * `extends`-declared abstract's `outputType`. Resolves the inline and
161
- * raw-schema forms; a bare named type reference is left unresolved. Undefined
162
- * when the kind declares no output contract. */
149
+ * {@link inputTypeForKind}. Undefined when the kind declares no output
150
+ * contract. */
163
151
  outputTypeForKind(kind: string): Record<string, unknown> | undefined {
152
+ return this.contractForKind(kind, "outputType");
153
+ }
154
+
155
+ private contractForKind(
156
+ kind: string,
157
+ direction: ContractDirection,
158
+ ): Record<string, unknown> | undefined {
164
159
  const def = this.resolveDefinition(kind);
165
160
  if (!def) return undefined;
166
- const own = resolveTypeFieldToSchema(def.outputType, []);
167
- if (own) return own;
168
- if (def.extends) {
169
- const abstractDef = this.resolveDefinition(def.extends);
170
- if (abstractDef) {
171
- const inherited = resolveTypeFieldToSchema(abstractDef.outputType, []);
172
- if (inherited) return inherited;
173
- }
174
- }
175
- return undefined;
161
+ return resolveContract(direction, undefined, def, {
162
+ resolveDefinition: this.scopedDefResolver(),
163
+ typeManifestsFor: () => [],
164
+ })?.schema;
165
+ }
166
+
167
+ /** A resolver that re-scopes at every hop of an `extends` chain: each kind is
168
+ * resolved in the module that DECLARED the definition it was read off, since
169
+ * `extends` aliases are lexical. `resolveParent` always passes that definition
170
+ * as `from`, so one resolver serves a chain of any depth crossing any number
171
+ * of modules. */
172
+ private scopedDefResolver(): DefResolver {
173
+ return (kind, from) =>
174
+ this.resolveDefinitionIn(kind, (from?.metadata as { module?: string } | undefined)?.module);
176
175
  }
177
176
 
178
177
  private capabilitiesForRefs(refs: string[]): string[] {
@@ -218,6 +217,17 @@ export class AnalysisRegistry {
218
217
  return ctx.definitions?.resolve(kind) ?? (resolved ? ctx.definitions?.resolve(resolved) : undefined);
219
218
  }
220
219
 
220
+ /** Resolve a kind in a named module's scope. The entry point for walking an
221
+ * `extends` chain from outside it (the kernel's contract binding): aliases are
222
+ * lexical, so each hop must resolve in the module that declared the definition
223
+ * the kind was read off. Falls back to the global table when the module is
224
+ * unknown or is a root. */
225
+ resolveDefinitionIn(kind: string, module?: string): ResourceDefinition | undefined {
226
+ const scope = (module ? this.aliasesByModule.get(module) : undefined) ?? this.aliases;
227
+ const canonical = scope.resolveKind(kind);
228
+ return this.defs.resolve(kind) ?? (canonical ? this.defs.resolve(canonical) : undefined);
229
+ }
230
+
221
231
  /** A resolver scoped to `def`'s OWN module, for resolving that definition's
222
232
  * `extends` target.
223
233
  *
package/src/analyzer.ts CHANGED
@@ -2,7 +2,12 @@ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { canonicalTypeSchemaId, OBSERVED_STATE_KEY } from "@telorun/sdk";
3
3
  import type { Environment } from "@marcbachmann/cel-js";
4
4
  import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
5
- import { AliasResolver, scopeResolverForModule } from "./alias-resolver.js";
5
+ import {
6
+ AliasResolver,
7
+ moduleScopedDefResolver,
8
+ type ModuleScopes,
9
+ scopeResolverForModule,
10
+ } from "./alias-resolver.js";
6
11
  import { AnalysisRegistry } from "./analysis-registry.js";
7
12
  import {
8
13
  buildCelEnvironment,
@@ -11,7 +16,12 @@ import {
11
16
  type CelHandlers,
12
17
  } from "./cel-environment.js";
13
18
  import { DefinitionRegistry } from "./definition-registry.js";
14
- import { effectiveAuthorSchema } from "./extends-resolution.js";
19
+ import { type ContractDirection, effectiveAuthorSchema } from "./extends-resolution.js";
20
+ import {
21
+ type ContractScope,
22
+ PERMISSIVE_CONTRACT,
23
+ resolveContract,
24
+ } from "./invocation-contract.js";
15
25
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
16
26
  import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
17
27
  import {
@@ -53,6 +63,8 @@ import { validateExtends } from "./validate-extends.js";
53
63
  import { validateLogging } from "./validate-logging.js";
54
64
  import { validateModuleArtifact } from "./validate-module-artifact.js";
55
65
  import { validateBaseMapping } from "./validate-base-mapping.js";
66
+ import { validateInvocationContract } from "./validate-invocation-contract.js";
67
+ import { collectStepInputIssues } from "./validate-step-inputs.js";
56
68
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
57
69
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
58
70
  import { validateReferences } from "./validate-references.js";
@@ -110,22 +122,24 @@ function resolveSelfOrAlias(
110
122
  return scopeResolver.resolveKind(value);
111
123
  }
112
124
 
113
- /** Look up a top-level field (`outputType`, `inputType`) on a kind's
114
- * `Telo.Definition`. Used as a fallback by `buildStepContextSchema` when the
115
- * invoked resource manifest doesn't carry the field inline most kinds
116
- * declare result shape on the definition, not the resource. */
117
- function lookupDefinitionTypeField(
118
- invokedKind: string,
119
- fieldName: string,
125
+ /** The {@link ContractScope} the analyzer resolves invocation contracts in: kinds
126
+ * resolve in the module that declared the definition they were read off (so an
127
+ * `extends` chain crossing module boundaries re-scopes at every hop), and named
128
+ * `telo#Type` references resolve against the flattened manifest list. `resolveIn`
129
+ * is the top-level entry point, where the kind was written by the READING
130
+ * module and there is no declaring definition yet. */
131
+ export function analyzerContractScope(
120
132
  defs: DefinitionRegistry,
121
133
  aliases: AliasResolver,
134
+ scopes: ModuleScopes,
122
135
  allManifests: Record<string, any>[],
123
- ): Record<string, any> | undefined {
124
- const canonical = aliases.resolveKind(invokedKind) ?? invokedKind;
125
- const def = defs.resolve(canonical);
126
- if (!def) return undefined;
127
- const value = (def as unknown as Record<string, unknown>)[fieldName];
128
- return resolveTypeFieldToSchema(value, allManifests);
136
+ ): ContractScope & { resolveIn(kind: string, module?: string): ResourceDefinition | undefined } {
137
+ const resolve = moduleScopedDefResolver<ResourceDefinition>(defs, aliases, scopes);
138
+ return {
139
+ resolveDefinition: resolve,
140
+ resolveIn: resolve.in,
141
+ typeManifestsFor: () => allManifests,
142
+ };
129
143
  }
130
144
 
131
145
  const SOURCE = "telo-analyzer";
@@ -170,42 +184,37 @@ function buildSelfSchema(
170
184
  }
171
185
 
172
186
  /** Build the JSON Schema for the `inputs` CEL variable available inside an
173
- * invocable template body. Three-layer fallback mirroring the runtime's
174
- * caller-supplied inputs:
175
- * 1. The definition's own `inputType:` field (preferred).
176
- * 2. The `extends:`-declared abstract's `inputType:` (so a concrete
177
- * definition inheriting a contract gets typed inputs without
178
- * redeclaring them).
179
- * 3. Undefined — caller signals opaque `map<string, dyn>` upstream. */
187
+ * invocable template body the shared contract resolver applied to the
188
+ * definition itself, so a body is typed against the exact signature callers are
189
+ * checked against and dispatch enforces. Walks the whole `extends` chain rather
190
+ * than one hop, so a definition two levels below the declaration still gets
191
+ * typed inputs. Undefined when nothing in the chain declares a contract —
192
+ * the caller signals opaque `map<string, dyn>` upstream. */
180
193
  function lookupTemplateInputsSchema(
181
194
  definition: Record<string, any>,
182
195
  defs: DefinitionRegistry,
183
196
  aliases: AliasResolver,
184
197
  allManifests: Record<string, any>[],
198
+ scopes: ModuleScopes,
185
199
  ): Record<string, any> | undefined {
186
- const own = resolveTypeFieldToSchema(definition.inputType, allManifests);
187
- if (own) return own;
188
- const ext = definition.extends as string | undefined;
189
- if (typeof ext === "string" && ext.length > 0) {
190
- const canonical = aliases.resolveKind(ext) ?? ext;
191
- const abstractDef = defs.resolve(canonical);
192
- if (abstractDef) {
193
- const inherited = resolveTypeFieldToSchema(
194
- (abstractDef as unknown as Record<string, unknown>).inputType,
195
- allManifests,
196
- );
197
- if (inherited) return inherited;
198
- }
199
- }
200
- return undefined;
200
+ return resolveContract(
201
+ "inputType",
202
+ undefined,
203
+ definition as unknown as ResourceDefinition,
204
+ analyzerContractScope(defs, aliases, scopes, allManifests),
205
+ )?.schema;
201
206
  }
202
207
 
203
208
  /** Returns a "resolver-facing" view of the manifest where the fields used as
204
209
  * navigation roots by Telo.Definition's `x-telo-context-from-root` annotations
205
210
  * have been pre-augmented:
206
211
  * - `schema` → augmented `self` schema (synthetic `name`/`kind`/metadata).
207
- * - `inputType` → resolved with extends fallback when the field isn't
208
- * declared directly on the definition.
212
+ * - `inputType` → resolved through the shared contract resolver, so
213
+ * `x-telo-context-from-root: inputType` substitutes the
214
+ * real signature. Without it the annotation would replace
215
+ * the node verbatim with the inline `{kind, schema}` wrapper
216
+ * the standard library writes everywhere, typing `inputs` as
217
+ * `{kind, schema}` instead of the declared properties.
209
218
  *
210
219
  * For non-definition manifests the original object is returned. */
211
220
  function manifestRootForResolver(
@@ -213,9 +222,10 @@ function manifestRootForResolver(
213
222
  defs: DefinitionRegistry,
214
223
  aliases: AliasResolver,
215
224
  allManifests: Record<string, any>[],
225
+ scopes: ModuleScopes,
216
226
  ): Record<string, any> {
217
227
  if (m.kind !== "Telo.Definition") return m;
218
- const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests);
228
+ const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests, scopes);
219
229
  return {
220
230
  ...m,
221
231
  schema: buildSelfSchema(m, defs, aliases),
@@ -223,9 +233,42 @@ function manifestRootForResolver(
223
233
  };
224
234
  }
225
235
 
236
+ /** True when an issue reports a property that is absent — its path points at a
237
+ * node the manifest does not contain. */
238
+ export const missingRequired = (issue: { message: string }): boolean =>
239
+ /is missing required property/.test(issue.message);
240
+
241
+ /** The path minus its last segment: the node that should have contained the
242
+ * missing property. Empty for a top-level miss, which anchors on the map. */
243
+ export function containerOf(path: string): string {
244
+ const dot = path.lastIndexOf(".");
245
+ return dot === -1 ? "" : path.slice(0, dot);
246
+ }
247
+
248
+ /** How to name the owner of a resolved contract in a diagnostic. When the
249
+ * contract came from the definition's direct parent, echo the author's own
250
+ * spelling (`extends: Mcp.SessionProvider`) — that is the text they can find in
251
+ * their file. A contract inherited from further up the chain isn't written
252
+ * anywhere in this file, so the canonical `module.Kind` is what locates it. */
253
+ function contractOwnerLabel(
254
+ definition: Record<string, any>,
255
+ contract: { declaredBy?: ResourceDefinition },
256
+ ): string {
257
+ const declaredBy = contract.declaredBy;
258
+ if (!declaredBy || declaredBy === (definition as unknown as ResourceDefinition)) {
259
+ return String(definition.metadata?.name ?? definition.kind);
260
+ }
261
+ const parentSpelling = definition.extends as string | undefined;
262
+ const canonical = `${declaredBy.metadata.module}.${declaredBy.metadata.name}`;
263
+ if (typeof parentSpelling === "string" && parentSpelling.endsWith(`.${declaredBy.metadata.name}`)) {
264
+ return parentSpelling;
265
+ }
266
+ return canonical;
267
+ }
268
+
226
269
  /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
227
270
  * Non-refs and unresolved refs pass through unchanged. */
228
- function resolveLocalRef(
271
+ export function resolveLocalRef(
229
272
  schema: Record<string, any> | undefined,
230
273
  root: Record<string, any>,
231
274
  ): Record<string, any> | undefined {
@@ -241,7 +284,7 @@ function resolveLocalRef(
241
284
 
242
285
  /** Gather property schemas from a (possibly variant-bearing) object schema:
243
286
  * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch. */
244
- function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Record<string, any>]> {
287
+ export function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Record<string, any>]> {
245
288
  const out: Array<[string, Record<string, any>]> = [];
246
289
  if (schema.properties && typeof schema.properties === "object") {
247
290
  for (const [k, v] of Object.entries(schema.properties as Record<string, any>)) {
@@ -272,7 +315,7 @@ function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Reco
272
315
  * role or nesting form updates both consumers at once. No resource kind is
273
316
  * hardcoded; recursion is driven entirely by the schema annotations.
274
317
  */
275
- function walkStepArray(
318
+ export function walkStepArray(
276
319
  steps: unknown[],
277
320
  stepItemSchema: Record<string, any> | undefined,
278
321
  rootSchema: Record<string, any>,
@@ -331,20 +374,18 @@ function walkStepArray(
331
374
 
332
375
  /**
333
376
  * Build a `steps` context schema from `x-telo-step-context` annotation.
334
- * Walks each step in the manifest array, resolves the invoked resource's outputType,
335
- * and builds `steps.<name>.result` context entries.
377
+ * Walks each step in the manifest array, resolves the invoked resource's output
378
+ * contract, and builds `steps.<name>.result` context entries.
336
379
  *
337
- * outputType resolution falls through three layers:
338
- * 1. The invoked resource manifest's own `outputType` field (rare most
339
- * resources don't declare outputType inline).
340
- * 2. The kind's `Telo.Definition` outputType (the common case for kinds that
341
- * declare a stable result shape, e.g. `Ai.TextStream` ↦ `{output: stream}`).
342
- * 3. Permissive `{type: object, additionalProperties: true}` if neither
343
- * yields a schema.
380
+ * Resolution is the shared {@link resolveContract} — the invoked resource
381
+ * manifest's own declaration, then the kind's, resolved to the nearest
382
+ * declaration along `extends`, then permissive. Sharing it with the kernel is
383
+ * what stops `telo check` from typing `steps.X.result` against one contract
384
+ * while dispatch validates against another.
344
385
  *
345
- * Layer 2 is what makes `x-telo-stream` properties on definitions actually
346
- * govern step-result chain validation — without it, the validator falls back
347
- * to permissive and the stream-opacity rule never fires.
386
+ * The kind layer is what makes `x-telo-stream` properties on definitions
387
+ * actually govern step-result chain validation — without it, the validator falls
388
+ * back to permissive and the stream-opacity rule never fires.
348
389
  *
349
390
  * Recursion into nested step arrays is annotation-driven via
350
391
  * `x-telo-topology-role`. The analyzer recognises three role values:
@@ -361,10 +402,14 @@ function buildStepContextSchema(
361
402
  allManifests: Record<string, any>[],
362
403
  defs: DefinitionRegistry,
363
404
  aliases: AliasResolver,
405
+ scopes: ModuleScopes,
364
406
  ): Record<string, any> | undefined {
365
407
  const props = defSchema.properties as Record<string, any> | undefined;
366
408
  if (!props) return undefined;
367
409
 
410
+ const contractScope = analyzerContractScope(defs, aliases, scopes, allManifests);
411
+ const readingModule = (manifest.metadata as { module?: string } | undefined)?.module;
412
+
368
413
  for (const [fieldName, fieldSchema] of Object.entries(props)) {
369
414
  const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
370
415
  if (!stepCtx) continue;
@@ -391,36 +436,30 @@ function buildStepContextSchema(
391
436
  // not shadow real entries with a permissive `additionalProperties: true`,
392
437
  // or unknown step references slip through chain validation.
393
438
  if (typeof name !== "string" || !invoke || typeof invoke !== "object") return;
394
- let outputSchema: Record<string, any> | undefined;
395
439
  const invokedKind = invoke.kind as string | undefined;
396
440
  const invokedName = invoke.name as string | undefined;
397
- if (invokedName) {
398
- const invokedManifest = allManifests.find(
399
- (m) =>
400
- (m.metadata as any)?.name === invokedName &&
401
- (!invokedKind || m.kind === invokedKind),
402
- ) as Record<string, any> | undefined;
403
- if (invokedManifest) {
404
- outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
405
- }
406
- } else {
407
- outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
408
- }
409
- // Fallback: pull outputType from the kind's Telo.Definition. The
410
- // resource manifest typically doesn't carry outputType; the def does.
411
- if (!outputSchema && invokedKind) {
412
- outputSchema = lookupDefinitionTypeField(
413
- invokedKind,
414
- outputTypeField,
415
- defs,
416
- aliases,
417
- allManifests,
418
- );
419
- }
441
+ // A named `!ref` carries the target's own manifest (which may narrow the
442
+ // contract for this one instance); an inline `{ kind, ... }` step IS the
443
+ // manifest. Either way the kind layer resolves through `extends`.
444
+ const invokedManifest = invokedName
445
+ ? (allManifests.find(
446
+ (m) =>
447
+ (m.metadata as any)?.name === invokedName && (!invokedKind || m.kind === invokedKind),
448
+ ) as Record<string, any> | undefined)
449
+ : (invoke as Record<string, any>);
450
+ const invokedDef = invokedKind
451
+ ? contractScope.resolveIn(invokedKind, readingModule)
452
+ : undefined;
453
+ const outputSchema = resolveContract(
454
+ outputTypeField as ContractDirection,
455
+ invokedManifest,
456
+ invokedDef,
457
+ contractScope,
458
+ )?.schema;
420
459
  stepProperties[name] = {
421
460
  type: "object",
422
461
  properties: {
423
- result: outputSchema ?? { type: "object", additionalProperties: true },
462
+ result: outputSchema ?? PERMISSIVE_CONTRACT,
424
463
  },
425
464
  };
426
465
  });
@@ -1549,6 +1588,13 @@ export class StaticAnalyzer {
1549
1588
  // the abstract this definition `extends`. CEL fields inside the templated
1550
1589
  // values are replaced with type-appropriate placeholders before AJV runs —
1551
1590
  // same pattern as the per-resource schema validation above.
1591
+ const contractScope = analyzerContractScope(
1592
+ defs,
1593
+ aliases,
1594
+ { aliasesByModule, rootModules },
1595
+ allManifests as Record<string, any>[],
1596
+ );
1597
+
1552
1598
  for (const m of allManifests) {
1553
1599
  if (m.kind !== "Telo.Definition") continue;
1554
1600
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
@@ -1598,20 +1644,20 @@ export class StaticAnalyzer {
1598
1644
  // values passed to the dispatch target's invoke(). Validate against the
1599
1645
  // target's declared `inputType` when both sides have one.
1600
1646
  if (dispatchKind && md.inputs && typeof md.inputs === "object") {
1601
- const targetSchema = lookupDefinitionTypeField(
1602
- dispatchKind,
1647
+ const targetSchema = resolveContract(
1603
1648
  "inputType",
1604
- defs,
1605
- aliases,
1606
- allManifests as Record<string, any>[],
1607
- );
1649
+ undefined,
1650
+ contractScope.resolveIn(dispatchKind, (md.metadata as any)?.module),
1651
+ contractScope,
1652
+ )?.schema;
1608
1653
  if (targetSchema) {
1609
1654
  emitTargetMismatch(dispatchKind, targetSchema, md.inputs, "inputs");
1610
1655
  }
1611
1656
  }
1612
1657
 
1613
- // Top-level `result:` is a post-call mapping that must satisfy the abstract
1614
- // this definition `extends` (`outputType`). It's a sibling of whichever
1658
+ // Top-level `result:` is a post-call mapping that must satisfy THIS
1659
+ // definition's output contract — its own `outputType` when it declares
1660
+ // one, otherwise the nearest ancestor's. It's a sibling of whichever
1615
1661
  // dispatch entry-point declared a kind-typed target (`provide:` or
1616
1662
  // `invoke:`). The target's outputType lives on the dispatcher's `kind`
1617
1663
  // and is what `result` is typed against *inside* CEL — separate role.
@@ -1619,18 +1665,14 @@ export class StaticAnalyzer {
1619
1665
  (provide && typeof provide === "object" && !Array.isArray(provide)) ||
1620
1666
  (invoke && typeof invoke === "object" && !Array.isArray(invoke));
1621
1667
  if (hasDispatchObject && md.result && typeof md.result === "object") {
1622
- const extendsValue = md.extends as string | undefined;
1623
- if (typeof extendsValue === "string" && extendsValue.length > 0) {
1624
- const abstractSchema = lookupDefinitionTypeField(
1625
- extendsValue,
1626
- "outputType",
1627
- defs,
1628
- aliases,
1629
- allManifests as Record<string, any>[],
1630
- );
1631
- if (abstractSchema) {
1632
- emitTargetMismatch(extendsValue, abstractSchema, md.result, "result");
1633
- }
1668
+ const contract = resolveContract(
1669
+ "outputType",
1670
+ undefined,
1671
+ md as unknown as ResourceDefinition,
1672
+ contractScope,
1673
+ );
1674
+ if (contract) {
1675
+ emitTargetMismatch(contractOwnerLabel(md, contract), contract.schema, md.result, "result");
1634
1676
  }
1635
1677
  }
1636
1678
  }
@@ -1675,8 +1717,33 @@ export class StaticAnalyzer {
1675
1717
  allManifests as Record<string, any>[],
1676
1718
  defs,
1677
1719
  aliases,
1720
+ { aliasesByModule, rootModules },
1678
1721
  )
1679
1722
  : undefined;
1723
+ if (e.definition?.schema) {
1724
+ const stepName = (m.metadata as any)?.name as string | undefined;
1725
+ const stepFile = (m.metadata as { source?: string } | undefined)?.source;
1726
+ for (const issue of collectStepInputIssues(
1727
+ m as Record<string, any>,
1728
+ e.definition.schema as Record<string, any>,
1729
+ allManifests as Record<string, any>[],
1730
+ defs,
1731
+ aliases,
1732
+ { aliasesByModule, rootModules },
1733
+ )) {
1734
+ diagnostics.push({
1735
+ severity: DiagnosticSeverity.Error,
1736
+ code: "CONTRACT_INPUTS_MISMATCH",
1737
+ source: SOURCE,
1738
+ message: `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
1739
+ data: {
1740
+ resource: { kind: m.kind, name: stepName ?? "" },
1741
+ filePath: stepFile,
1742
+ path: issue.path,
1743
+ },
1744
+ });
1745
+ }
1746
+ }
1680
1747
  celErrorScopes = collectErrorContextScopes(
1681
1748
  e.definition?.schema as Record<string, any> | undefined,
1682
1749
  );
@@ -1814,6 +1881,7 @@ export class StaticAnalyzer {
1814
1881
  defs,
1815
1882
  aliases,
1816
1883
  allManifests as Record<string, any>[],
1884
+ { aliasesByModule, rootModules },
1817
1885
  );
1818
1886
  const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
1819
1887
  manifestRoot: rootForResolver,
@@ -1918,6 +1986,9 @@ export class StaticAnalyzer {
1918
1986
  diagnostics.push(...validateExtends(allManifests, defs, aliases));
1919
1987
 
1920
1988
  diagnostics.push(...validateBaseMapping(allManifests, defs, aliases));
1989
+ diagnostics.push(
1990
+ ...validateInvocationContract(allManifests, defs, aliases, aliasesByModule),
1991
+ );
1921
1992
 
1922
1993
  // Validate provider coherence rules for `provide:` template-target definitions.
1923
1994
  diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
package/src/builtins.ts CHANGED
@@ -218,6 +218,66 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
218
218
  additionalProperties: false,
219
219
  },
220
220
  },
221
+ {
222
+ // Telo.JsonSchema — the concrete data-shape kind, in the kernel rather than
223
+ // in an installable module for the same reason the mandatory sinks are:
224
+ // declaring a shape is not optional. Every kind with an invocation contract
225
+ // needs one, so requiring an import to write `inputType:` would put a tax on
226
+ // the one thing the contract wants authors to do more of — and a library
227
+ // declaring a contract would have to import a module purely to describe
228
+ // itself. `type.JsonSchema` remains as a deprecated alias of this kind.
229
+ kind: "Telo.Definition",
230
+ metadata: { name: "JsonSchema", module: "Telo" },
231
+ capability: "Telo.Type",
232
+ // Declared so the kind reads as controller-BEARING, which is what lets
233
+ // another definition inherit it by delegation (`extends: Telo.JsonSchema`
234
+ // with no controller of its own). The entry is never loaded from — the
235
+ // kernel registers this controller directly at boot, before any lazy
236
+ // resolution — it states truthfully who provides it.
237
+ controllers: [{ runtime: "kernel", entry: "Telo.JsonSchema" }],
238
+ schema: {
239
+ type: "object",
240
+ properties: {
241
+ schema: {
242
+ title: "Schema",
243
+ description: "JSON Schema definition for the declared data type.",
244
+ type: "object",
245
+ },
246
+ extends: {
247
+ title: "Extends",
248
+ description: "Parent type name or list of parent type names to inherit from.",
249
+ oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
250
+ },
251
+ rules: {
252
+ title: "Rules",
253
+ description:
254
+ "CEL-based business invariant rules. Each rule's condition must return true for valid data.",
255
+ type: "array",
256
+ items: {
257
+ type: "object",
258
+ properties: {
259
+ condition: {
260
+ type: "string",
261
+ description:
262
+ "CEL expression evaluated with 'this' bound to the data. Must return true for valid data.",
263
+ },
264
+ code: {
265
+ type: "string",
266
+ description: "Machine-readable error code surfaced on validation failure.",
267
+ },
268
+ message: {
269
+ type: "string",
270
+ description: "Optional human-readable hint for the validation failure.",
271
+ },
272
+ },
273
+ required: ["condition", "code"],
274
+ },
275
+ },
276
+ },
277
+ required: ["schema"],
278
+ additionalProperties: false,
279
+ },
280
+ },
221
281
  {
222
282
  kind: "Telo.Definition",
223
283
  metadata: { name: "Abstract", module: "Telo" },
@@ -491,6 +551,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
491
551
  default: "shared",
492
552
  },
493
553
  targets: {
554
+ // Boot targets form a step list: a later target reads an earlier one's
555
+ // result as `steps.<name>.result`, exactly as a sequence step does, so
556
+ // the same annotation types that context and drives the call-site
557
+ // contract check.
558
+ "x-telo-step-context": { invoke: "invoke", outputType: "outputType" },
494
559
  type: "array",
495
560
  items: {
496
561
  anyOf: [
@@ -564,7 +629,15 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
564
629
  { "x-telo-ref": "Telo.Runnable" },
565
630
  ],
566
631
  },
567
- inputs: { type: "object", additionalProperties: true },
632
+ inputs: {
633
+ // Same annotation Run.Sequence steps carry: it is what makes
634
+ // a boot target's inputs visible to the call-site contract
635
+ // check and to the wiring rule. Without it the kernel would
636
+ // validate these at dispatch and nothing before it.
637
+ "x-telo-topology-role": "inputs",
638
+ type: "object",
639
+ additionalProperties: true,
640
+ },
568
641
  when: { type: "string" },
569
642
  },
570
643
  additionalProperties: false,