@telorun/analyzer 0.33.0 → 0.34.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.
@@ -21,20 +21,26 @@ function checkKind(kind, entry, registry, aliases) {
21
21
  const targetDef = registry.resolve(targetKind);
22
22
  if (!targetDef)
23
23
  return [];
24
+ // Liskov substitutability: a value satisfies the slot when it transitively
25
+ // extends the target kind, or — for a CONCRETE target — IS that kind.
26
+ // `getByExtends` is the same transitive subtype index for abstract and
27
+ // concrete targets alike; an abstract is satisfied only by an implementer,
28
+ // never by the abstract kind itself (which is non-instantiable).
29
+ if (targetDef.kind !== "Telo.Abstract" && resolved === targetKind)
30
+ return [];
31
+ const subtypes = registry.getByExtends(targetKind);
32
+ const subtypeKinds = new Set(subtypes.map((d) => `${d.metadata.module}.${d.metadata.name}`));
33
+ if (subtypeKinds.has(resolved))
34
+ return [];
24
35
  if (targetDef.kind === "Telo.Abstract") {
25
- const implementing = registry.getByExtends(targetKind);
26
- if (implementing.length === 0)
36
+ if (subtypes.length === 0)
27
37
  return []; // partial context — no implementations loaded yet
28
- const implementingKinds = new Set(implementing.map((d) => `${d.metadata.module}.${d.metadata.name}`));
29
- if (implementingKinds.has(resolved))
30
- return [];
31
- const options = [...implementingKinds].join(", ");
38
+ const options = [...subtypeKinds].join(", ");
32
39
  errors.push(`'${kind}' does not implement '${targetKind}' (known implementations: ${options})`);
33
40
  }
34
41
  else {
35
- if (resolved === targetKind)
36
- return [];
37
- errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'`);
42
+ const options = subtypeKinds.size > 0 ? ` or a subtype (${[...subtypeKinds].join(", ")})` : "";
43
+ errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'${options}`);
38
44
  }
39
45
  }
40
46
  return errors;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.33.0",
