@telorun/analyzer 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,26 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
- import type { LoadedFile, LoadedGraph, LoadedModule } from "./loaded-types.js";
2
+ import type { LoadedGraph, LoadedModule } from "./loaded-types.js";
3
+ import type { LoadedFile } from "./loaded-types.js";
3
4
  import { isModuleKind } from "./module-kinds.js";
4
5
 
6
+ /** One parsed `exports.resources` / `exports.kinds` entry. `name` is the exported
7
+ * instance name or kind suffix (the part after the dot, or the whole entry); `alias`
8
+ * (when set) is this library's own import the entry RE-EXPORTS from. */
9
+ export interface ParsedExportEntry {
10
+ name: string;
11
+ alias?: string;
12
+ }
13
+
14
+ /** Parse a single dotted export entry: `Alias.Name` → `{name: "Name", alias: "Alias"}`,
15
+ * bare `Name` → `{name: "Name"}`. The single grammar for `exports.resources` and
16
+ * `exports.kinds`, shared by the kernel's import controller and the analyzer/editor so
17
+ * the dotted-name split can't drift. A leading dot (`.Name`) has no alias by design —
18
+ * the empty prefix isn't a valid alias. */
19
+ export function parseExportEntry(entry: string): ParsedExportEntry {
20
+ const dot = entry.indexOf(".");
21
+ return dot > 0 ? { name: entry.slice(dot + 1), alias: entry.slice(0, dot) } : { name: entry };
22
+ }
23
+
5
24
  /** The import-boundary forwarding rule, shared by `flattenForAnalyzer` (the
6
25
  * CLI / kernel loader path) and the telo-editor's workspace projection so the
7
26
  * two cannot drift. Given one module's stamped manifests and whether that
@@ -29,9 +48,14 @@ export function selectModuleManifestsForAnalysis(
29
48
  if (isRoot) return moduleManifests;
30
49
 
31
50
  const libDoc = moduleManifests.find((m) => isModuleKind(m.kind));
32
- const exportedResources = new Set<string>(
33
- (libDoc as { exports?: { resources?: string[] } } | undefined)?.exports?.resources ?? [],
34
- );
51
+ // An `exports.resources` entry is a bare name or a dotted `Alias.Name` (re-export). Only the
52
+ // export NAME matches a local instance below; re-exports are forwarded by `forwardReExports`.
53
+ const exportedResources = new Set<string>();
54
+ for (const entry of (libDoc as { exports?: { resources?: unknown[] } } | undefined)?.exports
55
+ ?.resources ?? []) {
56
+ if (typeof entry !== "string") continue;
57
+ exportedResources.add(parseExportEntry(entry).name);
58
+ }
35
59
 
36
60
  const out: ResourceManifest[] = [];
37
61
  for (const m of moduleManifests) {
@@ -96,6 +120,8 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
96
120
  }
97
121
  }
98
122
 
123
+ forwardReExports(graph, result);
124
+
99
125
  // Stamp resolved import identity on every Telo.Import in the result by
100
126
  // reading the edge's pre-resolved name/namespace — no re-derivation from
101
127
  // manifest metadata. The edge is keyed by (owner-file, alias) which is
@@ -120,6 +146,193 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
120
146
  return result;
121
147
  }
122
148
 
149
+ /** A re-export declared in a library's `exports.resources` as a dotted `Alias.Name`:
150
+ * module `module` re-exports the instance `name` reached through its own import
151
+ * aliased `alias`. */
152
+ export interface ReExportSpec {
153
+ module: string;
154
+ alias: string;
155
+ name: string;
156
+ }
157
+
158
+ /** Extract re-export specs from a library doc's `exports.resources` — the dotted `Alias.Name`
159
+ * entries (bare-name locals are forwarded by the BFS instead). Shared by the CLI graph path
160
+ * and the editor's workspace projection so the two cannot drift. */
161
+ export function reExportSpecsFromExports(
162
+ moduleName: string,
163
+ exportsResources: readonly unknown[] | undefined,
164
+ ): ReExportSpec[] {
165
+ const specs: ReExportSpec[] = [];
166
+ for (const entry of exportsResources ?? []) {
167
+ if (typeof entry !== "string") continue;
168
+ const { name, alias } = parseExportEntry(entry);
169
+ if (!alias || alias === "Self") continue;
170
+ specs.push({ module: moduleName, alias, name });
171
+ }
172
+ return specs;
173
+ }
174
+
175
+ /** Forward re-exported instances (`exports.resources: [!ref Alias.name]`) transitively so a
176
+ * consumer's `!ref Consumer.name` resolves in `resolveRefSentinels` (keyed by the RE-EXPORTING
177
+ * module). The owning instance is already forwarded under its own module; here we emit an
178
+ * additional copy stamped under each re-exporting module, with an already-canonical kind. A
179
+ * fixpoint loop forwards chains of arbitrary depth (`app → api → domain → …`): each pass can
180
+ * resolve a re-export whose source was emitted in a prior pass. Graph-agnostic: `aliasToModule`
181
+ * maps `(module, alias)` to the imported module's name. Mutates `result` in place. */
182
+ export function forwardReExportManifests(
183
+ result: ResourceManifest[],
184
+ specs: readonly ReExportSpec[],
185
+ aliasToModule: (module: string, alias: string) => string | undefined,
186
+ ): void {
187
+ // Index forwarded instances by `module\0name` (only re-export TARGETS are forwarded).
188
+ const forwarded = new Map<string, ResourceManifest>();
189
+ for (const m of result) {
190
+ const meta = m.metadata as { module?: string; name?: string; forwardedExport?: boolean };
191
+ if (meta?.forwardedExport && meta.module && meta.name) {
192
+ forwarded.set(`${meta.module}\0${meta.name}`, m);
193
+ }
194
+ }
195
+
196
+ // Canonicalize an authored/forwarded kind to a scope-independent `<module>.<Kind>` using the
197
+ // owning module's own import aliases. Idempotent: an already-canonical kind whose prefix isn't
198
+ // an alias of `ownerModule` is returned unchanged, so re-exports of re-exports stay stable.
199
+ const canonicalKind = (kind: string, ownerModule: string): string => {
200
+ if (kind.startsWith("Self.")) return `${ownerModule}.${kind.slice("Self.".length)}`;
201
+ const dot = kind.indexOf(".");
202
+ if (dot <= 0) return kind;
203
+ const target = aliasToModule(ownerModule, kind.slice(0, dot));
204
+ return target ? `${target}.${kind.slice(dot + 1)}` : kind;
205
+ };
206
+
207
+ // Fixpoint — bounded by the number of specs (each can be satisfied at most once).
208
+ for (let pass = 0; pass <= specs.length; pass++) {
209
+ let added = false;
210
+ for (const spec of specs) {
211
+ const key = `${spec.module}\0${spec.name}`;
212
+ if (forwarded.has(key)) continue;
213
+ const sourceModule = aliasToModule(spec.module, spec.alias);
214
+ if (!sourceModule) continue;
215
+ const src = forwarded.get(`${sourceModule}\0${spec.name}`);
216
+ if (!src) continue; // source not forwarded yet — a later pass may satisfy it
217
+ const kind = canonicalKind(src.kind as string, sourceModule);
218
+ const manifest: ResourceManifest = {
219
+ ...src,
220
+ kind,
221
+ metadata: {
222
+ ...src.metadata,
223
+ name: spec.name,
224
+ module: spec.module,
225
+ forwardedExport: true,
226
+ } as ResourceManifest["metadata"],
227
+ };
228
+ result.push(manifest);
229
+ forwarded.set(key, manifest);
230
+ added = true;
231
+ }
232
+ if (!added) break;
233
+ }
234
+ }
235
+
236
+ /** Resolve every library's `exports.kinds` to a per-module map `suffix → canonical
237
+ * <owningModule>.<Kind>`, following re-exports (`Alias.Kind`) transitively via a fixpoint.
238
+ * `modules` lists each library's name + its raw `exports.kinds`; `aliasToModule(module, alias)`
239
+ * maps one of that module's import aliases to the imported module's name. Graph-agnostic —
240
+ * shared by the CLI graph path and the editor's workspace projection. */
241
+ export function resolveExportedKinds(
242
+ modules: ReadonlyArray<{ module: string; exportsKinds: readonly string[] }>,
243
+ aliasToModule: (module: string, alias: string) => string | undefined,
244
+ ): Map<string, Map<string, string>> {
245
+ const out = new Map<string, Map<string, string>>();
246
+ const tableFor = (m: string): Map<string, string> => {
247
+ let t = out.get(m);
248
+ if (!t) out.set(m, (t = new Map()));
249
+ return t;
250
+ };
251
+ for (let pass = 0; pass <= modules.length; pass++) {
252
+ let changed = false;
253
+ for (const { module, exportsKinds } of modules) {
254
+ const table = tableFor(module);
255
+ for (const entry of exportsKinds) {
256
+ const { name: suffix, alias } = parseExportEntry(entry);
257
+ if (table.has(suffix)) continue;
258
+ if (!alias) {
259
+ table.set(suffix, `${module}.${suffix}`);
260
+ changed = true;
261
+ continue;
262
+ }
263
+ const source = aliasToModule(module, alias);
264
+ const canonical = source ? out.get(source)?.get(suffix) : undefined;
265
+ if (canonical) {
266
+ table.set(suffix, canonical);
267
+ changed = true;
268
+ }
269
+ }
270
+ }
271
+ if (!changed) break;
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /** Stamp `metadata.reExportedKinds` (suffix → canonical kind) onto every `Telo.Import` whose
277
+ * target re-exports kinds, so the analyzer can register the re-export mappings. Only entries
278
+ * that point at a module OTHER than the import's own target are stamped (genuine re-exports;
279
+ * a locally-defined kind resolves through the normal alias path). Stamped on `metadata` (which
280
+ * permits additional properties, like `resolvedModuleName`) since the `Telo.Import` schema
281
+ * forbids extra top-level fields. Shared by both paths. */
282
+ export function stampReExportedKinds(
283
+ imports: ReadonlyArray<{ manifest: ResourceManifest; targetModule: string }>,
284
+ exportedKinds: Map<string, Map<string, string>>,
285
+ ): void {
286
+ for (const { manifest, targetModule } of imports) {
287
+ const table = exportedKinds.get(targetModule);
288
+ if (!table) continue;
289
+ const reExported: Record<string, string> = {};
290
+ for (const [suffix, canonical] of table) {
291
+ if (canonical !== `${targetModule}.${suffix}`) reExported[suffix] = canonical;
292
+ }
293
+ if (Object.keys(reExported).length === 0) continue;
294
+ (manifest.metadata as Record<string, unknown>).reExportedKinds = reExported;
295
+ }
296
+ }
297
+
298
+ /** CLI/kernel adapter: collect re-export specs + alias map from a LoadedGraph. */
299
+ function forwardReExports(graph: LoadedGraph, result: ResourceManifest[]): void {
300
+ const ownerSourceOf = new Map<string, string>();
301
+ const specs: ReExportSpec[] = [];
302
+ const kindModules: Array<{ module: string; exportsKinds: string[] }> = [];
303
+ for (const [source, mod] of graph.modules) {
304
+ if (source === graph.rootSource) continue; // root is an Application — no exports
305
+ const libDoc = mod.owner.manifests.find((m) => m && isModuleKind(m.kind)) as
306
+ | (ResourceManifest & { exports?: { resources?: unknown[]; kinds?: string[] } })
307
+ | undefined;
308
+ const moduleName = libDoc?.metadata?.name as string | undefined;
309
+ if (!libDoc || !moduleName) continue;
310
+ ownerSourceOf.set(moduleName, mod.owner.source);
311
+ specs.push(...reExportSpecsFromExports(moduleName, libDoc.exports?.resources));
312
+ kindModules.push({ module: moduleName, exportsKinds: libDoc.exports?.kinds ?? [] });
313
+ }
314
+ const aliasToModule = (module: string, alias: string): string | undefined => {
315
+ const ownerSource = ownerSourceOf.get(module);
316
+ return ownerSource
317
+ ? (graph.importEdges.get(ownerSource)?.get(alias)?.targetModuleName ?? undefined)
318
+ : undefined;
319
+ };
320
+ forwardReExportManifests(result, specs, aliasToModule);
321
+
322
+ // Resolve every library's re-exported kinds and stamp them onto the consumer-facing
323
+ // Telo.Import manifests so the analyzer can register the re-export mappings.
324
+ const exportedKinds = resolveExportedKinds(kindModules, aliasToModule);
325
+ const imports: Array<{ manifest: ResourceManifest; targetModule: string }> = [];
326
+ for (const m of result) {
327
+ if (m.kind !== "Telo.Import") continue;
328
+ const owner = (m.metadata as { source?: string } | undefined)?.source;
329
+ const alias = m.metadata?.name as string | undefined;
330
+ const target = owner && alias ? graph.importEdges.get(owner)?.get(alias)?.targetModuleName : undefined;
331
+ if (target) imports.push({ manifest: m, targetModule: target });
332
+ }
333
+ stampReExportedKinds(imports, exportedKinds);
334
+ }
335
+
123
336
  /** Project a LoadedModule (owner + partials) to a flat ResourceManifest[]
124
337
  * with `metadata.module` stamped on non-module docs. The kernel's runtime
125
338
  * entry load uses this to convert a `Loader.loadModule` result into the
package/src/index.ts CHANGED
@@ -12,7 +12,14 @@ export type {
12
12
  export {
13
13
  flattenForAnalyzer,
14
14
  flattenLoadedModule,
15
+ forwardReExportManifests,
16
+ parseExportEntry,
17
+ reExportSpecsFromExports,
18
+ resolveExportedKinds,
15
19
  selectModuleManifestsForAnalysis,
20
+ stampReExportedKinds,
21
+ type ParsedExportEntry,
22
+ type ReExportSpec,
16
23
  } from "./flatten-for-analyzer.js";
17
24
  export { visitManifest } from "./manifest-visitor.js";
18
25
  export type {
@@ -1,6 +1,6 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { isTaggedSentinel } from "@telorun/templating";
3
- import type { AliasResolver } from "./alias-resolver.js";
3
+ import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
5
 
6
6
  export interface ThrowsCodeMeta {
@@ -32,6 +32,13 @@ export interface ResolveCtx {
32
32
  allManifests: ResourceManifest[];
33
33
  defs: DefinitionRegistry;
34
34
  aliases: AliasResolver;
35
+ /** Per-imported-library alias resolvers, keyed by module name. A manifest that
36
+ * originated in an imported library resolves its kind aliases against its own
37
+ * module's resolver, not the consumer's — an inline handler extracted from an
38
+ * imported Http.Api inherits the lexical scope of the library that declares it. */
39
+ aliasesByModule: Map<string, AliasResolver>;
40
+ /** The consumer/root module names; resources owned by these resolve against `aliases`. */
41
+ rootModules: Set<string>;
35
42
  memo: Map<string, ThrowsUnion>;
36
43
  inProgress: Set<string>;
37
44
  }
@@ -40,11 +47,15 @@ export function createResolveCtx(
40
47
  allManifests: ResourceManifest[],
41
48
  defs: DefinitionRegistry,
42
49
  aliases: AliasResolver,
50
+ aliasesByModule: Map<string, AliasResolver> = new Map(),
51
+ rootModules: Set<string> = new Set(),
43
52
  ): ResolveCtx {
44
53
  return {
45
54
  allManifests,
46
55
  defs,
47
56
  aliases,
57
+ aliasesByModule,
58
+ rootModules,
48
59
  memo: new Map(),
49
60
  inProgress: new Set(),
50
61
  };
@@ -54,6 +65,11 @@ function emptyUnion(): ThrowsUnion {
54
65
  return { codes: new Map(), unbounded: false };
55
66
  }
56
67
 
68
+ /** The owning module's alias resolver for a manifest in this resolve context. */
69
+ function scopeResolverFor(ctx: ResolveCtx, ownModule: string | undefined): AliasResolver | undefined {
70
+ return scopeResolverForModule(ownModule, ctx.rootModules, ctx.aliasesByModule);
71
+ }
72
+
57
73
  function unionInto(target: ThrowsUnion, src: ThrowsUnion): void {
58
74
  for (const [code, meta] of src.codes) {
59
75
  if (!target.codes.has(code)) target.codes.set(code, meta);
@@ -66,9 +82,17 @@ function definitionFor(
66
82
  kind: string,
67
83
  defs: DefinitionRegistry,
68
84
  aliases: AliasResolver,
85
+ scopeResolver?: AliasResolver,
69
86
  ): ResourceDefinition | undefined {
87
+ const direct = defs.resolve(kind);
88
+ if (direct) return direct;
89
+ const scoped = scopeResolver?.resolveKind(kind);
90
+ if (scoped) {
91
+ const d = defs.resolve(scoped);
92
+ if (d) return d;
93
+ }
70
94
  const resolved = aliases.resolveKind(kind);
71
- return defs.resolve(kind) ?? (resolved ? defs.resolve(resolved) : undefined);
95
+ return resolved ? defs.resolve(resolved) : undefined;
72
96
  }
73
97
 
74
98
  function codesFromDefinition(definition: ResourceDefinition): Map<string, ThrowsCodeMeta> {
@@ -97,7 +121,9 @@ export function resolveThrowsUnion(
97
121
  if (ctx.inProgress.has(name)) return emptyUnion();
98
122
  }
99
123
 
100
- const definition = definitionFor(manifest.kind, ctx.defs, ctx.aliases);
124
+ const ownModule = (manifest.metadata as { module?: string } | undefined)?.module;
125
+ const scopeResolver = scopeResolverFor(ctx, ownModule);
126
+ const definition = definitionFor(manifest.kind, ctx.defs, ctx.aliases, scopeResolver);
101
127
  if (!definition) {
102
128
  const u: ThrowsUnion = { codes: new Map(), unbounded: true };
103
129
  if (name) ctx.memo.set(name, u);
@@ -126,7 +152,7 @@ export function resolveThrowsUnion(
126
152
  }
127
153
 
128
154
  if (throws.inherit) {
129
- const inherited = resolveInherited(manifest, definition, ctx);
155
+ const inherited = resolveInherited(manifest, definition, ctx, ownModule);
130
156
  unionInto(result, inherited);
131
157
  }
132
158
 
@@ -141,6 +167,7 @@ function resolveInherited(
141
167
  manifest: ResourceManifest,
142
168
  definition: ResourceDefinition,
143
169
  ctx: ResolveCtx,
170
+ ownerModule: string | undefined,
144
171
  ): ThrowsUnion {
145
172
  const result: ThrowsUnion = { codes: new Map(), unbounded: false };
146
173
  const props = definition.schema?.properties as Record<string, any> | undefined;
@@ -151,7 +178,7 @@ function resolveInherited(
151
178
  if (!stepCtx?.invoke) continue;
152
179
  const steps = (manifest as Record<string, any>)[fieldName];
153
180
  if (!Array.isArray(steps)) continue;
154
- unionInto(result, collectStepArrayThrows(steps, stepCtx.invoke, undefined, ctx));
181
+ unionInto(result, collectStepArrayThrows(steps, stepCtx.invoke, undefined, ctx, ownerModule));
155
182
  }
156
183
 
157
184
  return result;
@@ -162,13 +189,14 @@ function collectStepArrayThrows(
162
189
  invokeField: string,
163
190
  enclosingTryCodes: Set<string> | undefined,
164
191
  ctx: ResolveCtx,
192
+ ownerModule: string | undefined,
165
193
  ): ThrowsUnion {
166
194
  const result = emptyUnion();
167
195
  for (const step of steps) {
168
196
  if (!step || typeof step !== "object") continue;
169
197
  unionInto(
170
198
  result,
171
- collectStepThrows(step as Record<string, any>, invokeField, enclosingTryCodes, ctx),
199
+ collectStepThrows(step as Record<string, any>, invokeField, enclosingTryCodes, ctx, ownerModule),
172
200
  );
173
201
  }
174
202
  return result;
@@ -184,11 +212,14 @@ function collectStepThrows(
184
212
  invokeField: string,
185
213
  enclosingTryCodes: Set<string> | undefined,
186
214
  ctx: ResolveCtx,
215
+ ownerModule: string | undefined,
187
216
  ): ThrowsUnion {
188
217
  if (step[invokeField]) {
189
218
  // Any invoked resource can throw a non-InvokeError at runtime, which an
190
219
  // enclosing catch surfaces as PLAIN_ERROR_CODE — record that possibility.
191
- const u = cloneUnion(resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx));
220
+ const u = cloneUnion(
221
+ resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx, ownerModule),
222
+ );
192
223
  u.canThrowPlain = true;
193
224
  return u;
194
225
  }
@@ -198,7 +229,7 @@ function collectStepThrows(
198
229
  }
199
230
 
200
231
  if (Array.isArray(step.try)) {
201
- const tryUnion = collectStepArrayThrows(step.try, invokeField, enclosingTryCodes, ctx);
232
+ const tryUnion = collectStepArrayThrows(step.try, invokeField, enclosingTryCodes, ctx, ownerModule);
202
233
  let propagated: ThrowsUnion;
203
234
  if (Array.isArray(step.catch)) {
204
235
  // Catch absorbs the try block's codes; the catch's own throws propagate
@@ -209,7 +240,7 @@ function collectStepThrows(
209
240
  // `error.code === PLAIN_ERROR_CODE`, so a `throw: { code: error.code }`
210
241
  // rethrow can propagate it — seed the set the catch resolves against.
211
242
  if (tryUnion.canThrowPlain) tryCodes.add(PLAIN_ERROR_CODE);
212
- propagated = collectStepArrayThrows(step.catch, invokeField, tryCodes, ctx);
243
+ propagated = collectStepArrayThrows(step.catch, invokeField, tryCodes, ctx, ownerModule);
213
244
  // Unbounded in the try block still signals the caller to expect
214
245
  // arbitrary codes to flow through the catch (e.g. via passthrough).
215
246
  if (tryUnion.unbounded) propagated.unbounded = true;
@@ -219,7 +250,7 @@ function collectStepThrows(
219
250
  if (Array.isArray(step.finally)) {
220
251
  unionInto(
221
252
  propagated,
222
- collectStepArrayThrows(step.finally, invokeField, enclosingTryCodes, ctx),
253
+ collectStepArrayThrows(step.finally, invokeField, enclosingTryCodes, ctx, ownerModule),
223
254
  );
224
255
  }
225
256
  return propagated;
@@ -227,16 +258,16 @@ function collectStepThrows(
227
258
 
228
259
  if (Array.isArray(step.then)) {
229
260
  const result = emptyUnion();
230
- unionInto(result, collectStepArrayThrows(step.then, invokeField, enclosingTryCodes, ctx));
261
+ unionInto(result, collectStepArrayThrows(step.then, invokeField, enclosingTryCodes, ctx, ownerModule));
231
262
  if (Array.isArray(step.else)) {
232
- unionInto(result, collectStepArrayThrows(step.else, invokeField, enclosingTryCodes, ctx));
263
+ unionInto(result, collectStepArrayThrows(step.else, invokeField, enclosingTryCodes, ctx, ownerModule));
233
264
  }
234
265
  if (Array.isArray(step.elseif)) {
235
266
  for (const branch of step.elseif) {
236
267
  if (Array.isArray(branch?.then)) {
237
268
  unionInto(
238
269
  result,
239
- collectStepArrayThrows(branch.then, invokeField, enclosingTryCodes, ctx),
270
+ collectStepArrayThrows(branch.then, invokeField, enclosingTryCodes, ctx, ownerModule),
240
271
  );
241
272
  }
242
273
  }
@@ -245,18 +276,18 @@ function collectStepThrows(
245
276
  }
246
277
 
247
278
  if (Array.isArray(step.do)) {
248
- return collectStepArrayThrows(step.do, invokeField, enclosingTryCodes, ctx);
279
+ return collectStepArrayThrows(step.do, invokeField, enclosingTryCodes, ctx, ownerModule);
249
280
  }
250
281
 
251
282
  if (step.cases && typeof step.cases === "object") {
252
283
  const result = emptyUnion();
253
284
  for (const arr of Object.values(step.cases as Record<string, unknown>)) {
254
285
  if (Array.isArray(arr)) {
255
- unionInto(result, collectStepArrayThrows(arr, invokeField, enclosingTryCodes, ctx));
286
+ unionInto(result, collectStepArrayThrows(arr, invokeField, enclosingTryCodes, ctx, ownerModule));
256
287
  }
257
288
  }
258
289
  if (Array.isArray(step.default)) {
259
- unionInto(result, collectStepArrayThrows(step.default, invokeField, enclosingTryCodes, ctx));
290
+ unionInto(result, collectStepArrayThrows(step.default, invokeField, enclosingTryCodes, ctx, ownerModule));
260
291
  }
261
292
  return result;
262
293
  }
@@ -277,13 +308,18 @@ function resolveStepInvokeThrows(
277
308
  invokeField: string,
278
309
  enclosingTryCodes: Set<string> | undefined,
279
310
  ctx: ResolveCtx,
311
+ ownerModule: string | undefined,
280
312
  ): ThrowsUnion {
281
313
  const invokeRef = step[invokeField];
282
314
  if (!invokeRef || typeof invokeRef !== "object") return emptyUnion();
283
315
  const invokedKind = invokeRef.kind as string | undefined;
284
316
  if (!invokedKind) return emptyUnion();
285
317
 
286
- const definition = definitionFor(invokedKind, ctx.defs, ctx.aliases);
318
+ // The invoked kind's alias resolves in the OWNER manifest's lexical scope (the
319
+ // composer that declares the step), so a library's step referencing its own
320
+ // import resolves against that library, not the consumer.
321
+ const scopeResolver = scopeResolverFor(ctx, ownerModule);
322
+ const definition = definitionFor(invokedKind, ctx.defs, ctx.aliases, scopeResolver);
287
323
  if (!definition) return { codes: new Map(), unbounded: true };
288
324
 
289
325
  if (definition.throws?.passthrough) {
@@ -293,12 +329,14 @@ function resolveStepInvokeThrows(
293
329
  // Named manifest: resolve the full chain (covers transitive inherit).
294
330
  const invokeName = invokeRef.name as string | undefined;
295
331
  if (invokeName) {
332
+ const scopedInvokedKind = scopeResolver?.resolveKind(invokedKind);
296
333
  const target = ctx.allManifests.find(
297
334
  (m) =>
298
335
  m.metadata?.name === invokeName &&
299
336
  (m.kind === invokedKind ||
300
337
  ctx.aliases.resolveKind(m.kind) === invokedKind ||
301
- m.kind === ctx.aliases.resolveKind(invokedKind)),
338
+ m.kind === ctx.aliases.resolveKind(invokedKind) ||
339
+ (scopedInvokedKind !== undefined && m.kind === scopedInvokedKind)),
302
340
  );
303
341
  if (target) return resolveThrowsUnion(target, ctx);
304
342
  }
@@ -1,6 +1,6 @@
1
1
  import type { ASTNode, Environment } from "@marcbachmann/cel-js";
2
2
  import type { ResourceManifest } from "@telorun/sdk";
3
- import type { AliasResolver } from "./alias-resolver.js";
3
+ import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
5
  import {
6
6
  createResolveCtx,
@@ -490,16 +490,28 @@ export function validateThrowsCoverage(
490
490
  defs: DefinitionRegistry,
491
491
  aliases: AliasResolver,
492
492
  env: Environment,
493
+ aliasesByModule: Map<string, AliasResolver> = new Map(),
494
+ rootModules: Set<string> = new Set(),
493
495
  ): AnalysisDiagnostic[] {
494
496
  const diagnostics: AnalysisDiagnostic[] = [];
495
497
  diagnostics.push(...validateThrowsDeclarations(manifests));
496
498
 
497
- const resolveCtx = createResolveCtx(manifests, defs, aliases);
499
+ const resolveCtx = createResolveCtx(manifests, defs, aliases, aliasesByModule, rootModules);
500
+
501
+ // The alias resolver for a manifest's own lexical scope — an imported library's
502
+ // resolver when it owns the manifest, else undefined (fall back to root aliases).
503
+ const scopeResolverFor = (m: ResourceManifest): AliasResolver | undefined =>
504
+ scopeResolverForModule(
505
+ (m.metadata as { module?: string } | undefined)?.module,
506
+ rootModules,
507
+ aliasesByModule,
508
+ );
498
509
 
499
510
  for (const manifest of manifests) {
500
511
  if (!manifest.kind || !manifest.metadata?.name) continue;
501
512
  if (manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract") continue;
502
- const resolvedKind = aliases.resolveKind(manifest.kind);
513
+ const scopeResolver = scopeResolverFor(manifest);
514
+ const resolvedKind = scopeResolver?.resolveKind(manifest.kind) ?? aliases.resolveKind(manifest.kind);
503
515
  const definition =
504
516
  defs.resolve(manifest.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
505
517
  if (!definition?.schema) continue;
@@ -519,7 +531,7 @@ export function validateThrowsCoverage(
519
531
  ...checkCatchAllPlacement(entries, resource, "catches", filePath, arrayPath),
520
532
  );
521
533
  const handlerRef = resolveHandlerRef(siblingData[catchesFor]);
522
- const union = handlerRefUnion(handlerRef, manifests, resolveCtx);
534
+ const union = handlerRefUnion(handlerRef, manifests, resolveCtx, scopeResolver);
523
535
  diagnostics.push(
524
536
  ...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env),
525
537
  );
@@ -539,20 +551,24 @@ function handlerRefUnion(
539
551
  handlerRef: { kind: string; name?: string } | null,
540
552
  manifests: ResourceManifest[],
541
553
  ctx: ReturnType<typeof createResolveCtx>,
554
+ scopeResolver: AliasResolver | undefined,
542
555
  ): ThrowsUnion {
543
556
  if (!handlerRef) return { codes: new Map(), unbounded: false };
544
557
  if (handlerRef.name) {
545
- const resolvedKind = ctx.aliases.resolveKind(handlerRef.kind);
558
+ const resolvedKind = scopeResolver?.resolveKind(handlerRef.kind) ?? ctx.aliases.resolveKind(handlerRef.kind);
546
559
  const targetManifest = manifests.find(
547
560
  (m) =>
548
561
  m.metadata?.name === handlerRef.name &&
549
562
  (m.kind === handlerRef.kind ||
550
563
  m.kind === resolvedKind ||
564
+ scopeResolver?.resolveKind(m.kind) === handlerRef.kind ||
551
565
  ctx.aliases.resolveKind(m.kind) === handlerRef.kind),
552
566
  );
553
567
  if (targetManifest) return resolveThrowsUnion(targetManifest, ctx);
554
568
  }
555
- const resolved = ctx.aliases.resolveKind(handlerRef.kind);
569
+ // No named target — fall back to the handler kind's own declared codes,
570
+ // resolving the kind in the owner's lexical scope first, then root aliases.
571
+ const resolved = scopeResolver?.resolveKind(handlerRef.kind) ?? ctx.aliases.resolveKind(handlerRef.kind);
556
572
  const def =
557
573
  ctx.defs.resolve(handlerRef.kind) ?? (resolved ? ctx.defs.resolve(resolved) : undefined);
558
574
  if (!def?.throws) return { codes: new Map(), unbounded: false };