@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
@@ -1,5 +1,5 @@
1
1
  import type { Environment } from "@marcbachmann/cel-js";
2
- import type { ResourceManifest } from "@telorun/sdk";
2
+ import { type ResourceManifest } from "@telorun/sdk";
3
3
  import { type AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
5
  import { type AnalysisDiagnostic } from "./types.js";
@@ -1 +1 @@
1
- {"version":3,"file":"validate-throws-coverage.d.ts","sourceRoot":"","sources":["../src/validate-throws-coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAOnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAoezE,oDAAoD;AACpD,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,WAAW,EAChB,eAAe,GAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAa,EACvD,WAAW,GAAE,GAAG,CAAC,MAAM,CAAa,GACnC,kBAAkB,EAAE,CAkDtB"}
1
+ {"version":3,"file":"validate-throws-coverage.d.ts","sourceRoot":"","sources":["../src/validate-throws-coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAOnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AA0ezE,oDAAoD;AACpD,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,WAAW,EAChB,eAAe,GAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAa,EACvD,WAAW,GAAE,GAAG,CAAC,MAAM,CAAa,GACnC,kBAAkB,EAAE,CAkDtB"}
@@ -1,4 +1,5 @@
1
1
  import { isTaggedSentinel } from "@telorun/templating";
2
+ import { AMBIENT_CONTRACT_ERROR_CODES, isAmbientContractErrorCode, } from "@telorun/sdk";
2
3
  import { scopeResolverForModule } from "./alias-resolver.js";
3
4
  import { createResolveCtx, resolveThrowsUnion, } from "./resolve-throws-union.js";
4
5
  import { DiagnosticSeverity } from "./types.js";
@@ -186,12 +187,19 @@ function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env
186
187
  const { proven, codes } = extractCoveredCodes(e.when, env);
187
188
  if (proven) {
188
189
  for (const c of codes) {
190
+ // An ambient kernel code (contract violations) is raised by the kernel,
191
+ // not declared by the kind, so naming it is legal and still typo-checked
192
+ // — but it is NOT part of the declared union, so it never counts toward
193
+ // coverage. Folding these into every union would make every bounded
194
+ // catches: block in the standard library incomplete overnight.
195
+ if (isAmbientContractErrorCode(c))
196
+ continue;
189
197
  if (!declaredCodes.has(c)) {
190
198
  diagnostics.push({
191
199
  severity: DiagnosticSeverity.Error,
192
200
  code: "UNDECLARED_THROW_CODE",
193
201
  source: SOURCE,
194
- message: `catches[${i}] references code '${c}' which is not in the handler's declared throw union {${[...declaredCodes].sort().join(", ") || "∅"}}${union.unbounded ? " (union is unbounded a catch-all is required)" : ""}.`,
202
+ message: `catches[${i}] references code '${c}' which is not in the handler's declared throw union {${[...declaredCodes].sort().join(", ") || "∅"}} (ambient kernel codes ${AMBIENT_CONTRACT_ERROR_CODES.join(", ")} may also be named)${union.unbounded ? "; the union is unbounded, so a catch-all is required" : ""}.`,
195
203
  data: { resource, filePath, path: `${arrayPath}[${i}].when` },
196
204
  });
197
205
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -48,7 +48,7 @@
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
50
  "vitest": "^2.1.8",
51
- "@telorun/sdk": "0.59.0"
51
+ "@telorun/sdk": "0.61.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@telorun/sdk": "*"
@@ -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 {
@@ -51,7 +61,10 @@ import {
51
61
  import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
52
62
  import { validateExtends } from "./validate-extends.js";
53
63
  import { validateLogging } from "./validate-logging.js";
64
+ import { validateModuleArtifact } from "./validate-module-artifact.js";
54
65
  import { validateBaseMapping } from "./validate-base-mapping.js";
66
+ import { validateInvocationContract } from "./validate-invocation-contract.js";
67
+ import { collectStepInputIssues } from "./validate-step-inputs.js";
55
68
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
56
69
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
57
70
  import { validateReferences } from "./validate-references.js";
@@ -109,22 +122,24 @@ function resolveSelfOrAlias(
109
122
  return scopeResolver.resolveKind(value);
110
123
  }
111
124
 
112
- /** Look up a top-level field (`outputType`, `inputType`) on a kind's
113
- * `Telo.Definition`. Used as a fallback by `buildStepContextSchema` when the
114
- * invoked resource manifest doesn't carry the field inline most kinds
115
- * declare result shape on the definition, not the resource. */
116
- function lookupDefinitionTypeField(
117
- invokedKind: string,
118
- 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(
119
132
  defs: DefinitionRegistry,
120
133
  aliases: AliasResolver,
134
+ scopes: ModuleScopes,
121
135
  allManifests: Record<string, any>[],
122
- ): Record<string, any> | undefined {
123
- const canonical = aliases.resolveKind(invokedKind) ?? invokedKind;
124
- const def = defs.resolve(canonical);
125
- if (!def) return undefined;
126
- const value = (def as unknown as Record<string, unknown>)[fieldName];
127
- 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
+ };
128
143
  }
129
144
 
130
145
  const SOURCE = "telo-analyzer";
@@ -169,42 +184,37 @@ function buildSelfSchema(
169
184
  }
170
185
 
171
186
  /** Build the JSON Schema for the `inputs` CEL variable available inside an
172
- * invocable template body. Three-layer fallback mirroring the runtime's
173
- * caller-supplied inputs:
174
- * 1. The definition's own `inputType:` field (preferred).
175
- * 2. The `extends:`-declared abstract's `inputType:` (so a concrete
176
- * definition inheriting a contract gets typed inputs without
177
- * redeclaring them).
178
- * 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. */
179
193
  function lookupTemplateInputsSchema(
180
194
  definition: Record<string, any>,
181
195
  defs: DefinitionRegistry,
182
196
  aliases: AliasResolver,
183
197
  allManifests: Record<string, any>[],
198
+ scopes: ModuleScopes,
184
199
  ): Record<string, any> | undefined {
185
- const own = resolveTypeFieldToSchema(definition.inputType, allManifests);
186
- if (own) return own;
187
- const ext = definition.extends as string | undefined;
188
- if (typeof ext === "string" && ext.length > 0) {
189
- const canonical = aliases.resolveKind(ext) ?? ext;
190
- const abstractDef = defs.resolve(canonical);
191
- if (abstractDef) {
192
- const inherited = resolveTypeFieldToSchema(
193
- (abstractDef as unknown as Record<string, unknown>).inputType,
194
- allManifests,
195
- );
196
- if (inherited) return inherited;
197
- }
198
- }
199
- return undefined;
200
+ return resolveContract(
201
+ "inputType",
202
+ undefined,
203
+ definition as unknown as ResourceDefinition,
204
+ analyzerContractScope(defs, aliases, scopes, allManifests),
205
+ )?.schema;
200
206
  }
201
207
 
202
208
  /** Returns a "resolver-facing" view of the manifest where the fields used as
203
209
  * navigation roots by Telo.Definition's `x-telo-context-from-root` annotations
204
210
  * have been pre-augmented:
205
211
  * - `schema` → augmented `self` schema (synthetic `name`/`kind`/metadata).
206
- * - `inputType` → resolved with extends fallback when the field isn't
207
- * 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.
208
218
  *
209
219
  * For non-definition manifests the original object is returned. */
210
220
  function manifestRootForResolver(
@@ -212,9 +222,10 @@ function manifestRootForResolver(
212
222
  defs: DefinitionRegistry,
213
223
  aliases: AliasResolver,
214
224
  allManifests: Record<string, any>[],
225
+ scopes: ModuleScopes,
215
226
  ): Record<string, any> {
216
227
  if (m.kind !== "Telo.Definition") return m;
217
- const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests);
228
+ const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests, scopes);
218
229
  return {
219
230
  ...m,
220
231
  schema: buildSelfSchema(m, defs, aliases),
@@ -222,9 +233,42 @@ function manifestRootForResolver(
222
233
  };
223
234
  }
224
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
+
225
269
  /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
226
270
  * Non-refs and unresolved refs pass through unchanged. */
227
- function resolveLocalRef(
271
+ export function resolveLocalRef(
228
272
  schema: Record<string, any> | undefined,
229
273
  root: Record<string, any>,
230
274
  ): Record<string, any> | undefined {
@@ -240,7 +284,7 @@ function resolveLocalRef(
240
284
 
241
285
  /** Gather property schemas from a (possibly variant-bearing) object schema:
242
286
  * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch. */
243
- function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Record<string, any>]> {
287
+ export function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Record<string, any>]> {
244
288
  const out: Array<[string, Record<string, any>]> = [];
245
289
  if (schema.properties && typeof schema.properties === "object") {
246
290
  for (const [k, v] of Object.entries(schema.properties as Record<string, any>)) {
@@ -271,7 +315,7 @@ function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Reco
271
315
  * role or nesting form updates both consumers at once. No resource kind is
272
316
  * hardcoded; recursion is driven entirely by the schema annotations.
273
317
  */
274
- function walkStepArray(
318
+ export function walkStepArray(
275
319
  steps: unknown[],
276
320
  stepItemSchema: Record<string, any> | undefined,
277
321
  rootSchema: Record<string, any>,
@@ -330,20 +374,18 @@ function walkStepArray(
330
374
 
331
375
  /**
332
376
  * Build a `steps` context schema from `x-telo-step-context` annotation.
333
- * Walks each step in the manifest array, resolves the invoked resource's outputType,
334
- * 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.
335
379
  *
336
- * outputType resolution falls through three layers:
337
- * 1. The invoked resource manifest's own `outputType` field (rare most
338
- * resources don't declare outputType inline).
339
- * 2. The kind's `Telo.Definition` outputType (the common case for kinds that
340
- * declare a stable result shape, e.g. `Ai.TextStream` ↦ `{output: stream}`).
341
- * 3. Permissive `{type: object, additionalProperties: true}` if neither
342
- * 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.
343
385
  *
344
- * Layer 2 is what makes `x-telo-stream` properties on definitions actually
345
- * govern step-result chain validation — without it, the validator falls back
346
- * 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.
347
389
  *
348
390
  * Recursion into nested step arrays is annotation-driven via
349
391
  * `x-telo-topology-role`. The analyzer recognises three role values:
@@ -360,10 +402,14 @@ function buildStepContextSchema(
360
402
  allManifests: Record<string, any>[],
361
403
  defs: DefinitionRegistry,
362
404
  aliases: AliasResolver,
405
+ scopes: ModuleScopes,
363
406
  ): Record<string, any> | undefined {
364
407
  const props = defSchema.properties as Record<string, any> | undefined;
365
408
  if (!props) return undefined;
366
409
 
410
+ const contractScope = analyzerContractScope(defs, aliases, scopes, allManifests);
411
+ const readingModule = (manifest.metadata as { module?: string } | undefined)?.module;
412
+
367
413
  for (const [fieldName, fieldSchema] of Object.entries(props)) {
368
414
  const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
369
415
  if (!stepCtx) continue;
@@ -390,36 +436,30 @@ function buildStepContextSchema(
390
436
  // not shadow real entries with a permissive `additionalProperties: true`,
391
437
  // or unknown step references slip through chain validation.
392
438
  if (typeof name !== "string" || !invoke || typeof invoke !== "object") return;
393
- let outputSchema: Record<string, any> | undefined;
394
439
  const invokedKind = invoke.kind as string | undefined;
395
440
  const invokedName = invoke.name as string | undefined;
396
- if (invokedName) {
397
- const invokedManifest = allManifests.find(
398
- (m) =>
399
- (m.metadata as any)?.name === invokedName &&
400
- (!invokedKind || m.kind === invokedKind),
401
- ) as Record<string, any> | undefined;
402
- if (invokedManifest) {
403
- outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
404
- }
405
- } else {
406
- outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
407
- }
408
- // Fallback: pull outputType from the kind's Telo.Definition. The
409
- // resource manifest typically doesn't carry outputType; the def does.
410
- if (!outputSchema && invokedKind) {
411
- outputSchema = lookupDefinitionTypeField(
412
- invokedKind,
413
- outputTypeField,
414
- defs,
415
- aliases,
416
- allManifests,
417
- );
418
- }
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;
419
459
  stepProperties[name] = {
420
460
  type: "object",
421
461
  properties: {
422
- result: outputSchema ?? { type: "object", additionalProperties: true },
462
+ result: outputSchema ?? PERMISSIVE_CONTRACT,
423
463
  },
424
464
  };
425
465
  });
@@ -1202,6 +1242,11 @@ export class StaticAnalyzer {
1202
1242
  // §14.1 / §10.3: redaction paths and `on_full: block` are statically
1203
1243
  // detectable, so they fail `telo check` rather than only at boot.
1204
1244
  diagnostics.push(...validateLogging(allManifests, defs, aliases, aliasesByModule));
1245
+ // Module-artifact surface: bundled-controller selector qualifiers and the
1246
+ // published `layers:` index. Every case is decidable from the manifest and
1247
+ // would otherwise fail on a consumer's machine — or, for a mistyped platform
1248
+ // axis, silently offer one platform's binary to every host.
1249
+ diagnostics.push(...validateModuleArtifact(allManifests));
1205
1250
  }
1206
1251
  resolveSchemaTypeRefs(allManifests, aliases, aliasesByModule);
1207
1252
 
@@ -1543,6 +1588,13 @@ export class StaticAnalyzer {
1543
1588
  // the abstract this definition `extends`. CEL fields inside the templated
1544
1589
  // values are replaced with type-appropriate placeholders before AJV runs —
1545
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
+
1546
1598
  for (const m of allManifests) {
1547
1599
  if (m.kind !== "Telo.Definition") continue;
1548
1600
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
@@ -1592,20 +1644,20 @@ export class StaticAnalyzer {
1592
1644
  // values passed to the dispatch target's invoke(). Validate against the
1593
1645
  // target's declared `inputType` when both sides have one.
1594
1646
  if (dispatchKind && md.inputs && typeof md.inputs === "object") {
1595
- const targetSchema = lookupDefinitionTypeField(
1596
- dispatchKind,
1647
+ const targetSchema = resolveContract(
1597
1648
  "inputType",
1598
- defs,
1599
- aliases,
1600
- allManifests as Record<string, any>[],
1601
- );
1649
+ undefined,
1650
+ contractScope.resolveIn(dispatchKind, (md.metadata as any)?.module),
1651
+ contractScope,
1652
+ )?.schema;
1602
1653
  if (targetSchema) {
1603
1654
  emitTargetMismatch(dispatchKind, targetSchema, md.inputs, "inputs");
1604
1655
  }
1605
1656
  }
1606
1657
 
1607
- // Top-level `result:` is a post-call mapping that must satisfy the abstract
1608
- // 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
1609
1661
  // dispatch entry-point declared a kind-typed target (`provide:` or
1610
1662
  // `invoke:`). The target's outputType lives on the dispatcher's `kind`
1611
1663
  // and is what `result` is typed against *inside* CEL — separate role.
@@ -1613,18 +1665,14 @@ export class StaticAnalyzer {
1613
1665
  (provide && typeof provide === "object" && !Array.isArray(provide)) ||
1614
1666
  (invoke && typeof invoke === "object" && !Array.isArray(invoke));
1615
1667
  if (hasDispatchObject && md.result && typeof md.result === "object") {
1616
- const extendsValue = md.extends as string | undefined;
1617
- if (typeof extendsValue === "string" && extendsValue.length > 0) {
1618
- const abstractSchema = lookupDefinitionTypeField(
1619
- extendsValue,
1620
- "outputType",
1621
- defs,
1622
- aliases,
1623
- allManifests as Record<string, any>[],
1624
- );
1625
- if (abstractSchema) {
1626
- emitTargetMismatch(extendsValue, abstractSchema, md.result, "result");
1627
- }
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");
1628
1676
  }
1629
1677
  }
1630
1678
  }
@@ -1669,8 +1717,33 @@ export class StaticAnalyzer {
1669
1717
  allManifests as Record<string, any>[],
1670
1718
  defs,
1671
1719
  aliases,
1720
+ { aliasesByModule, rootModules },
1672
1721
  )
1673
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
+ }
1674
1747
  celErrorScopes = collectErrorContextScopes(
1675
1748
  e.definition?.schema as Record<string, any> | undefined,
1676
1749
  );
@@ -1808,6 +1881,7 @@ export class StaticAnalyzer {
1808
1881
  defs,
1809
1882
  aliases,
1810
1883
  allManifests as Record<string, any>[],
1884
+ { aliasesByModule, rootModules },
1811
1885
  );
1812
1886
  const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
1813
1887
  manifestRoot: rootForResolver,
@@ -1912,6 +1986,9 @@ export class StaticAnalyzer {
1912
1986
  diagnostics.push(...validateExtends(allManifests, defs, aliases));
1913
1987
 
1914
1988
  diagnostics.push(...validateBaseMapping(allManifests, defs, aliases));
1989
+ diagnostics.push(
1990
+ ...validateInvocationContract(allManifests, defs, aliases, aliasesByModule),
1991
+ );
1915
1992
 
1916
1993
  // Validate provider coherence rules for `provide:` template-target definitions.
1917
1994
  diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));