3
+ "version": "0.34.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.41.0"
51
+ "@telorun/sdk": "0.44.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@telorun/sdk": "*"
@@ -262,16 +262,17 @@ export class AnalysisRegistry {
262
262
  const targetDef = this.defs.resolve(targetKind);
263
263
  if (!targetDef) return undefined;
264
264
 
265
+ // General single inheritance: the accepted set is the target kind plus every
266
+ // kind that transitively extends it (subtypes are substitutable). A concrete
267
+ // target contributes itself and any specializations; an abstract contributes
268
+ // its implementations. Same transitive index for both.
265
269
  const out = new Set<string>();
266
- if (targetDef.kind === "Telo.Abstract") {
267
- for (const def of this.defs.getByExtends(targetKind)) {
268
- const module = (def.metadata as { module?: string } | undefined)?.module;
269
- if (module && def.metadata?.name) {
270
- out.add(`${module}.${def.metadata.name as string}`);
271
- }
270
+ if (targetDef.kind !== "Telo.Abstract") out.add(targetKind);
271
+ for (const def of this.defs.getByExtends(targetKind)) {
272
+ const module = (def.metadata as { module?: string } | undefined)?.module;
273
+ if (module && def.metadata?.name) {
274
+ out.add(`${module}.${def.metadata.name as string}`);
272
275
  }
273
- } else {
274
- out.add(targetKind);
275
276
  }
276
277
  return out;
277
278
  }
package/src/analyzer.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  type CelHandlers,
12
12
  } from "./cel-environment.js";
13
13
  import { DefinitionRegistry } from "./definition-registry.js";
14
+ import { effectiveAuthorSchema } from "./extends-resolution.js";
14
15
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
15
16
  import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
16
17
  import { computeSuggestKind } from "./kind-suggest.js";
@@ -39,6 +40,7 @@ import {
39
40
  } from "./validate-cel-context.js";
40
41
  import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
41
42
  import { validateExtends } from "./validate-extends.js";
43
+ import { validateBaseMapping } from "./validate-base-mapping.js";
42
44
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
43
45
  import { validateKindDescriptions } from "./validate-kind-descriptions.js";
44
46
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
@@ -123,8 +125,20 @@ const SOURCE = "telo-analyzer";
123
125
  * property the user declared in `schema:` plus synthetic `name` / `kind` and
124
126
  * the metadata sub-object (kept open since metadata legitimately carries
125
127
  * arbitrary user-added fields). */
126
- function buildSelfSchema(definition: Record<string, any>): Record<string, any> {
127
- const userSchema = (definition.schema ?? {}) as Record<string, any>;
128
+ function buildSelfSchema(
129
+ definition: Record<string, any>,
130
+ defs?: DefinitionRegistry,
131
+ aliases?: AliasResolver,
132
+ ): Record<string, any> {
133
+ // The author-facing schema resolves inheritance: with `base:` the child's own
134
+ // schema (the parent's config is internal); without it, `merge(parent, own)`.
135
+ const userSchema = (
136
+ defs
137
+ ? effectiveAuthorSchema(definition as unknown as ResourceDefinition, (k) =>
138
+ defs.resolve(aliases?.resolveKind(k) ?? k) ?? defs.resolve(k),
139
+ )
140
+ : (definition.schema ?? {})
141
+ ) as Record<string, any>;
128
142
  const userProps = (userSchema.properties ?? {}) as Record<string, any>;
129
143
  const userRequired = Array.isArray(userSchema.required) ? userSchema.required : [];
130
144
  return {
@@ -193,7 +207,7 @@ function manifestRootForResolver(
193
207
  const inputs = lookupTemplateInputsSchema(m, defs, aliases, allManifests);
194
208
  return {
195
209
  ...m,
196
- schema: buildSelfSchema(m),
210
+ schema: buildSelfSchema(m, defs, aliases),
197
211
  ...(inputs ? { inputType: inputs } : {}),
198
212
  };
199
213
  }
@@ -1230,21 +1244,28 @@ export class StaticAnalyzer {
1230
1244
  continue;
1231
1245
  }
1232
1246
 
1233
- // Validate resource config against definition schema.
1247
+ // Validate resource config against the definition's AUTHOR-FACING schema
1248
+ // inheritance-resolved: with `base:` the child's own schema (parent config
1249
+ // is internal), else `merge(parent, own)` so a `base:`-less `extends` child
1250
+ // is validated against the inherited fields it may set. For a definition
1251
+ // that neither extends nor uses `base:` this is exactly its own schema.
1234
1252
  // `kind` and `metadata` are implicit on every resource — inject them so module
1235
1253
  // authors don't have to repeat them when using additionalProperties: false.
1236
- if (definition.schema) {
1254
+ const authorSchema = effectiveAuthorSchema(definition, (k) =>
1255
+ defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k),
1256
+ );
1257
+ if (authorSchema && Object.keys(authorSchema).length > 0) {
1237
1258
  const schema =
1238
- definition.schema.additionalProperties === false
1259
+ authorSchema.additionalProperties === false
1239
1260
  ? {
1240
- ...definition.schema,
1261
+ ...authorSchema,
1241
1262
  properties: {
1242
1263
  kind: { type: "string" },
1243
1264
  metadata: { type: "object" },
1244
- ...definition.schema.properties,
1265
+ ...authorSchema.properties,
1245
1266
  },
1246
1267
  }
1247
- : definition.schema;
1268
+ : authorSchema;
1248
1269
  // Phase 1: CEL type checking — walk data+schema together, check env.check() return types.
1249
1270
  // A Telo.Import's variables/secrets are a config-only contract evaluated against the
1250
1271
  // IMPORTING module's scope, so type them from the owning module doc (matched by
@@ -1628,6 +1649,8 @@ export class StaticAnalyzer {
1628
1649
  // Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
1629
1650
  diagnostics.push(...validateExtends(allManifests, defs, aliases));
1630
1651
 
1652
+ diagnostics.push(...validateBaseMapping(allManifests, defs, aliases));
1653
+
1631
1654
  // Validate provider coherence rules for `provide:` template-target definitions.
1632
1655
  diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
1633
1656
 
package/src/builtins.ts CHANGED
@@ -209,6 +209,21 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
209
209
  },
210
210
  },
211
211
  },
212
+ // `base:` ("super(...)") — construction mapping for an inherited
213
+ // (concrete-`extends`) definition. Its CEL is evaluated once against
214
+ // `self` (typed from this definition's `schema:`) to build the parent
215
+ // kind's config. Same `self`-only scope as a resource body.
216
+ base: {
217
+ type: "object",
218
+ additionalProperties: true,
219
+ "x-telo-context": {
220
+ type: "object",
221
+ additionalProperties: false,
222
+ properties: {
223
+ self: { "x-telo-context-from-root": "schema" },
224
+ },
225
+ },
226
+ },
212
227
  },
213
228
  },
214
229
  },
