@telorun/analyzer 0.3.0 → 0.4.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.
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAa9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAsP/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAsPvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAKxF,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAiB5F"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAa9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAuP/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAsSvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAKxF,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAiB5F"}
package/dist/analyzer.js CHANGED
@@ -9,6 +9,7 @@ import { normalizeInlineResources } from "./normalize-inline-resources.js";
9
9
  import { celTypeSatisfiesJsonSchema, substituteCelFields, validateAgainstSchema, } from "./schema-compat.js";
10
10
  import { DiagnosticSeverity } from "./types.js";
11
11
  import { extractAccessChains, getManifestItem, pathMatchesScope, resolveContextAnnotations, resolveTypeFieldToSchema, validateChainAgainstSchema, } from "./validate-cel-context.js";
12
+ import { validateExtends } from "./validate-extends.js";
12
13
  import { validateReferences } from "./validate-references.js";
13
14
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
14
15
  const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
@@ -210,9 +211,24 @@ export class StaticAnalyzer {
210
211
  // Register module identities and aliases.
211
212
  // The root module doc (Telo.Application or Telo.Library) provides its own
212
213
  // identity; imported modules surface their identity via resolvedModuleName/
213
- // resolvedNamespace stamped onto the Telo.Import by the loader (so we don't
214
- // need to include imported module manifests in the analysis set, avoiding false
215
- // reference errors in the parent context).
214
+ // resolvedNamespace stamped onto the Telo.Import by the loader.
215
+ //
216
+ // Two alias scopes are tracked:
217
+ // - `aliases` — the consumer's aliases, populated from Telo.Imports declared in
218
+ // the entry manifest (its own module).
219
+ // - `aliasesByModule` — per-imported-library aliases, populated from Telo.Imports
220
+ // forwarded by the loader from inside imported libraries. A library may use
221
+ // different alias names than the consumer for the same dependency; resolving
222
+ // a forwarded def's `extends` / `capability` against the consumer's scope
223
+ // would either fail or pick the wrong target. Each forwarded def is normalized
224
+ // in its own library's scope.
225
+ const rootModules = new Set();
226
+ for (const m of manifests) {
227
+ if (isModuleKind(m.kind) && m.metadata?.name) {
228
+ rootModules.add(m.metadata.name);
229
+ }
230
+ }
231
+ const aliasesByModule = new Map();
216
232
  for (const m of manifests) {
217
233
  if (isModuleKind(m.kind)) {
218
234
  const namespace = m.metadata.namespace ?? null;
@@ -226,26 +242,58 @@ export class StaticAnalyzer {
226
242
  const exportedKinds = m.exports?.kinds ?? [];
227
243
  const resolvedModuleName = m.metadata.resolvedModuleName;
228
244
  const resolvedNamespace = m.metadata.resolvedNamespace;
245
+ const ownModule = m.metadata?.module;
229
246
  if (alias && source) {
230
247
  const targetModule = resolvedModuleName ?? source.split("/").filter(Boolean).pop() ?? source;
231
- aliases.registerImport(alias, targetModule, exportedKinds);
248
+ // Module identity is registered globally so x-telo-ref resolution sees
249
+ // transitively-imported modules regardless of which scope brought them in.
232
250
  if (resolvedModuleName) {
233
251
  defs.registerModuleIdentity(resolvedNamespace ?? null, resolvedModuleName);
234
252
  }
253
+ // Alias registration is scoped: consumer imports vs. imported-library imports.
254
+ if (!ownModule || rootModules.has(ownModule)) {
255
+ aliases.registerImport(alias, targetModule, exportedKinds);
256
+ }
257
+ else {
258
+ let libResolver = aliasesByModule.get(ownModule);
259
+ if (!libResolver) {
260
+ libResolver = new AliasResolver();
261
+ aliasesByModule.set(ownModule, libResolver);
262
+ }
263
+ libResolver.registerImport(alias, targetModule, exportedKinds);
264
+ }
235
265
  }
236
266
  }
237
267
  }
238
- // Register definitions from Telo.Definition resources.
239
- // Normalize alias-prefixed `capability` to canonical form so extendedBy lookup works
240
- // (e.g. "Workflow.Backend" "workflow.Backend" when "Workflow" is a known alias).
268
+ // Register definitions from Telo.Definition AND Telo.Abstract resources.
269
+ // Abstracts declare contracts that implementations target via `extends` (canonical)
270
+ // or `capability: <AbstractKind>` (legacy). Until they're registered, validateReferences
271
+ // can't resolve x-telo-ref entries pointing at library-declared abstracts — so abstracts
272
+ // must go through register() too, not just the kernel builtins in the constructor.
273
+ //
274
+ // Normalize alias-prefixed `capability` and `extends` to canonical form using the
275
+ // declaring scope's resolver, so `extendedBy` is keyed by canonical kind regardless
276
+ // of alias choices. `capability` covers the legacy implements-this-abstract overload;
277
+ // `extends` is the canonical first-class form.
241
278
  for (const m of manifests) {
242
- if (m.kind === "Telo.Definition") {
243
- const def = m;
244
- const resolvedCapability = def.capability
245
- ? (aliases.resolveKind(def.capability) ?? def.capability)
246
- : def.capability;
247
- defs.register(resolvedCapability !== def.capability ? { ...def, capability: resolvedCapability } : def);
248
- }
279
+ if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract")
280
+ continue;
281
+ const def = m;
282
+ const ownModule = def.metadata?.module;
283
+ const scopeResolver = ownModule && !rootModules.has(ownModule)
284
+ ? (aliasesByModule.get(ownModule) ?? new AliasResolver())
285
+ : aliases;
286
+ const resolvedCapability = def.capability
287
+ ? (scopeResolver.resolveKind(def.capability) ?? def.capability)
288
+ : def.capability;
289
+ const resolvedExtends = def.extends
290
+ ? (scopeResolver.resolveKind(def.extends) ?? def.extends)
291
+ : def.extends;
292
+ const needsPatch = resolvedCapability !== def.capability || resolvedExtends !== def.extends;
293
+ const normalized = needsPatch
294
+ ? { ...def, capability: resolvedCapability, extends: resolvedExtends }
295
+ : def;
296
+ defs.register(normalized);
249
297
  }
250
298
  // Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
251
299
  const allManifests = normalizeInlineResources(manifests, defs, aliases);
@@ -401,6 +449,8 @@ export class StaticAnalyzer {
401
449
  }
402
450
  // Validate resource references (Phase 3)
403
451
  diagnostics.push(...validateReferences(allManifests, { aliases, definitions: defs }));
452
+ // Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
453
+ diagnostics.push(...validateExtends(allManifests, defs, aliases));
404
454
  // Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
405
455
  diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
406
456
  return diagnostics;
@@ -1 +1 @@
1
- {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EA4I/C,CAAC"}
1
+ {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EAsJ/C,CAAC"}
package/dist/builtins.js CHANGED
@@ -25,9 +25,13 @@ export const KERNEL_BUILTINS = [
25
25
  additionalProperties: true,
26
26
  },
27
27
  capability: { type: "string" },
28
+ schema: { type: "object", additionalProperties: true },
28
29
  },
29
30
  required: ["metadata"],
30
- additionalProperties: false,
31
+ // Telo.Abstract is an extension point by design — it must accept forward-compatible
32
+ // fields (e.g. inputType/outputType from the typed-abstracts plan) without requiring
33
+ // the analyzer to enumerate them here.
34
+ additionalProperties: true,
31
35
  },
32
36
  },
33
37
  {
@@ -53,6 +57,12 @@ export const KERNEL_BUILTINS = [
53
57
  source: { type: "string" },
54
58
  variables: { type: "object" },
55
59
  secrets: { type: "object" },
60
+ runtime: {
61
+ oneOf: [
62
+ { type: "string" },
63
+ { type: "array", items: { type: "string" } },
64
+ ],
65
+ },
56
66
  },
57
67
  required: ["metadata", "source"],
58
68
  additionalProperties: false,
@@ -20,6 +20,7 @@ export declare class DefinitionRegistry {
20
20
  * Used to compute definition $id values for the AJV schema store. */
21
21
  private readonly reverseIdentityMap;
22
22
  register(definition: ResourceDefinition): void;
23
+ private addExtendedBy;
23
24
  /** Register a module identity for x-telo-ref resolution.
24
25
  * Call once per module doc (Telo.Application or Telo.Library) when the manifest is loaded.
25
26
  * @param namespace The module's metadata.namespace (e.g. "std"), or null for telo built-ins.
@@ -1 +1 @@
1
- {"version":3,"file":"definition-registry.d.ts","sourceRoot":"","sources":["../src/definition-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,OAAO,EAA0B,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAG1F,+EAA+E;AAC/E,qBAAa,kBAAkB;;IAK7B;;sFAEkF;IAClF,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IAEzD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyC;IAC9D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAwC;IAClE,mEAAmE;IACnE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA+B;IAC1D;6DACyD;IACzD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD;;0EAEsE;IACtE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA6B;IAEhE,QAAQ,CAAC,UAAU,EAAE,kBAAkB,GAAG,IAAI;IAwB9C;;;yFAGqF;IACrF,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAgB1E;4EACwE;IACxE,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAMnE;wFACoF;IACpF,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,EAAE;IAWtE,OAAO,CAAC,iBAAiB;IAczB;;;;;;;;4FAQwF;IACxF,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAUhD,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAIrD,+FAA+F;IAC/F,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS;IAIxD,gGAAgG;IAChG,kBAAkB,CAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;KAAE,GACvD,iBAAiB,GAAG,SAAS;IAOhC;;qEAEiE;IACjE,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,EAAE;IAgBxD,KAAK,IAAI,MAAM,EAAE;CAGlB"}
1
+ {"version":3,"file":"definition-registry.d.ts","sourceRoot":"","sources":["../src/definition-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,OAAO,EAA0B,KAAK,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAG1F,+EAA+E;AAC/E,qBAAa,kBAAkB;;IAK7B;;sFAEkF;IAClF,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IAEzD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyC;IAC9D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAwC;IAClE,mEAAmE;IACnE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA+B;IAC1D;6DACyD;IACzD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD;;0EAEsE;IACtE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA6B;IAEhE,QAAQ,CAAC,UAAU,EAAE,kBAAkB,GAAG,IAAI;IAiC9C,OAAO,CAAC,aAAa;IASrB;;;yFAGqF;IACrF,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAgB1E;4EACwE;IACxE,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAMnE;wFACoF;IACpF,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,EAAE;IAWtE,OAAO,CAAC,iBAAiB;IAczB;;;;;;;;4FAQwF;IACxF,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAUhD,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAIrD,+FAA+F;IAC/F,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS;IAIxD,gGAAgG;IAChG,kBAAkB,CAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;KAAE,GACvD,iBAAiB,GAAG,SAAS;IAOhC;;qEAEiE;IACjE,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,EAAE;IAgBxD,KAAK,IAAI,MAAM,EAAE;CAGlB"}
@@ -28,14 +28,22 @@ export class DefinitionRegistry {
28
28
  const key = mod ? `${mod}.${name}` : name;
29
29
  this.defs.set(key, definition);
30
30
  this.fieldMaps.set(key, buildReferenceFieldMap(definition.schema ?? {}));
31
+ // `capability` populates extendedBy for backward-compat with the legacy pattern where
32
+ // a concrete definition overloaded `capability: <AbstractKind>` to mean "implements
33
+ // this abstract." The canonical pattern is `extends` (below). Both populate the index,
34
+ // unioned — so in-flight modules pre-migration keep working.
31
35
  if (definition.capability) {
32
- const children = this.extendedBy.get(definition.capability);
33
- if (children) {
34
- children.push(key);
35
- }
36
- else {
37
- this.extendedBy.set(definition.capability, [key]);
38
- }
36
+ this.addExtendedBy(definition.capability, key);
37
+ }
38
+ // `extends` — first-class "implements-this-abstract" edge. Alias-form resolution
39
+ // happens in the analyzer before register() is called (analyzer.ts pre-resolves
40
+ // via aliases.resolveKind), so the value here is already the canonical kind string
41
+ // (e.g. "workflow.Backend"). If the analyzer could not resolve the alias (partial
42
+ // context, or the declaring file doesn't import the target's alias), the value
43
+ // stays as the original alias-prefixed form; validateExtends emits EXTENDS_MALFORMED
44
+ // or EXTENDS_UNKNOWN_TARGET depending on the case.
45
+ if (definition.extends) {
46
+ this.addExtendedBy(definition.extends, key);
39
47
  }
40
48
  // Auto-register the telo identity when any Telo built-in is registered.
41
49
  if (definition.kind === "Telo.Abstract" && mod === "Telo") {
@@ -47,6 +55,16 @@ export class DefinitionRegistry {
47
55
  this.tryRegisterSchema(mod, name, definition.schema);
48
56
  }
49
57
  }
58
+ addExtendedBy(parent, child) {
59
+ const children = this.extendedBy.get(parent);
60
+ if (children) {
61
+ if (!children.includes(child))
62
+ children.push(child);
63
+ }
64
+ else {
65
+ this.extendedBy.set(parent, [child]);
66
+ }
67
+ }
50
68
  /** Register a module identity for x-telo-ref resolution.
51
69
  * Call once per module doc (Telo.Application or Telo.Library) when the manifest is loaded.
52
70
  * @param namespace The module's metadata.namespace (e.g. "std"), or null for telo built-ins.
@@ -1 +1 @@
1
- {"version":3,"file":"manifest-loader.d.ts","sourceRoot":"","sources":["../src/manifest-loader.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAOtE,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EAGrB,MAAM,YAAY,CAAC;AASpB,qBAAa,MAAM;IACjB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAG/B;IAEJ,SAAS,CAAC,QAAQ,EAAE,eAAe,EAAE,CAAC;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,sBAAsB,GAAE,eAAe,EAAE,GAAG,iBAAsB;IAmB9E,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI;IAKxC,OAAO,CAAC,IAAI;IAMN,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAqGnE,eAAe;YAoBf,eAAe;IAgEvB,iBAAiB,CACrB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QACT,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,gBAAgB,EAAE,CAAC;QAC9B,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;KAClD,GAAG,IAAI,CAAC;IAiCH,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,KAAK,IAAI,GAC5C,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAsCrC,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;CAiEnE"}
1
+ {"version":3,"file":"manifest-loader.d.ts","sourceRoot":"","sources":["../src/manifest-loader.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAOtE,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EAGrB,MAAM,YAAY,CAAC;AASpB,qBAAa,MAAM;IACjB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAG/B;IAEJ,SAAS,CAAC,QAAQ,EAAE,eAAe,EAAE,CAAC;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,sBAAsB,GAAE,eAAe,EAAE,GAAG,iBAAsB;IAmB9E,QAAQ,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI;IAKxC,OAAO,CAAC,IAAI;IAMN,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAqGnE,eAAe;YAoBf,eAAe;IAgEvB,iBAAiB,CACrB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;QACT,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,gBAAgB,EAAE,CAAC;QAC9B,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;KAClD,GAAG,IAAI,CAAC;IAiCH,eAAe,CACnB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,KAAK,IAAI,GAC5C,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;IAsCrC,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;CA2GnE"}
@@ -277,7 +277,30 @@ export class Loader {
277
277
  }
278
278
  async loadManifests(entryUrl) {
279
279
  const visited = new Set([entryUrl]);
280
+ // Cache resolved library identity per import URL so a Telo.Import re-encountered
281
+ // through a different chain still gets `resolvedModuleName` / `resolvedNamespace`
282
+ // stamped — without re-loading the target. The early `visited` short-circuit used
283
+ // to silently leave duplicate Telo.Imports unstamped, which broke alias resolution
284
+ // when the same library was imported by two different files in the same analysis set.
285
+ const libraryIdentityByUrl = new Map();
280
286
  const entry = await this.loadModule(entryUrl);
287
+ // Forward Telo.Definition, Telo.Abstract, AND Telo.Import docs from imported
288
+ // libraries to the analyzer so its downstream passes can see them:
289
+ // - Definitions / Abstracts feed cross-package `x-telo-ref` resolution and
290
+ // `extends` target validation.
291
+ // - Imports feed the per-library alias resolver — alias-form `extends` inside
292
+ // a library (e.g. ai-openai's `extends: Ai.Model`) resolves against THAT
293
+ // library's own `Telo.Import` declarations, not the root manifest's. Without
294
+ // forwarding the imports, importing such a library would surface a spurious
295
+ // EXTENDS_MALFORMED for an alias the library legitimately uses internally.
296
+ // Alias resolution itself stays in the analyzer; the loader's only semantic
297
+ // action is stamping `resolvedModuleName` / `resolvedNamespace` — recording the
298
+ // result of loading. Identity is cached per URL (see libraryIdentityByUrl above)
299
+ // because the same library can be reached through multiple chains, and every
300
+ // Telo.Import doc — including the duplicates short-circuited by `visited` —
301
+ // must end up stamped, otherwise per-scope alias resolution falls back to a
302
+ // path-derived string (e.g. "abstract-lib.yaml") and produces wrong canonical
303
+ // kinds.
281
304
  const importedDefs = [];
282
305
  const queue = [...entry];
283
306
  while (queue.length > 0) {
@@ -291,36 +314,52 @@ export class Loader {
291
314
  const importUrl = importSource.startsWith(".") || importSource.startsWith("/")
292
315
  ? this.pick(base).resolveRelative(base, importSource)
293
316
  : importSource;
294
- if (visited.has(importUrl))
295
- continue;
296
- visited.add(importUrl);
297
- let imported;
298
- try {
299
- imported = await this.loadModule(importUrl);
300
- }
301
- catch (err) {
302
- const e = err instanceof Error ? err : new Error(String(err));
303
- e.sourceLine = m.metadata?.sourceLine ?? 0;
304
- throw e;
305
- }
306
- // Import target must be a Telo.Library. Check the Library branch
307
- // explicitly rather than "anything that's a module kind" so that a
308
- // future third kind can't silently slip past as a valid import target.
309
- const importedLibrary = imported.find((im) => im.kind === "Telo.Library");
310
- const importedApplication = imported.find((im) => im.kind === "Telo.Application");
311
- if (importedApplication) {
312
- const e = new Error(`Telo.Import target '${importSource}' is a Telo.Application. ` +
313
- `Only Telo.Library modules may be imported. Applications are run directly, not imported.`);
314
- e.sourceLine = m.metadata?.sourceLine ?? 0;
315
- throw e;
317
+ if (!visited.has(importUrl)) {
318
+ visited.add(importUrl);
319
+ let imported;
320
+ try {
321
+ imported = await this.loadModule(importUrl);
322
+ }
323
+ catch (err) {
324
+ const e = err instanceof Error ? err : new Error(String(err));
325
+ e.sourceLine = m.metadata?.sourceLine ?? 0;
326
+ throw e;
327
+ }
328
+ // Import target must be a Telo.Library. Check the Library branch
329
+ // explicitly rather than "anything that's a module kind" so that a
330
+ // future third kind can't silently slip past as a valid import target.
331
+ const importedLibrary = imported.find((im) => im.kind === "Telo.Library");
332
+ const importedApplication = imported.find((im) => im.kind === "Telo.Application");
333
+ if (importedApplication) {
334
+ const e = new Error(`Telo.Import target '${importSource}' is a Telo.Application. ` +
335
+ `Only Telo.Library modules may be imported. Applications are run directly, not imported.`);
336
+ e.sourceLine = m.metadata?.sourceLine ?? 0;
337
+ throw e;
338
+ }
339
+ if (importedLibrary?.metadata?.name) {
340
+ libraryIdentityByUrl.set(importUrl, {
341
+ name: importedLibrary.metadata.name,
342
+ namespace: importedLibrary.metadata.namespace ?? null,
343
+ });
344
+ }
345
+ for (const im of imported) {
346
+ if (im.kind === "Telo.Definition" ||
347
+ im.kind === "Telo.Abstract" ||
348
+ im.kind === "Telo.Import") {
349
+ importedDefs.push(im);
350
+ }
351
+ if (im.kind === "Telo.Import")
352
+ queue.push(im);
353
+ }
316
354
  }
317
- const importedModule = importedLibrary;
318
- if (importedModule?.metadata?.name) {
355
+ // Stamp m with cached identity (works for both fresh and duplicate visits).
356
+ const identity = libraryIdentityByUrl.get(importUrl);
357
+ if (identity) {
319
358
  const pi = m.metadata?.positionIndex;
320
359
  m.metadata = {
321
360
  ...m.metadata,
322
- resolvedModuleName: importedModule.metadata.name,
323
- resolvedNamespace: importedModule.metadata.namespace ?? null,
361
+ resolvedModuleName: identity.name,
362
+ resolvedNamespace: identity.namespace,
324
363
  };
325
364
  if (pi) {
326
365
  Object.defineProperty(m.metadata, "positionIndex", {
@@ -331,12 +370,6 @@ export class Loader {
331
370
  });
332
371
  }
333
372
  }
334
- for (const im of imported) {
335
- if (im.kind === "Telo.Definition")
336
- importedDefs.push(im);
337
- if (im.kind === "Telo.Import")
338
- queue.push(im);
339
- }
340
373
  }
341
374
  return [...entry, ...importedDefs];
342
375
  }
@@ -0,0 +1,26 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import { type AnalysisDiagnostic } from "./types.js";
5
+ /**
6
+ * Phase 3b — Validate `extends` fields on Telo.Definition docs, and flag the legacy
7
+ * `capability: <UserAbstract>` overload with CAPABILITY_SHADOWS_EXTENDS so users migrate.
8
+ *
9
+ * `extends` uses alias form ("<Alias>.<Name>") resolved against the declaring file's
10
+ * Telo.Import declarations — same pattern as `kind:` prefixes. The analyzer pre-resolves
11
+ * via AliasResolver before register() is called, so by the time this validator runs,
12
+ * the definition's effective `extends` is either the canonical form (when the alias was
13
+ * known) or the original alias-prefixed string (when it wasn't).
14
+ *
15
+ * Diagnostics:
16
+ * - EXTENDS_MALFORMED: value not in "<Alias>.<Name>" alias form, or not resolvable
17
+ * via the declaring file's imports (alias unknown → can't distinguish from a typo).
18
+ * - EXTENDS_UNKNOWN_TARGET: alias resolves to a module, but that module has no
19
+ * registered definition with the target name.
20
+ * - EXTENDS_NON_ABSTRACT: target resolves to a Telo.Definition, not a Telo.Abstract.
21
+ * - CAPABILITY_SHADOWS_EXTENDS (warning): `capability` names a user-declared abstract
22
+ * (metadata.module !== "Telo"). Builtin lifecycle capabilities (Telo.Invocable, etc.)
23
+ * never trigger this — they're lifecycle roles by design.
24
+ */
25
+ export declare function validateExtends(manifests: ResourceManifest[], registry: DefinitionRegistry, aliases: AliasResolver): AnalysisDiagnostic[];
26
+ //# sourceMappingURL=validate-extends.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-extends.d.ts","sourceRoot":"","sources":["../src/validate-extends.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AACnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAOzE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,EAAE,aAAa,GACrB,kBAAkB,EAAE,CA4ItB"}
@@ -0,0 +1,163 @@
1
+ import { DiagnosticSeverity } from "./types.js";
2
+ const SOURCE = "telo-analyzer";
3
+ /** Alias-form pattern for `extends`: "<Alias>.<AbstractName>", two PascalCase segments. */
4
+ const EXTENDS_ALIAS_RE = /^[A-Z][A-Za-z0-9_]*\.[A-Z][A-Za-z0-9_]*$/;
5
+ /**
6
+ * Phase 3b — Validate `extends` fields on Telo.Definition docs, and flag the legacy
7
+ * `capability: <UserAbstract>` overload with CAPABILITY_SHADOWS_EXTENDS so users migrate.
8
+ *
9
+ * `extends` uses alias form ("<Alias>.<Name>") resolved against the declaring file's
10
+ * Telo.Import declarations — same pattern as `kind:` prefixes. The analyzer pre-resolves
11
+ * via AliasResolver before register() is called, so by the time this validator runs,
12
+ * the definition's effective `extends` is either the canonical form (when the alias was
13
+ * known) or the original alias-prefixed string (when it wasn't).
14
+ *
15
+ * Diagnostics:
16
+ * - EXTENDS_MALFORMED: value not in "<Alias>.<Name>" alias form, or not resolvable
17
+ * via the declaring file's imports (alias unknown → can't distinguish from a typo).
18
+ * - EXTENDS_UNKNOWN_TARGET: alias resolves to a module, but that module has no
19
+ * registered definition with the target name.
20
+ * - EXTENDS_NON_ABSTRACT: target resolves to a Telo.Definition, not a Telo.Abstract.
21
+ * - CAPABILITY_SHADOWS_EXTENDS (warning): `capability` names a user-declared abstract
22
+ * (metadata.module !== "Telo"). Builtin lifecycle capabilities (Telo.Invocable, etc.)
23
+ * never trigger this — they're lifecycle roles by design.
24
+ */
25
+ export function validateExtends(manifests, registry, aliases) {
26
+ const diagnostics = [];
27
+ // Defs forwarded from imported libraries carry `metadata.module` set to that
28
+ // library's name (stamped by the loader). The analyzer's Phase 1 already normalized
29
+ // their `extends` against the declaring library's own alias scope (`aliasesByModule`
30
+ // in analyzer.ts), so the canonical-form value is correct. What this validator can't
31
+ // re-check is whether the original alias was well-formed in the library's source —
32
+ // that's the library author's concern, surfaced when the library is analyzed as a
33
+ // root. Re-validating here against the consumer's alias scope (which doesn't know
34
+ // the library's internal aliases) would produce false-positive EXTENDS_MALFORMED /
35
+ // EXTENDS_UNKNOWN_TARGET. Skip forwarded defs entirely.
36
+ const importedModules = new Set();
37
+ for (const m of manifests) {
38
+ if (m.kind !== "Telo.Import")
39
+ continue;
40
+ const resolved = m.metadata?.resolvedModuleName;
41
+ if (resolved)
42
+ importedModules.add(resolved);
43
+ }
44
+ for (const m of manifests) {
45
+ if (m.kind !== "Telo.Definition")
46
+ continue;
47
+ const name = m.metadata?.name;
48
+ if (!name)
49
+ continue;
50
+ const ownModule = m.metadata?.module;
51
+ if (ownModule && importedModules.has(ownModule))
52
+ continue;
53
+ const filePath = m.metadata?.source;
54
+ const resource = { kind: m.kind, name };
55
+ const label = `${m.kind}/${name}`;
56
+ // --- extends validation ---
57
+ // At this point `m.extends` is whatever the manifest declared (alias form, e.g.
58
+ // "Ai.Model"). Resolve through the AliasResolver to the canonical form before
59
+ // looking up the target definition.
60
+ const extendsValue = m.extends;
61
+ if (extendsValue !== undefined) {
62
+ if (typeof extendsValue !== "string") {
63
+ diagnostics.push({
64
+ severity: DiagnosticSeverity.Error,
65
+ code: "EXTENDS_MALFORMED",
66
+ source: SOURCE,
67
+ message: `${label}: 'extends' must be a string in alias form "<Alias>.<Name>"`,
68
+ data: { resource, filePath, path: "extends" },
69
+ });
70
+ }
71
+ else if (!EXTENDS_ALIAS_RE.test(extendsValue)) {
72
+ diagnostics.push({
73
+ severity: DiagnosticSeverity.Error,
74
+ code: "EXTENDS_MALFORMED",
75
+ source: SOURCE,
76
+ message: `${label}: 'extends: ${extendsValue}' must be in alias form "<Alias>.<Name>" ` +
77
+ `(e.g. "Ai.Model"), resolved via this file's Telo.Import declarations.`,
78
+ data: { resource, filePath, path: "extends" },
79
+ });
80
+ }
81
+ else {
82
+ const prefix = extendsValue.slice(0, extendsValue.indexOf("."));
83
+ if (!aliases.hasAlias(prefix)) {
84
+ diagnostics.push({
85
+ severity: DiagnosticSeverity.Error,
86
+ code: "EXTENDS_MALFORMED",
87
+ source: SOURCE,
88
+ message: `${label}: 'extends: ${extendsValue}' — alias '${prefix}' is not a Telo.Import ` +
89
+ `in this file's scope. Declare the import or correct the alias.`,
90
+ data: { resource, filePath, path: "extends" },
91
+ });
92
+ }
93
+ else {
94
+ const canonical = aliases.resolveKind(extendsValue);
95
+ if (!canonical) {
96
+ // Alias exists but the suffix isn't in its exported kinds — behave like
97
+ // an unknown target so users see the symbol is wrong, not that the alias is.
98
+ diagnostics.push({
99
+ severity: DiagnosticSeverity.Error,
100
+ code: "EXTENDS_UNKNOWN_TARGET",
101
+ source: SOURCE,
102
+ message: `${label}: 'extends' target '${extendsValue}' is not an exported kind of alias '${prefix}'.`,
103
+ data: { resource, filePath, path: "extends" },
104
+ });
105
+ }
106
+ else {
107
+ const targetDef = registry.resolve(canonical);
108
+ if (!targetDef) {
109
+ diagnostics.push({
110
+ severity: DiagnosticSeverity.Error,
111
+ code: "EXTENDS_UNKNOWN_TARGET",
112
+ source: SOURCE,
113
+ message: `${label}: 'extends' target '${extendsValue}' (resolved: '${canonical}') is not a registered definition.`,
114
+ data: { resource, filePath, path: "extends" },
115
+ });
116
+ }
117
+ else if (targetDef.kind !== "Telo.Abstract") {
118
+ diagnostics.push({
119
+ severity: DiagnosticSeverity.Error,
120
+ code: "EXTENDS_NON_ABSTRACT",
121
+ source: SOURCE,
122
+ message: `${label}: 'extends' target '${extendsValue}' (resolved: '${canonical}') is a ${targetDef.kind}, not a Telo.Abstract. ` +
123
+ `Only Telo.Abstract declarations may be extended.`,
124
+ data: { resource, filePath, path: "extends" },
125
+ });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ // --- legacy capability-as-abstract warning ---
132
+ // `capability` is expected to name either a builtin lifecycle (module "Telo") or a
133
+ // user-declared abstract. The latter is the pre-`extends` overload — emit a warning
134
+ // suggesting the canonical form. Resolve through aliases first because the manifest
135
+ // retains the alias-prefixed form (e.g. "AbstractLib.Greeter"); the registered key
136
+ // is the canonical form after alias resolution (e.g. "abstract-lib.Greeter").
137
+ const capability = m.capability;
138
+ if (typeof capability === "string") {
139
+ const resolvedCap = aliases.resolveKind(capability) ?? capability;
140
+ const capDef = registry.resolve(resolvedCap);
141
+ if (capDef &&
142
+ capDef.kind === "Telo.Abstract" &&
143
+ capDef.metadata.module !== "Telo") {
144
+ // Build suggestion using the original alias form if aliases can produce it.
145
+ const aliasesForModule = aliases.aliasesFor(capDef.metadata.module);
146
+ const suggestion = aliasesForModule.length > 0
147
+ ? `${aliasesForModule[0]}.${capDef.metadata.name}`
148
+ : `${capDef.metadata.module}.${capDef.metadata.name}`;
149
+ diagnostics.push({
150
+ severity: DiagnosticSeverity.Warning,
151
+ code: "CAPABILITY_SHADOWS_EXTENDS",
152
+ source: SOURCE,
153
+ message: `${label}: 'capability: ${capability}' names a user-declared abstract. ` +
154
+ `Prefer 'extends' for implements-this-abstract declarations; 'capability' should ` +
155
+ `name a lifecycle role. Use \`extends: "${suggestion}"\` with a lifecycle ` +
156
+ `\`capability\` (e.g. Telo.Invocable, Telo.Provider, Telo.Service).`,
157
+ data: { resource, filePath, path: "capability" },
158
+ });
159
+ }
160
+ }
161
+ }
162
+ return diagnostics;
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -41,7 +41,7 @@
41
41
  "ajv-formats": "^3.0.1",
42
42
  "jsonpath-plus": "^10.3.0",
43
43
  "yaml": "^2.8.3",
44
- "@telorun/sdk": "0.3.2"
44
+ "@telorun/sdk": "0.5.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^20.0.0",
package/src/analyzer.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  resolveTypeFieldToSchema,
29
29
  validateChainAgainstSchema,
30
30
  } from "./validate-cel-context.js";
31
+ import { validateExtends } from "./validate-extends.js";
31
32
  import { validateReferences } from "./validate-references.js";
32
33
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
33
34
 
@@ -293,9 +294,24 @@ export class StaticAnalyzer {
293
294
  // Register module identities and aliases.
294
295
  // The root module doc (Telo.Application or Telo.Library) provides its own
295
296
  // identity; imported modules surface their identity via resolvedModuleName/
296
- // resolvedNamespace stamped onto the Telo.Import by the loader (so we don't
297
- // need to include imported module manifests in the analysis set, avoiding false
298
- // reference errors in the parent context).
297
+ // resolvedNamespace stamped onto the Telo.Import by the loader.
298
+ //
299
+ // Two alias scopes are tracked:
300
+ // - `aliases` — the consumer's aliases, populated from Telo.Imports declared in
301
+ // the entry manifest (its own module).
302
+ // - `aliasesByModule` — per-imported-library aliases, populated from Telo.Imports
303
+ // forwarded by the loader from inside imported libraries. A library may use
304
+ // different alias names than the consumer for the same dependency; resolving
305
+ // a forwarded def's `extends` / `capability` against the consumer's scope
306
+ // would either fail or pick the wrong target. Each forwarded def is normalized
307
+ // in its own library's scope.
308
+ const rootModules = new Set<string>();
309
+ for (const m of manifests) {
310
+ if (isModuleKind(m.kind) && m.metadata?.name) {
311
+ rootModules.add(m.metadata.name as string);
312
+ }
313
+ }
314
+ const aliasesByModule = new Map<string, AliasResolver>();
299
315
  for (const m of manifests) {
300
316
  if (isModuleKind(m.kind)) {
301
317
  const namespace = ((m.metadata as any).namespace as string | undefined) ?? null;
@@ -311,30 +327,60 @@ export class StaticAnalyzer {
311
327
  | string
312
328
  | null
313
329
  | undefined;
330
+ const ownModule = (m.metadata as { module?: string } | undefined)?.module;
314
331
  if (alias && source) {
315
332
  const targetModule =
316
333
  resolvedModuleName ?? source.split("/").filter(Boolean).pop() ?? source;
317
- aliases.registerImport(alias, targetModule, exportedKinds);
334
+ // Module identity is registered globally so x-telo-ref resolution sees
335
+ // transitively-imported modules regardless of which scope brought them in.
318
336
  if (resolvedModuleName) {
319
337
  defs.registerModuleIdentity(resolvedNamespace ?? null, resolvedModuleName);
320
338
  }
339
+ // Alias registration is scoped: consumer imports vs. imported-library imports.
340
+ if (!ownModule || rootModules.has(ownModule)) {
341
+ aliases.registerImport(alias, targetModule, exportedKinds);
342
+ } else {
343
+ let libResolver = aliasesByModule.get(ownModule);
344
+ if (!libResolver) {
345
+ libResolver = new AliasResolver();
346
+ aliasesByModule.set(ownModule, libResolver);
347
+ }
348
+ libResolver.registerImport(alias, targetModule, exportedKinds);
349
+ }
321
350
  }
322
351
  }
323
352
  }
324
353
 
325
- // Register definitions from Telo.Definition resources.
326
- // Normalize alias-prefixed `capability` to canonical form so extendedBy lookup works
327
- // (e.g. "Workflow.Backend" "workflow.Backend" when "Workflow" is a known alias).
354
+ // Register definitions from Telo.Definition AND Telo.Abstract resources.
355
+ // Abstracts declare contracts that implementations target via `extends` (canonical)
356
+ // or `capability: <AbstractKind>` (legacy). Until they're registered, validateReferences
357
+ // can't resolve x-telo-ref entries pointing at library-declared abstracts — so abstracts
358
+ // must go through register() too, not just the kernel builtins in the constructor.
359
+ //
360
+ // Normalize alias-prefixed `capability` and `extends` to canonical form using the
361
+ // declaring scope's resolver, so `extendedBy` is keyed by canonical kind regardless
362
+ // of alias choices. `capability` covers the legacy implements-this-abstract overload;
363
+ // `extends` is the canonical first-class form.
328
364
  for (const m of manifests) {
329
- if (m.kind === "Telo.Definition") {
330
- const def = m as unknown as ResourceDefinition;
331
- const resolvedCapability = def.capability
332
- ? (aliases.resolveKind(def.capability) ?? def.capability)
333
- : def.capability;
334
- defs.register(
335
- resolvedCapability !== def.capability ? { ...def, capability: resolvedCapability } : def,
336
- );
337
- }
365
+ if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract") continue;
366
+ const def = m as unknown as ResourceDefinition;
367
+ const ownModule = (def.metadata as { module?: string } | undefined)?.module;
368
+ const scopeResolver =
369
+ ownModule && !rootModules.has(ownModule)
370
+ ? (aliasesByModule.get(ownModule) ?? new AliasResolver())
371
+ : aliases;
372
+ const resolvedCapability = def.capability
373
+ ? (scopeResolver.resolveKind(def.capability) ?? def.capability)
374
+ : def.capability;
375
+ const resolvedExtends = def.extends
376
+ ? (scopeResolver.resolveKind(def.extends) ?? def.extends)
377
+ : def.extends;
378
+ const needsPatch =
379
+ resolvedCapability !== def.capability || resolvedExtends !== def.extends;
380
+ const normalized = needsPatch
381
+ ? { ...def, capability: resolvedCapability, extends: resolvedExtends }
382
+ : def;
383
+ defs.register(normalized);
338
384
  }
339
385
 
340
386
  // Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
@@ -520,6 +566,9 @@ export class StaticAnalyzer {
520
566
  // Validate resource references (Phase 3)
521
567
  diagnostics.push(...validateReferences(allManifests, { aliases, definitions: defs }));
522
568
 
569
+ // Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
570
+ diagnostics.push(...validateExtends(allManifests, defs, aliases));
571
+
523
572
  // Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
524
573
  diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
525
574
 
package/src/builtins.ts CHANGED
@@ -27,9 +27,13 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
27
27
  additionalProperties: true,
28
28
  },
29
29
  capability: { type: "string" },
30
+ schema: { type: "object", additionalProperties: true },
30
31
  },
31
32
  required: ["metadata"],
32
- additionalProperties: false,
33
+ // Telo.Abstract is an extension point by design — it must accept forward-compatible
34
+ // fields (e.g. inputType/outputType from the typed-abstracts plan) without requiring
35
+ // the analyzer to enumerate them here.
36
+ additionalProperties: true,
33
37
  },
34
38
  },
35
39
  {
@@ -55,6 +59,12 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
55
59
  source: { type: "string" },
56
60
  variables: { type: "object" },
57
61
  secrets: { type: "object" },
62
+ runtime: {
63
+ oneOf: [
64
+ { type: "string" },
65
+ { type: "array", items: { type: "string" } },
66
+ ],
67
+ },
58
68
  },
59
69
  required: ["metadata", "source"],
60
70
  additionalProperties: false,
@@ -32,13 +32,22 @@ export class DefinitionRegistry {
32
32
  const key = mod ? `${mod}.${name}` : name;
33
33
  this.defs.set(key, definition);
34
34
  this.fieldMaps.set(key, buildReferenceFieldMap(definition.schema ?? {}));
35
+ // `capability` populates extendedBy for backward-compat with the legacy pattern where
36
+ // a concrete definition overloaded `capability: <AbstractKind>` to mean "implements
37
+ // this abstract." The canonical pattern is `extends` (below). Both populate the index,
38
+ // unioned — so in-flight modules pre-migration keep working.
35
39
  if (definition.capability) {
36
- const children = this.extendedBy.get(definition.capability);
37
- if (children) {
38
- children.push(key);
39
- } else {
40
- this.extendedBy.set(definition.capability, [key]);
41
- }
40
+ this.addExtendedBy(definition.capability, key);
41
+ }
42
+ // `extends` — first-class "implements-this-abstract" edge. Alias-form resolution
43
+ // happens in the analyzer before register() is called (analyzer.ts pre-resolves
44
+ // via aliases.resolveKind), so the value here is already the canonical kind string
45
+ // (e.g. "workflow.Backend"). If the analyzer could not resolve the alias (partial
46
+ // context, or the declaring file doesn't import the target's alias), the value
47
+ // stays as the original alias-prefixed form; validateExtends emits EXTENDS_MALFORMED
48
+ // or EXTENDS_UNKNOWN_TARGET depending on the case.
49
+ if (definition.extends) {
50
+ this.addExtendedBy(definition.extends, key);
42
51
  }
43
52
  // Auto-register the telo identity when any Telo built-in is registered.
44
53
  if (definition.kind === "Telo.Abstract" && mod === "Telo") {
@@ -51,6 +60,15 @@ export class DefinitionRegistry {
51
60
  }
52
61
  }
53
62
 
63
+ private addExtendedBy(parent: string, child: string): void {
64
+ const children = this.extendedBy.get(parent);
65
+ if (children) {
66
+ if (!children.includes(child)) children.push(child);
67
+ } else {
68
+ this.extendedBy.set(parent, [child]);
69
+ }
70
+ }
71
+
54
72
  /** Register a module identity for x-telo-ref resolution.
55
73
  * Call once per module doc (Telo.Application or Telo.Library) when the manifest is loaded.
56
74
  * @param namespace The module's metadata.namespace (e.g. "std"), or null for telo built-ins.
@@ -333,8 +333,34 @@ export class Loader {
333
333
 
334
334
  async loadManifests(entryUrl: string): Promise<ResourceManifest[]> {
335
335
  const visited = new Set<string>([entryUrl]);
336
+ // Cache resolved library identity per import URL so a Telo.Import re-encountered
337
+ // through a different chain still gets `resolvedModuleName` / `resolvedNamespace`
338
+ // stamped — without re-loading the target. The early `visited` short-circuit used
339
+ // to silently leave duplicate Telo.Imports unstamped, which broke alias resolution
340
+ // when the same library was imported by two different files in the same analysis set.
341
+ const libraryIdentityByUrl = new Map<
342
+ string,
343
+ { name: string; namespace: string | null }
344
+ >();
336
345
  const entry = await this.loadModule(entryUrl);
337
346
 
347
+ // Forward Telo.Definition, Telo.Abstract, AND Telo.Import docs from imported
348
+ // libraries to the analyzer so its downstream passes can see them:
349
+ // - Definitions / Abstracts feed cross-package `x-telo-ref` resolution and
350
+ // `extends` target validation.
351
+ // - Imports feed the per-library alias resolver — alias-form `extends` inside
352
+ // a library (e.g. ai-openai's `extends: Ai.Model`) resolves against THAT
353
+ // library's own `Telo.Import` declarations, not the root manifest's. Without
354
+ // forwarding the imports, importing such a library would surface a spurious
355
+ // EXTENDS_MALFORMED for an alias the library legitimately uses internally.
356
+ // Alias resolution itself stays in the analyzer; the loader's only semantic
357
+ // action is stamping `resolvedModuleName` / `resolvedNamespace` — recording the
358
+ // result of loading. Identity is cached per URL (see libraryIdentityByUrl above)
359
+ // because the same library can be reached through multiple chains, and every
360
+ // Telo.Import doc — including the duplicates short-circuited by `visited` —
361
+ // must end up stamped, otherwise per-scope alias resolution falls back to a
362
+ // path-derived string (e.g. "abstract-lib.yaml") and produces wrong canonical
363
+ // kinds.
338
364
  const importedDefs: ResourceManifest[] = [];
339
365
  const queue: ResourceManifest[] = [...entry];
340
366
 
@@ -348,36 +374,56 @@ export class Loader {
348
374
  importSource.startsWith(".") || importSource.startsWith("/")
349
375
  ? this.pick(base).resolveRelative(base, importSource)
350
376
  : importSource;
351
- if (visited.has(importUrl)) continue;
352
- visited.add(importUrl);
353
- let imported: ResourceManifest[];
354
- try {
355
- imported = await this.loadModule(importUrl);
356
- } catch (err) {
357
- const e = err instanceof Error ? err : new Error(String(err));
358
- (e as any).sourceLine = (m.metadata as any)?.sourceLine ?? 0;
359
- throw e;
360
- }
361
- // Import target must be a Telo.Library. Check the Library branch
362
- // explicitly rather than "anything that's a module kind" so that a
363
- // future third kind can't silently slip past as a valid import target.
364
- const importedLibrary = imported.find((im) => im.kind === "Telo.Library");
365
- const importedApplication = imported.find((im) => im.kind === "Telo.Application");
366
- if (importedApplication) {
367
- const e = new Error(
368
- `Telo.Import target '${importSource}' is a Telo.Application. ` +
369
- `Only Telo.Library modules may be imported. Applications are run directly, not imported.`,
370
- );
371
- (e as any).sourceLine = (m.metadata as any)?.sourceLine ?? 0;
372
- throw e;
377
+
378
+ if (!visited.has(importUrl)) {
379
+ visited.add(importUrl);
380
+ let imported: ResourceManifest[];
381
+ try {
382
+ imported = await this.loadModule(importUrl);
383
+ } catch (err) {
384
+ const e = err instanceof Error ? err : new Error(String(err));
385
+ (e as any).sourceLine = (m.metadata as any)?.sourceLine ?? 0;
386
+ throw e;
387
+ }
388
+ // Import target must be a Telo.Library. Check the Library branch
389
+ // explicitly rather than "anything that's a module kind" so that a
390
+ // future third kind can't silently slip past as a valid import target.
391
+ const importedLibrary = imported.find((im) => im.kind === "Telo.Library");
392
+ const importedApplication = imported.find((im) => im.kind === "Telo.Application");
393
+ if (importedApplication) {
394
+ const e = new Error(
395
+ `Telo.Import target '${importSource}' is a Telo.Application. ` +
396
+ `Only Telo.Library modules may be imported. Applications are run directly, not imported.`,
397
+ );
398
+ (e as any).sourceLine = (m.metadata as any)?.sourceLine ?? 0;
399
+ throw e;
400
+ }
401
+ if (importedLibrary?.metadata?.name) {
402
+ libraryIdentityByUrl.set(importUrl, {
403
+ name: importedLibrary.metadata.name as string,
404
+ namespace: ((importedLibrary.metadata as any).namespace as string | null) ?? null,
405
+ });
406
+ }
407
+ for (const im of imported) {
408
+ if (
409
+ im.kind === "Telo.Definition" ||
410
+ im.kind === "Telo.Abstract" ||
411
+ im.kind === "Telo.Import"
412
+ ) {
413
+ importedDefs.push(im);
414
+ }
415
+ if (im.kind === "Telo.Import") queue.push(im);
416
+ }
373
417
  }
374
- const importedModule = importedLibrary;
375
- if (importedModule?.metadata?.name) {
418
+
419
+ // Stamp m with cached identity (works for both fresh and duplicate visits).
420
+ const identity = libraryIdentityByUrl.get(importUrl);
421
+ if (identity) {
376
422
  const pi = (m.metadata as any)?.positionIndex;
377
423
  m.metadata = {
378
424
  ...m.metadata,
379
- resolvedModuleName: importedModule.metadata.name as string,
380
- resolvedNamespace: (importedModule.metadata as any).namespace ?? null,
425
+ resolvedModuleName: identity.name,
426
+ resolvedNamespace: identity.namespace,
381
427
  };
382
428
  if (pi) {
383
429
  Object.defineProperty(m.metadata, "positionIndex", {
@@ -388,10 +434,6 @@ export class Loader {
388
434
  });
389
435
  }
390
436
  }
391
- for (const im of imported) {
392
- if (im.kind === "Telo.Definition") importedDefs.push(im);
393
- if (im.kind === "Telo.Import") queue.push(im);
394
- }
395
437
  }
396
438
 
397
439
  return [...entry, ...importedDefs];
@@ -0,0 +1,175 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
5
+
6
+ const SOURCE = "telo-analyzer";
7
+
8
+ /** Alias-form pattern for `extends`: "<Alias>.<AbstractName>", two PascalCase segments. */
9
+ const EXTENDS_ALIAS_RE = /^[A-Z][A-Za-z0-9_]*\.[A-Z][A-Za-z0-9_]*$/;
10
+
11
+ /**
12
+ * Phase 3b — Validate `extends` fields on Telo.Definition docs, and flag the legacy
13
+ * `capability: <UserAbstract>` overload with CAPABILITY_SHADOWS_EXTENDS so users migrate.
14
+ *
15
+ * `extends` uses alias form ("<Alias>.<Name>") resolved against the declaring file's
16
+ * Telo.Import declarations — same pattern as `kind:` prefixes. The analyzer pre-resolves
17
+ * via AliasResolver before register() is called, so by the time this validator runs,
18
+ * the definition's effective `extends` is either the canonical form (when the alias was
19
+ * known) or the original alias-prefixed string (when it wasn't).
20
+ *
21
+ * Diagnostics:
22
+ * - EXTENDS_MALFORMED: value not in "<Alias>.<Name>" alias form, or not resolvable
23
+ * via the declaring file's imports (alias unknown → can't distinguish from a typo).
24
+ * - EXTENDS_UNKNOWN_TARGET: alias resolves to a module, but that module has no
25
+ * registered definition with the target name.
26
+ * - EXTENDS_NON_ABSTRACT: target resolves to a Telo.Definition, not a Telo.Abstract.
27
+ * - CAPABILITY_SHADOWS_EXTENDS (warning): `capability` names a user-declared abstract
28
+ * (metadata.module !== "Telo"). Builtin lifecycle capabilities (Telo.Invocable, etc.)
29
+ * never trigger this — they're lifecycle roles by design.
30
+ */
31
+ export function validateExtends(
32
+ manifests: ResourceManifest[],
33
+ registry: DefinitionRegistry,
34
+ aliases: AliasResolver,
35
+ ): AnalysisDiagnostic[] {
36
+ const diagnostics: AnalysisDiagnostic[] = [];
37
+
38
+ // Defs forwarded from imported libraries carry `metadata.module` set to that
39
+ // library's name (stamped by the loader). The analyzer's Phase 1 already normalized
40
+ // their `extends` against the declaring library's own alias scope (`aliasesByModule`
41
+ // in analyzer.ts), so the canonical-form value is correct. What this validator can't
42
+ // re-check is whether the original alias was well-formed in the library's source —
43
+ // that's the library author's concern, surfaced when the library is analyzed as a
44
+ // root. Re-validating here against the consumer's alias scope (which doesn't know
45
+ // the library's internal aliases) would produce false-positive EXTENDS_MALFORMED /
46
+ // EXTENDS_UNKNOWN_TARGET. Skip forwarded defs entirely.
47
+ const importedModules = new Set<string>();
48
+ for (const m of manifests) {
49
+ if (m.kind !== "Telo.Import") continue;
50
+ const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)?.resolvedModuleName;
51
+ if (resolved) importedModules.add(resolved);
52
+ }
53
+
54
+ for (const m of manifests) {
55
+ if (m.kind !== "Telo.Definition") 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
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
61
+ const resource = { kind: m.kind, name };
62
+ const label = `${m.kind}/${name}`;
63
+
64
+ // --- extends validation ---
65
+ // At this point `m.extends` is whatever the manifest declared (alias form, e.g.
66
+ // "Ai.Model"). Resolve through the AliasResolver to the canonical form before
67
+ // looking up the target definition.
68
+ const extendsValue = (m as { extends?: unknown }).extends;
69
+ if (extendsValue !== undefined) {
70
+ if (typeof extendsValue !== "string") {
71
+ diagnostics.push({
72
+ severity: DiagnosticSeverity.Error,
73
+ code: "EXTENDS_MALFORMED",
74
+ source: SOURCE,
75
+ message: `${label}: 'extends' must be a string in alias form "<Alias>.<Name>"`,
76
+ data: { resource, filePath, path: "extends" },
77
+ });
78
+ } else if (!EXTENDS_ALIAS_RE.test(extendsValue)) {
79
+ diagnostics.push({
80
+ severity: DiagnosticSeverity.Error,
81
+ code: "EXTENDS_MALFORMED",
82
+ source: SOURCE,
83
+ message:
84
+ `${label}: 'extends: ${extendsValue}' must be in alias form "<Alias>.<Name>" ` +
85
+ `(e.g. "Ai.Model"), resolved via this file's Telo.Import declarations.`,
86
+ data: { resource, filePath, path: "extends" },
87
+ });
88
+ } else {
89
+ const prefix = extendsValue.slice(0, extendsValue.indexOf("."));
90
+ if (!aliases.hasAlias(prefix)) {
91
+ diagnostics.push({
92
+ severity: DiagnosticSeverity.Error,
93
+ code: "EXTENDS_MALFORMED",
94
+ source: SOURCE,
95
+ message:
96
+ `${label}: 'extends: ${extendsValue}' — alias '${prefix}' is not a Telo.Import ` +
97
+ `in this file's scope. Declare the import or correct the alias.`,
98
+ data: { resource, filePath, path: "extends" },
99
+ });
100
+ } else {
101
+ const canonical = aliases.resolveKind(extendsValue);
102
+ if (!canonical) {
103
+ // Alias exists but the suffix isn't in its exported kinds — behave like
104
+ // an unknown target so users see the symbol is wrong, not that the alias is.
105
+ diagnostics.push({
106
+ severity: DiagnosticSeverity.Error,
107
+ code: "EXTENDS_UNKNOWN_TARGET",
108
+ source: SOURCE,
109
+ message: `${label}: 'extends' target '${extendsValue}' is not an exported kind of alias '${prefix}'.`,
110
+ data: { resource, filePath, path: "extends" },
111
+ });
112
+ } else {
113
+ const targetDef = registry.resolve(canonical);
114
+ if (!targetDef) {
115
+ diagnostics.push({
116
+ severity: DiagnosticSeverity.Error,
117
+ code: "EXTENDS_UNKNOWN_TARGET",
118
+ source: SOURCE,
119
+ message: `${label}: 'extends' target '${extendsValue}' (resolved: '${canonical}') is not a registered definition.`,
120
+ data: { resource, filePath, path: "extends" },
121
+ });
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
+ });
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ // --- legacy capability-as-abstract warning ---
139
+ // `capability` is expected to name either a builtin lifecycle (module "Telo") or a
140
+ // user-declared abstract. The latter is the pre-`extends` overload — emit a warning
141
+ // suggesting the canonical form. Resolve through aliases first because the manifest
142
+ // retains the alias-prefixed form (e.g. "AbstractLib.Greeter"); the registered key
143
+ // is the canonical form after alias resolution (e.g. "abstract-lib.Greeter").
144
+ const capability = (m as { capability?: unknown }).capability;
145
+ if (typeof capability === "string") {
146
+ const resolvedCap = aliases.resolveKind(capability) ?? capability;
147
+ const capDef = registry.resolve(resolvedCap);
148
+ if (
149
+ capDef &&
150
+ capDef.kind === "Telo.Abstract" &&
151
+ capDef.metadata.module !== "Telo"
152
+ ) {
153
+ // Build suggestion using the original alias form if aliases can produce it.
154
+ const aliasesForModule = aliases.aliasesFor(capDef.metadata.module);
155
+ const suggestion =
156
+ aliasesForModule.length > 0
157
+ ? `${aliasesForModule[0]}.${capDef.metadata.name}`
158
+ : `${capDef.metadata.module}.${capDef.metadata.name}`;
159
+ diagnostics.push({
160
+ severity: DiagnosticSeverity.Warning,
161
+ code: "CAPABILITY_SHADOWS_EXTENDS",
162
+ source: SOURCE,
163
+ message:
164
+ `${label}: 'capability: ${capability}' names a user-declared abstract. ` +
165
+ `Prefer 'extends' for implements-this-abstract declarations; 'capability' should ` +
166
+ `name a lifecycle role. Use \`extends: "${suggestion}"\` with a lifecycle ` +
167
+ `\`capability\` (e.g. Telo.Invocable, Telo.Provider, Telo.Service).`,
168
+ data: { resource, filePath, path: "capability" },
169
+ });
170
+ }
171
+ }
172
+ }
173
+
174
+ return diagnostics;
175
+ }