@@ -8,6 +8,7 @@ import {
8
8
  type ReferenceFieldMap,
9
9
  } from "./reference-field-map.js";
10
10
  import { createAjv, formatSingleError, navigateJsonPointer } from "./schema-compat.js";
11
+ import { effectiveAuthorSchema } from "./extends-resolution.js";
11
12
 
12
13
  /** Pure kind → ResourceDefinition map. No controller loading, no lifecycle. */
13
14
  export class DefinitionRegistry {
@@ -37,7 +38,11 @@ export class DefinitionRegistry {
37
38
  const { name, module: mod } = definition.metadata;
38
39
  const key = mod ? `${mod}.${name}` : name;
39
40
  this.defs.set(key, definition);
40
- this.fieldMaps.set(key, buildReferenceFieldMap(definition.schema ?? {}));
41
+ // Field maps derive from the AUTHOR-FACING (inheritance-resolved) schema, which
42
+ // depends on the parent — possibly registered after this child. Clear the cache
43
+ // so any already-computed map recomputes against the now-larger def set; the
44
+ // maps rebuild lazily on first `getFieldMap` (after all defs are registered).
45
+ this.fieldMaps.clear();
41
46
  // `capability` populates extendedBy for backward-compat with the legacy pattern where
42
47
  // a concrete definition overloaded `capability: <AbstractKind>` to mean "implements
43
48
  // this abstract." The canonical pattern is `extends` (below). Both populate the index,
@@ -204,9 +209,19 @@ export class DefinitionRegistry {
204
209
  return this.defs.get(kind);
205
210
  }
206
211
 
207
- /** Returns the cached reference field map for the given kind, built once during register(). */
212
+ /** Returns the reference field map for the given kind, computed lazily from the
213
+ * kind's AUTHOR-FACING (inheritance-resolved) schema and memoized. Lazy so a
214
+ * child registered before its parent still sees the parent's inherited ref
215
+ * slots once both are present. */
208
216
  getFieldMap(kind: string): ReferenceFieldMap | undefined {
209
- return this.fieldMaps.get(kind);
217
+ const cached = this.fieldMaps.get(kind);
218
+ if (cached) return cached;
219
+ const def = this.defs.get(kind);
220
+ if (!def) return undefined;
221
+ const schema = effectiveAuthorSchema(def, (k) => this.resolve(k));
222
+ const map = buildReferenceFieldMap(schema ?? {});
223
+ this.fieldMaps.set(kind, map);
224
+ return map;
210
225
  }
211
226
 
212
227
  /** Returns the field map for `kind`, falling back to the alias-resolved kind when not found. */
@@ -0,0 +1,124 @@
1
+ import type { ResourceDefinition } from "@telorun/sdk";
2
+ import { mergeTypeSchemas } from "@telorun/sdk";
3
+
4
+ /** Resolves a kind string (canonical or alias form, depending on the caller's
5
+ * registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined. */
6
+ export type DefResolver = (kind: string) => ResourceDefinition | undefined;
7
+
8
+ /** The template-body / controller fields a definition may carry. Kept local
9
+ * because `ResourceDefinition` intentionally types only the stable surface;
10
+ * template bodies are read structurally. */
11
+ interface DefinitionBody {
12
+ extends?: string;
13
+ capability?: string;
14
+ controllers?: unknown[];
15
+ invoke?: unknown;
16
+ run?: unknown;
17
+ provide?: unknown;
18
+ mount?: unknown;
19
+ resources?: unknown[];
20
+ base?: Record<string, unknown>;
21
+ schema?: Record<string, any>;
22
+ }
23
+
24
+ const body = (def: ResourceDefinition | undefined): DefinitionBody =>
25
+ (def ?? {}) as unknown as DefinitionBody;
26
+
27
+ /** The definition a given definition directly `extends`, or undefined when it
28
+ * extends nothing / the target can't be resolved. */
29
+ export function resolveParent(
30
+ def: ResourceDefinition | undefined,
31
+ resolve: DefResolver,
32
+ ): ResourceDefinition | undefined {
33
+ const ext = body(def).extends;
34
+ if (typeof ext !== "string" || ext.length === 0) return undefined;
35
+ return resolve(ext);
36
+ }
37
+
38
+ /** The `extends` ancestor chain, nearest-first, excluding `def` itself.
39
+ * Cycle-guarded so a malformed self/mutual `extends` can't loop forever. */
40
+ export function ancestorChain(
41
+ def: ResourceDefinition | undefined,
42
+ resolve: DefResolver,
43
+ ): ResourceDefinition[] {
44
+ const chain: ResourceDefinition[] = [];
45
+ const seen = new Set<ResourceDefinition>();
46
+ let cur = resolveParent(def, resolve);
47
+ while (cur && !seen.has(cur)) {
48
+ seen.add(cur);
49
+ chain.push(cur);
50
+ cur = resolveParent(cur, resolve);
51
+ }
52
+ return chain;
53
+ }
54
+
55
+ /** True when a definition carries its own controller (`controllers:`) or a
56
+ * template body (`invoke:` / `run:` / `provide:` / `mount:` / `resources:`). */
57
+ export function hasOwnControllerOrTemplate(def: ResourceDefinition | undefined): boolean {
58
+ const d = body(def);
59
+ return !!(
60
+ (d.controllers && d.controllers.length) ||
61
+ d.invoke ||
62
+ d.run ||
63
+ d.provide ||
64
+ d.mount ||
65
+ d.resources
66
+ );
67
+ }
68
+
69
+ /** The nearest concrete ancestor that provides a controller (own `controllers:`
70
+ * or a template body) — the definition whose controller an inherited child
71
+ * delegates to. Undefined when no controller-bearing concrete ancestor exists. */
72
+ export function controllerBearingAncestor(
73
+ def: ResourceDefinition | undefined,
74
+ resolve: DefResolver,
75
+ ): ResourceDefinition | undefined {
76
+ for (const a of ancestorChain(def, resolve)) {
77
+ if (a.kind === "Telo.Abstract") continue;
78
+ if (hasOwnControllerOrTemplate(a)) return a;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /** True when this definition inherits its controller by delegation: it declares
84
+ * `extends`, has no own controller/template body, and its nearest concrete
85
+ * ancestor is controller-bearing. */
86
+ export function isInheritedDelegation(
87
+ def: ResourceDefinition | undefined,
88
+ resolve: DefResolver,
89
+ ): boolean {
90
+ if (!body(def).extends || hasOwnControllerOrTemplate(def)) return false;
91
+ return controllerBearingAncestor(def, resolve) !== undefined;
92
+ }
93
+
94
+ /** The effective (possibly inherited) capability: the nearest self-or-ancestor
95
+ * that declares a `capability`. Undefined when none in the chain does. */
96
+ export function inheritedCapability(
97
+ def: ResourceDefinition | undefined,
98
+ resolve: DefResolver,
99
+ ): string | undefined {
100
+ if (body(def).capability) return body(def).capability;
101
+ for (const a of ancestorChain(def, resolve)) {
102
+ if (body(a).capability) return body(a).capability;
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /** The author-facing schema for a definition:
108
+ * - with `base:` present → the definition's **own** schema (the parent's config
109
+ * fields are internal, set solely through `base:`).
110
+ * - without `base:` but with `extends` → `merge(parent-effective, own)` (a pure
111
+ * additive extension; child overrides on key conflicts), reusing the same
112
+ * `mergeTypeSchemas` that `Type.JsonSchema.extends` uses.
113
+ * - no `extends` → the own schema unchanged. */
114
+ export function effectiveAuthorSchema(
115
+ def: ResourceDefinition | undefined,
116
+ resolve: DefResolver,
117
+ ): Record<string, any> {
118
+ const own = (body(def).schema ?? {}) as Record<string, any>;
119
+ const parent = resolveParent(def, resolve);
120
+ if (!parent) return own;
121
+ if (body(def).base) return own;
122
+ const parentSchema = effectiveAuthorSchema(parent, resolve);
123
+ return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
124
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,18 @@ export {
22
22
  type ReExportSpec,
23
23
  } from "./flatten-for-analyzer.js";
24
24
  export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
25
+ export {
26
+ ancestorChain,
27
+ controllerBearingAncestor,
28
+ effectiveAuthorSchema,
29
+ hasOwnControllerOrTemplate,
30
+ inheritedCapability,
31
+ isInheritedDelegation,
32
+ resolveParent,
33
+ } from "./extends-resolution.js";
34
+ export type { DefResolver } from "./extends-resolution.js";
35
+ export { buildReferenceFieldMap, isRefEntry, isScopeEntry } from "./reference-field-map.js";
36
+ export type { ReferenceFieldMap, RefFieldEntry } from "./reference-field-map.js";
25
37
  export { visitManifest } from "./manifest-visitor.js";
26
38
  export type {
27
39
  CelSiteEvent,
@@ -0,0 +1,154 @@
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
+ import { isCompiledValue } from "@telorun/sdk";
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { effectiveAuthorSchema, resolveParent, type DefResolver } from "./extends-resolution.js";
6
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
7
+
8
+ const SOURCE = "telo-analyzer";
9
+
10
+ /** True when a value subtree contains a compiled CEL leaf — such a value can
11
+ * produce anything at runtime, so its type is not statically checkable. */
12
+ function containsCel(value: unknown): boolean {
13
+ if (isCompiledValue(value)) return true;
14
+ if (Array.isArray(value)) return value.some(containsCel);
15
+ if (value && typeof value === "object") {
16
+ return Object.values(value as Record<string, unknown>).some(containsCel);
17
+ }
18
+ return false;
19
+ }
20
+
21
+ /**
22
+ * Phase 3c — Static validation of a definition's `base:` construction mapping
23
+ * against the parent kind's config schema. The kernel evaluates `base:` and
24
+ * passes the result to the inherited controller's `create()`, which validates it
25
+ * at boot; this mirrors that check statically so an omitted required field or a
26
+ * wrong literal type surfaces at `telo check`, not first boot.
27
+ *
28
+ * Diagnostics:
29
+ * - BASE_MISSING_REQUIRED: `base:` omits a field the parent schema requires.
30
+ * - BASE_UNKNOWN_FIELD: `base:` sets a field the parent schema (with
31
+ * `additionalProperties: false`) does not declare.
32
+ * - BASE_SCHEMA_MISMATCH: a CEL-free `base:` value violates the parent field's
33
+ * schema (wrong type / constraint). CEL-bearing values are skipped — their
34
+ * runtime value is unknown — but still count as present for required checks.
35
+ */
36
+ export function validateBaseMapping(
37
+ manifests: ResourceManifest[],
38
+ registry: DefinitionRegistry,
39
+ aliases: AliasResolver,
40
+ ): AnalysisDiagnostic[] {
41
+ const diagnostics: AnalysisDiagnostic[] = [];
42
+ const resolveDef: DefResolver = (k) =>
43
+ registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
44
+
45
+ const importedModules = new Set<string>();
46
+ for (const m of manifests) {
47
+ if (m.kind !== "Telo.Import") continue;
48
+ const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)?.resolvedModuleName;
49
+ if (resolved) importedModules.add(resolved);
50
+ }
51
+
52
+ for (const m of manifests) {
53
+ if (m.kind !== "Telo.Definition") continue;
54
+ const base = (m as { base?: unknown }).base;
55
+ if (!base || typeof base !== "object" || Array.isArray(base)) continue;
56
+ const name = m.metadata?.name as string | undefined;
57
+ if (!name) continue;
58
+ const ownModule = (m.metadata as { module?: string } | undefined)?.module;
59
+ if (ownModule && importedModules.has(ownModule)) continue;
60
+
61
+ const parent = resolveParent(m as unknown as ResourceDefinition, resolveDef);
62
+ // Missing / unresolved `extends` is already reported by validateExtends.
63
+ if (!parent) continue;
64
+ const parentSchema = effectiveAuthorSchema(parent, resolveDef);
65
+ if (!parentSchema || typeof parentSchema !== "object") continue;
66
+
67
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
68
+ const resource = { kind: m.kind, name };
69
+ const label = `${m.kind}/${name}`;
70
+ checkObject(base as Record<string, unknown>, parentSchema, "base", {
71
+ diagnostics,
72
+ registry,
73
+ label,
74
+ resource,
75
+ filePath,
76
+ });
77
+ }
78
+
79
+ return diagnostics;
80
+ }
81
+
82
+ interface CheckCtx {
83
+ diagnostics: AnalysisDiagnostic[];
84
+ registry: DefinitionRegistry;
85
+ label: string;
86
+ resource: { kind: string; name: string };
87
+ filePath: string | undefined;
88
+ }
89
+
90
+ function checkObject(
91
+ value: Record<string, unknown>,
92
+ schema: Record<string, any>,
93
+ path: string,
94
+ ctx: CheckCtx,
95
+ ): void {
96
+ const properties = (schema.properties ?? {}) as Record<string, Record<string, any>>;
97
+ const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
98
+ const additionalFalse = schema.additionalProperties === false;
99
+
100
+ for (const req of required) {
101
+ if (!(req in value)) {
102
+ ctx.diagnostics.push({
103
+ severity: DiagnosticSeverity.Error,
104
+ code: "BASE_MISSING_REQUIRED",
105
+ source: SOURCE,
106
+ message: `${ctx.label}: '${path}' does not set required parent field '${req}'.`,
107
+ data: { resource: ctx.resource, filePath: ctx.filePath, path },
108
+ });
109
+ }
110
+ }
111
+
112
+ for (const [key, fieldValue] of Object.entries(value)) {
113
+ const fieldPath = `${path}.${key}`;
114
+ const propSchema = properties[key];
115
+ if (!propSchema) {
116
+ if (additionalFalse) {
117
+ ctx.diagnostics.push({
118
+ severity: DiagnosticSeverity.Error,
119
+ code: "BASE_UNKNOWN_FIELD",
120
+ source: SOURCE,
121
+ message: `${ctx.label}: '${fieldPath}' is not a field of the parent kind's schema.`,
122
+ data: { resource: ctx.resource, filePath: ctx.filePath, path: fieldPath },
123
+ });
124
+ }
125
+ continue;
126
+ }
127
+ // A CEL-bearing value produces its shape at runtime — not statically
128
+ // checkable. Recurse into a partially-CEL nested object so its literal
129
+ // sub-fields still get validated; fully-literal values validate directly.
130
+ if (containsCel(fieldValue)) {
131
+ if (
132
+ fieldValue &&
133
+ typeof fieldValue === "object" &&
134
+ !Array.isArray(fieldValue) &&
135
+ !isCompiledValue(fieldValue) &&
136
+ propSchema.type === "object" &&
137
+ propSchema.properties
138
+ ) {
139
+ checkObject(fieldValue as Record<string, unknown>, propSchema, fieldPath, ctx);
140
+ }
141
+ continue;
142
+ }
143
+ const issues = ctx.registry.validateWithRefs(fieldValue, propSchema);
144
+ for (const issue of issues) {
145
+ ctx.diagnostics.push({
146
+ severity: DiagnosticSeverity.Error,
147
+ code: "BASE_SCHEMA_MISMATCH",
148
+ source: SOURCE,
149
+ message: `${ctx.label}: '${fieldPath}' does not match the parent field's schema: ${issue}`,
150
+ data: { resource: ctx.resource, filePath: ctx.filePath, path: fieldPath },
151
+ });
152
+ }
153
+ }
154
+ }
@@ -1,6 +1,7 @@
1
- import type { ResourceManifest } from "@telorun/sdk";
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import { inheritedCapability, type DefResolver } from "./extends-resolution.js";
4
5
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
5
6
 
6
7
  const SOURCE = "telo-analyzer";
@@ -119,16 +120,28 @@ export function validateExtends(
119
120
  message: `${label}: 'extends' target '${extendsValue}' (resolved: '${canonical}') is not a registered definition.`,
120
121
  data: { resource, filePath, path: "extends" },
121
122
  });
122
- } else if (targetDef.kind !== "Telo.Abstract") {
123
- diagnostics.push({
124
- severity: DiagnosticSeverity.Error,
125
- code: "EXTENDS_NON_ABSTRACT",
126
- source: SOURCE,
127
- message:
128
- `${label}: 'extends' target '${extendsValue}' (resolved: '${canonical}') is a ${targetDef.kind}, not a Telo.Abstract. ` +
129
- `Only Telo.Abstract declarations may be extended.`,
130
- data: { resource, filePath, path: "extends" },
131
- });
123
+ } else {
124
+ // General single inheritance: any concrete or abstract kind may be
125
+ // extended. What inheritance must NOT do is change the lifecycle
126
+ // role — a child that restates `capability` differently from an
127
+ // ancestor is a hard error (no silent capability change).
128
+ const resolveDef: DefResolver = (k) =>
129
+ registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
130
+ const ownCap = (m as { capability?: unknown }).capability;
131
+ const ownCapResolved =
132
+ typeof ownCap === "string" ? aliases.resolveKind(ownCap) ?? ownCap : undefined;
133
+ const ancestorCap = inheritedCapability(targetDef, resolveDef);
134
+ if (ownCapResolved && ancestorCap && ownCapResolved !== ancestorCap) {
135
+ diagnostics.push({
136
+ severity: DiagnosticSeverity.Error,
137
+ code: "EXTENDS_CAPABILITY_MISMATCH",
138
+ source: SOURCE,
139
+ message:
140
+ `${label}: declares 'capability: ${ownCap}' but extends '${extendsValue}' whose inherited capability is '${ancestorCap}'. ` +
141
+ `Capability is inherited and immutable — omit 'capability' or restate it identically.`,
142
+ data: { resource, filePath, path: "capability" },
143
+ });
144
+ }
132
145
  }
133
146
  }
134
147
  }
@@ -1,6 +1,7 @@
1
- import type { ResourceManifest } from "@telorun/sdk";
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import { controllerBearingAncestor, type DefResolver } from "./extends-resolution.js";
4
5
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
5
6
 
6
7
  const SOURCE = "telo-analyzer";
@@ -234,7 +235,16 @@ export function validateProviderCoherence(
234
235
  }
235
236
  }
236
237
 
237
- if (capability === "Telo.Provider" && !hasControllers && !hasProvide) {
238
+ // A definition that inherits a controller by delegation (concrete `extends`,
239
+ // no own controller/template) satisfies the implementation requirement
240
+ // through its parent — `base:` supplies the parent's config.
241
+ const resolveDef: DefResolver = (k) =>
242
+ registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
243
+ const inheritsController =
244
+ typeof md.extends === "string" &&
245
+ controllerBearingAncestor(m as ResourceDefinition, resolveDef) !== undefined;
246
+
247
+ if (capability === "Telo.Provider" && !hasControllers && !hasProvide && !inheritsController) {
238
248
  diagnostics.push({
239
249
  severity: DiagnosticSeverity.Error,
240
250
  code: "PROVIDER_MISSING_IMPLEMENTATION",
@@ -29,20 +29,24 @@ function checkKind(
29
29
  if (!targetKind) return [];
30
30
  const targetDef = registry.resolve(targetKind);
31
31
  if (!targetDef) return [];
32
+ // Liskov substitutability: a value satisfies the slot when it transitively
33
+ // extends the target kind, or — for a CONCRETE target — IS that kind.
34
+ // `getByExtends` is the same transitive subtype index for abstract and
35
+ // concrete targets alike; an abstract is satisfied only by an implementer,
36
+ // never by the abstract kind itself (which is non-instantiable).
37
+ if (targetDef.kind !== "Telo.Abstract" && resolved === targetKind) return [];
38
+ const subtypes = registry.getByExtends(targetKind);
39
+ const subtypeKinds = new Set(subtypes.map((d) => `${d.metadata.module}.${d.metadata.name}`));
40
+ if (subtypeKinds.has(resolved)) return [];
32
41
  if (targetDef.kind === "Telo.Abstract") {
33
- const implementing = registry.getByExtends(targetKind);
34
- if (implementing.length === 0) return []; // partial context — no implementations loaded yet
35
- const implementingKinds = new Set(
36
- implementing.map((d) => `${d.metadata.module}.${d.metadata.name}`),
37
- );
38
- if (implementingKinds.has(resolved)) return [];
39
- const options = [...implementingKinds].join(", ");
42
+ if (subtypes.length === 0) return []; // partial context — no implementations loaded yet
43
+ const options = [...subtypeKinds].join(", ");
40
44
  errors.push(
41
45
  `'${kind}' does not implement '${targetKind}' (known implementations: ${options})`,
42
46
  );
43
47
  } else {
44
- if (resolved === targetKind) return [];
45
- errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'`);
48
+ const options = subtypeKinds.size > 0 ? ` or a subtype (${[...subtypeKinds].join(", ")})` : "";
49
+ errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'${options}`);
46
50
  }
47
51
  }
48
52
  return errors;