@telorun/analyzer 0.70.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/analysis-registry.d.ts.map +1 -1
  2. package/dist/analysis-registry.js +4 -5
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +50 -8
  5. package/dist/call-graph.d.ts.map +1 -1
  6. package/dist/call-graph.js +10 -4
  7. package/dist/cel-scope-query.d.ts +14 -0
  8. package/dist/cel-scope-query.d.ts.map +1 -1
  9. package/dist/cel-scope-query.js +36 -6
  10. package/dist/definition-registry.d.ts.map +1 -1
  11. package/dist/definition-registry.js +3 -4
  12. package/dist/flatten-for-analyzer.d.ts +2 -2
  13. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  14. package/dist/flatten-for-analyzer.js +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/manifest-visitor.d.ts.map +1 -1
  19. package/dist/manifest-visitor.js +11 -3
  20. package/dist/module-alias-scope.d.ts +65 -0
  21. package/dist/module-alias-scope.d.ts.map +1 -0
  22. package/dist/module-alias-scope.js +25 -0
  23. package/dist/{zone-module-documents.d.ts → module-documents.d.ts} +11 -7
  24. package/dist/module-documents.d.ts.map +1 -0
  25. package/dist/ref-sentinel-target.d.ts +38 -0
  26. package/dist/ref-sentinel-target.d.ts.map +1 -0
  27. package/dist/ref-sentinel-target.js +13 -0
  28. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  29. package/dist/resolve-schema-type-refs.js +2 -1
  30. package/dist/resolve-throws-union.d.ts +18 -1
  31. package/dist/resolve-throws-union.d.ts.map +1 -1
  32. package/dist/resolve-throws-union.js +93 -9
  33. package/dist/resolve-zone-requirements.d.ts +3 -3
  34. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  35. package/dist/resolve-zone-requirements.js +12 -12
  36. package/dist/telo-version.d.ts +1 -1
  37. package/dist/telo-version.js +1 -1
  38. package/dist/template-body.d.ts.map +1 -1
  39. package/dist/template-body.js +2 -4
  40. package/dist/types.d.ts +3 -3
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/validate-invocation-contract.d.ts +5 -0
  43. package/dist/validate-invocation-contract.d.ts.map +1 -1
  44. package/dist/validate-invocation-contract.js +114 -3
  45. package/dist/validate-logging.d.ts.map +1 -1
  46. package/dist/validate-logging.js +2 -2
  47. package/dist/validate-references.d.ts +16 -5
  48. package/dist/validate-references.d.ts.map +1 -1
  49. package/dist/validate-references.js +30 -12
  50. package/dist/validate-resource-inputs.d.ts +1 -26
  51. package/dist/validate-resource-inputs.d.ts.map +1 -1
  52. package/dist/validate-resource-inputs.js +12 -2
  53. package/dist/validate-schema-type-refs.d.ts.map +1 -1
  54. package/dist/validate-schema-type-refs.js +2 -1
  55. package/dist/validate-throws-coverage.d.ts +5 -1
  56. package/dist/validate-throws-coverage.d.ts.map +1 -1
  57. package/dist/validate-throws-coverage.js +6 -2
  58. package/package.json +2 -2
  59. package/src/analysis-registry.ts +4 -5
  60. package/src/analyzer.ts +61 -10
  61. package/src/call-graph.ts +9 -3
  62. package/src/cel-scope-query.ts +45 -8
  63. package/src/definition-registry.ts +3 -6
  64. package/src/flatten-for-analyzer.ts +3 -3
  65. package/src/index.ts +2 -2
  66. package/src/manifest-visitor.ts +11 -3
  67. package/src/module-alias-scope.ts +94 -0
  68. package/src/{zone-module-documents.ts → module-documents.ts} +10 -6
  69. package/src/ref-sentinel-target.ts +46 -0
  70. package/src/resolve-schema-type-refs.ts +2 -1
  71. package/src/resolve-throws-union.ts +123 -8
  72. package/src/resolve-zone-requirements.ts +14 -14
  73. package/src/telo-version.ts +1 -1
  74. package/src/template-body.ts +2 -5
  75. package/src/types.ts +3 -3
  76. package/src/validate-invocation-contract.ts +128 -2
  77. package/src/validate-logging.ts +2 -3
  78. package/src/validate-references.ts +41 -11
  79. package/src/validate-resource-inputs.ts +26 -3
  80. package/src/validate-schema-type-refs.ts +2 -1
  81. package/src/validate-throws-coverage.ts +12 -1
  82. package/dist/zone-module-documents.d.ts.map +0 -1
  83. /package/dist/{zone-module-documents.js → module-documents.js} +0 -0
package/src/call-graph.ts CHANGED
@@ -40,6 +40,7 @@
40
40
  */
41
41
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
42
42
  import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
43
+ import { refSentinelTarget } from "./ref-sentinel-target.js";
43
44
  import type { AliasResolver } from "./alias-resolver.js";
44
45
  import type { DefinitionRegistry } from "./definition-registry.js";
45
46
  import {
@@ -63,6 +64,7 @@ import {
63
64
  type RefFieldEntry,
64
65
  } from "./reference-field-map.js";
65
66
  import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
67
+ import { moduleAliasScope } from "./module-alias-scope.js";
66
68
 
67
69
  export interface ResourceGraphNode {
68
70
  type: "resource";
@@ -693,8 +695,7 @@ export function buildCallGraph(
693
695
  const definitionFor = (manifest: ResourceManifest): ResourceDefinition | undefined => {
694
696
  const direct = registry.resolve(manifest.kind as string);
695
697
  if (direct) return direct;
696
- const module = (manifest.metadata as { module?: string } | undefined)?.module;
697
- const scope = (module ? options.aliasesByModule?.get(module) : undefined) ?? options.aliases;
698
+ const scope = moduleAliasScope(manifest.metadata, options.aliases, options.aliasesByModule);
698
699
  const canonical = scope?.resolveKind(manifest.kind as string);
699
700
  return canonical ? registry.resolve(canonical) : undefined;
700
701
  };
@@ -962,7 +963,12 @@ export function buildCallGraph(
962
963
  * here: an unresolved `!ref <name>` sentinel and the `{kind, name}` object
963
964
  * `resolveRefSentinels` rewrites it into. */
964
965
  function refTargetName(value: unknown): string | undefined {
965
- if (isRefSentinel(value)) return value.source;
966
+ // An edge's target is taken VERBATIM — `resolveScopedName` and the node index
967
+ // are what resolve it, and a cross-module `Alias.name` is a target this graph
968
+ // legitimately carries unresolved. That is this pass's reduction over the
969
+ // shared parse, not a second reading of the tag.
970
+ const sentinel = refSentinelTarget(value);
971
+ if (sentinel) return sentinel.source;
966
972
  if (!value || typeof value !== "object") return undefined;
967
973
  const name = (value as Record<string, unknown>).name;
968
974
  return typeof name === "string" ? name : undefined;
@@ -18,6 +18,7 @@ import { buildCelEnvironment } from "./cel-environment.js";
18
18
  import { CelScopeResolver, type CelScope } from "./cel-scope.js";
19
19
  import { DefinitionRegistry } from "./definition-registry.js";
20
20
  import { buildKernelGlobalsIndex } from "./kernel-globals.js";
21
+ import { moduleAliasScope } from "./module-alias-scope.js";
21
22
  import { isModuleKind } from "./module-kinds.js";
22
23
  import { navigateConcretePath } from "./manifest-path.js";
23
24
  import { findManifest } from "./find-manifest.js";
@@ -41,6 +42,12 @@ export interface ContextDeclarationSite {
41
42
  kind: string;
42
43
  name: string;
43
44
  path: string;
45
+ /** The declaring module, when the manifest carries one. Part of the identity,
46
+ * not decoration: a resource name is unique inside its module and not across a
47
+ * flattened set, so `(kind, name)` alone lets a host locating this site land on
48
+ * another library's same-named document — which for go-to-declaration means
49
+ * jumping to the wrong file with nothing to indicate it. */
50
+ module?: string;
44
51
  }
45
52
 
46
53
  /** Join a concrete path segment, tolerating an empty base (the manifest root). */
@@ -210,9 +217,14 @@ export class CelScopeQuery {
210
217
  base = hit;
211
218
  }
212
219
 
213
- const metadata = origin.manifest.metadata as { name?: string } | undefined;
220
+ const metadata = origin.manifest.metadata as { name?: string; module?: string } | undefined;
214
221
  if (!origin.manifest.kind || !metadata?.name) return undefined;
215
- return { kind: origin.manifest.kind, name: metadata.name, path: base };
222
+ return {
223
+ kind: origin.manifest.kind,
224
+ name: metadata.name,
225
+ path: base,
226
+ ...(metadata.module === undefined ? {} : { module: metadata.module }),
227
+ };
216
228
  }
217
229
 
218
230
  /** The manifest and path an annotated context property is derived from, and
@@ -265,13 +277,29 @@ export class CelScopeQuery {
265
277
  if (hash <= 0) return undefined;
266
278
  const kindValue = navigateConcretePath(root, first.slice(0, hash).split("/").join("."));
267
279
  if (typeof kindValue !== "string") return undefined;
268
- const canonical = this.ctx.aliases.resolveKind(kindValue) ?? kindValue;
269
- const suffix = canonical.slice(canonical.indexOf(".") + 1);
270
- const target = this.manifests.find(
280
+ // The kind was read off THIS resource, so it is spelled in the alias scope
281
+ // of the module that declared it — not the entry's.
282
+ const scope = moduleAliasScope(resource.metadata, this.ctx.aliases, this.ctx.aliasesByModule);
283
+ const canonical = scope.resolveKind(kindValue) ?? kindValue;
284
+ const dot = canonical.indexOf(".");
285
+ const owningModule = dot === -1 ? undefined : canonical.slice(0, dot);
286
+ const suffix = canonical.slice(dot + 1);
287
+ // Matched by MODULE AND NAME once the canonical form carries a module: a
288
+ // definition name is unique inside its module and not across a flattened
289
+ // set, so two libraries each declaring an `Api` would otherwise resolve to
290
+ // whichever came first — and the editor would type a context region off the
291
+ // wrong kind's annotations, silently. Falls back to name alone only when the
292
+ // kind did not resolve to a canonical form, where there is nothing to narrow
293
+ // by and a best-effort match is still better than none.
294
+ const named = this.manifests.filter(
271
295
  (m) =>
272
296
  (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract") &&
273
297
  (m.metadata as { name?: string } | undefined)?.name === suffix,
274
- ) as Record<string, any> | undefined;
298
+ ) as Record<string, any>[];
299
+ const target =
300
+ (owningModule
301
+ ? named.find((m) => (m.metadata as { module?: string } | undefined)?.module === owningModule)
302
+ : undefined) ?? (owningModule && named.length > 1 ? undefined : named[0]);
275
303
  if (!target) return undefined;
276
304
  return { manifest: target, path: first.slice(hash + 1), propertyMap: false };
277
305
  }
@@ -313,9 +341,18 @@ export class CelScopeQuery {
313
341
  return undefined;
314
342
  }
315
343
 
344
+ /** The kind's declaration, resolved in the scope of the module that DECLARED
345
+ * this resource — the same rule the manifest visitor applies, and the reason
346
+ * it has to be the same one: everything this query offers is read off the
347
+ * definition, so resolving a forwarded library resource's `kind: Http.Api`
348
+ * through the entry's aliases alone found nothing and the editor silently
349
+ * offered no `request` / `result` at a site `telo check` accepts. A
350
+ * completion list is a claim that the name it offers will pass the checker,
351
+ * so the two must resolve a kind identically. */
316
352
  private definitionFor(resource: ResourceManifest): ResourceDefinition | undefined {
317
- const { defs, aliases } = this.ctx;
318
- const canonical = aliases.resolveKind(resource.kind);
353
+ const { defs, aliases, aliasesByModule } = this.ctx;
354
+ const scope = moduleAliasScope(resource.metadata, aliases, aliasesByModule);
355
+ const canonical = scope.resolveKind(resource.kind);
319
356
  return defs.resolve(resource.kind) ?? (canonical ? defs.resolve(canonical) : undefined);
320
357
  }
321
358
 
@@ -2,6 +2,7 @@ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { canonicalTypeSchemaId } from "@telorun/sdk";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
4
  import { KERNEL_BUILTINS } from "./builtins.js";
5
+ import { moduleAliasScope } from "./module-alias-scope.js";
5
6
  import {
6
7
  buildFieldMapAtPath,
7
8
  buildReferenceFieldMap,
@@ -372,9 +373,7 @@ export class DefinitionRegistry {
372
373
  // (e.g. `Ai.AgentStream` in a library that imports `Ai`), which the root/global
373
374
  // resolver doesn't know — using the global scope here left the base field map
374
375
  // unresolved, so Phase-5 injection saw no ref fields and skipped injection.
375
- const ownModule = (resource.metadata as { module?: string } | undefined)?.module;
376
- const moduleScope =
377
- (ownModule ? aliasesByModule.get(ownModule) : undefined) ?? aliases;
376
+ const moduleScope = moduleAliasScope(resource.metadata, aliases, aliasesByModule);
378
377
 
379
378
  const baseMap = this.getFieldMapForKind(resource.kind, moduleScope);
380
379
  if (!baseMap) return undefined;
@@ -383,9 +382,7 @@ export class DefinitionRegistry {
383
382
  const def = this.resolve(resource.kind) ?? this.resolve(resolvedKind);
384
383
  // schema-from anchors resolve in the DEFINITION's module scope (where the anchor
385
384
  // kind is declared), which may differ from the resource's own module.
386
- const ownerModule = (def?.metadata as { module?: string } | undefined)?.module;
387
- const ownerScope =
388
- (ownerModule ? aliasesByModule.get(ownerModule) : undefined) ?? aliases;
385
+ const ownerScope = moduleAliasScope(def?.metadata, aliases, aliasesByModule);
389
386
 
390
387
  const expanded: ReferenceFieldMap = new Map();
391
388
  for (const [path, entry] of baseMap) {
@@ -8,7 +8,7 @@ import {
8
8
  readResourceInputs,
9
9
  type ResourceInput,
10
10
  } from "./resource-input.js";
11
- import type { ZoneModuleDocuments } from "./zone-module-documents.js";
11
+ import type { ModuleDocuments } from "./module-documents.js";
12
12
 
13
13
  /** One parsed `exports.resources` / `exports.kinds` entry. `name` is the exported
14
14
  * instance name or kind suffix (the part after the dot, or the whole entry); `alias`
@@ -557,8 +557,8 @@ function canonicalizeInputKind(
557
557
  * projection needs the internal dispatch chain to derive an export's open
558
558
  * requirements. Only libraries that actually export instances are included:
559
559
  * a library exporting nothing has no export contract to derive. */
560
- export function collectZoneModuleDocuments(graph: LoadedGraph): ZoneModuleDocuments[] {
561
- const out: ZoneModuleDocuments[] = [];
560
+ export function collectModuleDocuments(graph: LoadedGraph): ModuleDocuments[] {
561
+ const out: ModuleDocuments[] = [];
562
562
  for (const [source, mod] of graph.modules) {
563
563
  if (source === graph.rootSource) continue;
564
564
  const libDoc = mod.owner.manifests.find((m) => m && isModuleKind(m.kind)) as
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export type {
11
11
  ParseError,
12
12
  } from "./loaded-types.js";
13
13
  export {
14
- collectZoneModuleDocuments,
14
+ collectModuleDocuments,
15
15
  flattenForAnalyzer,
16
16
  flattenLoadedModule,
17
17
  forwardReExportManifests,
@@ -246,7 +246,7 @@ export type {
246
246
  ZoneExportRequirements,
247
247
  ZoneRequirementSpec,
248
248
  } from "./resolve-zone-requirements.js";
249
- export type { ZoneModuleDocuments } from "./zone-module-documents.js";
249
+ export type { ModuleDocuments } from "./module-documents.js";
250
250
  export { validateZoneSlotDeclarations } from "./validate-zone-slots.js";
251
251
  export {
252
252
  RESOURCE_RULES_ANNOTATION,
@@ -17,6 +17,7 @@ import {
17
17
  type SchemaFromFieldEntry,
18
18
  } from "./reference-field-map.js";
19
19
  import type { ModuleScopes } from "./alias-resolver.js";
20
+ import { moduleAliasScope } from "./module-alias-scope.js";
20
21
  import { templateBodies, withTemplateSelf } from "./template-body.js";
21
22
  import { extractContextsFromSchema, pathMatchesScope } from "./validate-cel-context.js";
22
23
 
@@ -237,7 +238,14 @@ export function visitManifest(
237
238
  if (!r.metadata?.name || !r.kind) continue;
238
239
  if (skipKinds?.has(r.kind)) continue;
239
240
 
240
- const resolvedKind = aliases?.resolveKind(r.kind);
241
+ // A resource's own kind is written in the alias scope of the module that
242
+ // DECLARED it — see `moduleAliasScope`, which is the one place that rule
243
+ // lives. Resolving it through the entry's aliases alone left `definition`
244
+ // undefined for every forwarded resource, so its `x-telo-context` regions
245
+ // contributed nothing and `request` / `result` read as unknown identifiers
246
+ // inside the library's own routes, on a file the consumer cannot fix.
247
+ const moduleScope = moduleAliasScope(r.metadata, aliases, aliasesByModule);
248
+ const resolvedKind = moduleScope?.resolveKind(r.kind);
241
249
  const definition =
242
250
  registry.resolve(r.kind) ??
243
251
  (resolvedKind ? registry.resolve(resolvedKind) : undefined);
@@ -249,8 +257,8 @@ export function visitManifest(
249
257
  const emittedRefPaths = wantsNested ? new Set<string>() : null;
250
258
 
251
259
  if (wantsRefs || wantsScope || wantsSchemaFrom) {
252
- const baseMap = aliases
253
- ? registry.getFieldMapForKind(r.kind, aliases)
260
+ const baseMap = moduleScope
261
+ ? registry.getFieldMapForKind(r.kind, moduleScope)
254
262
  : registry.getFieldMap(r.kind);
255
263
 
256
264
  // Expanded map drives ref/scope sites when requested; schema-from sites
@@ -0,0 +1,94 @@
1
+ import { AliasResolver, scopeResolverForModule, type ModuleScopes } from "./alias-resolver.js";
2
+
3
+ /**
4
+ * WHICH ALIAS TABLE A MANIFEST'S OWN NAMES ARE WRITTEN IN.
5
+ *
6
+ * An application analysis is flattened, so an imported library's definitions and
7
+ * exported instances are checked in the CONSUMER's pass. But every alias-qualified
8
+ * name on such a manifest — its `kind:`, its `extends:`, an `x-telo-schema-from`
9
+ * anchor — was written against the alias map of the module that DECLARED it. A
10
+ * library writes `kind: Http.Api` through an import the consumer has no reason to
11
+ * have, so resolving it through the entry's table finds nothing, and everything
12
+ * derived from the definition silently goes missing: the field map (Phase-5
13
+ * injection blind to a forwarded resource's ref slots) and the `x-telo-context`
14
+ * regions (`request` / `result` reported as unknown identifiers on a file the
15
+ * consumer cannot edit).
16
+ *
17
+ * **There are TWO fallbacks, not one, and this file is where they are told
18
+ * apart** — that is the whole reason they live together. They agree on every
19
+ * module that has a table, and differ on a non-root module that has none:
20
+ *
21
+ * - {@link moduleAliasScope} falls back to the GLOBAL table. For a question
22
+ * asked about an arbitrary manifest, where a missing entry means "this is the
23
+ * entry's own", which is what the global table answers correctly.
24
+ * - {@link declaringModuleScope} falls back to an EMPTY table for a non-root
25
+ * module. For canonicalizing what a library DECLARED, where resolving its
26
+ * `Http.Api` through the consumer's imports would silently bind a library's
27
+ * kind to whatever the app happens to import under that alias — a wrong
28
+ * answer, which is worse than none.
29
+ *
30
+ * Both rest on one invariant neither states inline: **a root module is never a
31
+ * key in `aliasesByModule`** — a root's imports are registered into the global
32
+ * table instead (see the `rootModules` guards in `analyze()`) — so for a
33
+ * consumer-owned manifest the two rules coincide and neither fallback is a
34
+ * degradation.
35
+ *
36
+ * Every READ of the rule goes through one of these two. What legitimately does
37
+ * not, and why, so the next reader does not have to re-derive it: the three
38
+ * sites in `analyze()` that POPULATE `aliasesByModule` (a write, not a lookup),
39
+ * and `resolve-ref-sentinels`, whose module is known non-root by construction and
40
+ * whose fallback is the raw kind rather than another table — a different rule
41
+ * that happens to read the same map.
42
+ */
43
+ export function moduleAliasScope<A extends KindResolver, M extends KindResolver>(
44
+ metadata: { module?: unknown } | undefined,
45
+ aliases: A,
46
+ aliasesByModule: ReadonlyMap<string, M> | undefined,
47
+ ): A | M;
48
+ export function moduleAliasScope<A extends KindResolver, M extends KindResolver>(
49
+ metadata: { module?: unknown } | undefined,
50
+ aliases: A | undefined,
51
+ aliasesByModule: ReadonlyMap<string, M> | undefined,
52
+ ): A | M | undefined;
53
+ export function moduleAliasScope<A extends KindResolver, M extends KindResolver>(
54
+ metadata: { module?: unknown } | undefined,
55
+ aliases: A | undefined,
56
+ aliasesByModule: ReadonlyMap<string, M> | undefined,
57
+ ): A | M | undefined {
58
+ const declaringModule = metadata?.module;
59
+ if (typeof declaringModule !== "string") return aliases;
60
+ return aliasesByModule?.get(declaringModule) ?? aliases;
61
+ }
62
+
63
+ /** All this rule needs of a resolver, and deliberately all it asks for:
64
+ * `ModuleScopes` already types its map this way so a caller can hand over a
65
+ * lighter table, and requiring the full `AliasResolver` here would have made
66
+ * the one site that does (a template body) reach for a cast. */
67
+ interface KindResolver {
68
+ resolveKind(kind: string): string | undefined;
69
+ }
70
+
71
+ /** An empty table, shared: a non-root module with no aliases of its own resolves
72
+ * nothing rather than resolving through the consumer's. One instance because it
73
+ * is immutable in use and allocating one per call put a resolver on a per-schema
74
+ * loop. */
75
+ const NO_ALIASES = new AliasResolver();
76
+
77
+ /**
78
+ * The scope a DECLARING module's own alias-form names are canonicalized in — the
79
+ * root-aware half of the rule above, and the one that must never fall through to
80
+ * the consumer's table. See this file's header for why the two differ.
81
+ */
82
+ export function declaringModuleScope(
83
+ ownModule: string | undefined,
84
+ aliases: AliasResolver,
85
+ scopes: { aliasesByModule: Map<string, AliasResolver>; rootModules: Set<string> },
86
+ ): AliasResolver {
87
+ const own = scopeResolverForModule(ownModule, scopes.rootModules, scopes.aliasesByModule);
88
+ if (own) return own;
89
+ // Root (or unknown-and-therefore-treated-as-root): the global table IS its
90
+ // import map. A non-root module with no table of its own resolves nothing.
91
+ return ownModule && !scopes.rootModules.has(ownModule) ? NO_ALIASES : aliases;
92
+ }
93
+
94
+ export type { ModuleScopes };
@@ -1,19 +1,23 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
 
3
3
  /**
4
- * One imported library's FULL document set, for the zone stage's per-library
5
- * export derivation what the flattened analysis view no longer holds, since
6
- * it forwards only each library's export surface and never its internal
7
- * dispatch chain.
4
+ * One imported library's FULL document set what the flattened analysis view
5
+ * no longer holds, since it forwards only each library's export surface and
6
+ * never its internal dispatch chain.
7
+ *
8
+ * Named for what it CARRIES rather than for who asked first: the zone stage's
9
+ * per-library export derivation was the original consumer, and the throws walk
10
+ * is the second — a third should not have to import "zone" to ask about
11
+ * something else again.
8
12
  *
9
13
  * Plain data in a module of its own, deliberately. It is produced by the
10
- * loading side (`collectZoneModuleDocuments`), named in `AnalysisOptions`, and
14
+ * loading side (`collectModuleDocuments`), named in `AnalysisOptions`, and
11
15
  * consumed by the projection; putting it in any of the three would make the
12
16
  * other two import that one, and `types.ts` ↔ the projection is a genuine
13
17
  * cycle. A leaf module with no imports of its own breaks it without an inline
14
18
  * `import(...)` type expression standing in for the dependency nobody wanted.
15
19
  */
16
- export interface ZoneModuleDocuments {
20
+ export interface ModuleDocuments {
17
21
  /** The library's module name (its `Telo.Library` doc's `metadata.name`). */
18
22
  module: string;
19
23
  /** Stable source identity of the library's owner file — the cache key. */
@@ -0,0 +1,46 @@
1
+ import { isRefSentinel } from "@telorun/templating";
2
+
3
+ /**
4
+ * What a `!ref` names, parsed once.
5
+ *
6
+ * THE single reader of the tag's grammar, on the `ref-slot.ts` / `zone-slot.ts`
7
+ * precedent. Three passes had grown their own parse of the same scalar and they
8
+ * disagreed: one took the source verbatim, one split on the first dot and
9
+ * dropped every alias but `Self`, one split on the LAST dot and dropped the
10
+ * alias entirely — so the same `!ref Alias.name` named three different things
11
+ * depending on which pass was asking, and the loosest of the three resolved a
12
+ * bare name against whatever manifest happened to share it.
13
+ *
14
+ * The grammar itself is one line and is not in dispute: `!ref <name>` or
15
+ * `!ref <Alias>.<name>`, split on the FIRST dot, because that is what
16
+ * `resolveRefSentinels` does and a name may not contain one
17
+ * (`INVALID_NAME` rejects it at every declaration site).
18
+ *
19
+ * What this deliberately does NOT do is decide what to look up. A reduction is
20
+ * the caller's, and the three genuinely differ: an edge in the call graph points
21
+ * at a cross-module target and tolerates not resolving it; a zone correlation
22
+ * refuses to bind anything it cannot resolve exactly; a throws walk resolves in
23
+ * the DECLARING library first. Each states its own, over one parse.
24
+ *
25
+ * Browser-safe.
26
+ */
27
+ export interface RefSentinelTarget {
28
+ /** The scalar as written, for a caller that wants the author's spelling. */
29
+ source: string;
30
+ /** The prefix before the first dot, `Self` included and not normalized away —
31
+ * a caller that treats `Self` as "no alias" says so itself. */
32
+ alias?: string;
33
+ /** The segment after the first dot, or the whole scalar when there is none. */
34
+ name: string;
35
+ }
36
+
37
+ /** Parse a `!ref` sentinel. Returns `undefined` for anything that is not one —
38
+ * including the `{kind, name}` object `resolveRefSentinels` rewrites it into,
39
+ * which is a different shape with a different reader. */
40
+ export function refSentinelTarget(value: unknown): RefSentinelTarget | undefined {
41
+ if (!isRefSentinel(value)) return undefined;
42
+ const source = value.source;
43
+ const dot = source.indexOf(".");
44
+ if (dot <= 0) return { source, name: source };
45
+ return { source, alias: source.slice(0, dot), name: source.slice(dot + 1) };
46
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { canonicalTypeSchemaId, parseTeloTypeRef } from "@telorun/sdk";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
+ import { moduleAliasScope } from "./module-alias-scope.js";
4
5
 
5
6
  /** Schema-bearing fields on a Telo.Definition / Telo.Type resource. */
6
7
  const SCHEMA_FIELDS = ["schema", "inputType", "outputType"];
@@ -96,7 +97,7 @@ export function resolveSchemaTypeRefs(
96
97
 
97
98
  for (const r of resources) {
98
99
  const ownModule = (r.metadata as { module?: string } | undefined)?.module;
99
- const resolver = (ownModule ? aliasesByModule?.get(ownModule) : undefined) ?? aliases;
100
+ const resolver = moduleAliasScope(r.metadata, aliases, aliasesByModule);
100
101
  const resolveAuthority = (authority: string): string | undefined =>
101
102
  authority === "Self" ? ownModule : resolver?.moduleForAlias(authority);
102
103
  for (const field of SCHEMA_FIELDS) {
@@ -1,6 +1,8 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { isTaggedSentinel } from "@telorun/templating";
3
3
  import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js";
4
+ import { resolveScopedName } from "./call-graph.js";
5
+ import { refSentinelTarget, type RefSentinelTarget } from "./ref-sentinel-target.js";
4
6
  import type { DefinitionRegistry } from "./definition-registry.js";
5
7
  import { readStepSlot } from "./step-slot.js";
6
8
 
@@ -40,6 +42,23 @@ export interface ResolveCtx {
40
42
  aliasesByModule: Map<string, AliasResolver>;
41
43
  /** The consumer/root module names; resources owned by these resolve against `aliases`. */
42
44
  rootModules: Set<string>;
45
+ /**
46
+ * Every imported library's FULL manifest list, keyed by module name.
47
+ *
48
+ * A consumer's flat set holds a library's EXPORTED instances and nothing else,
49
+ * so the siblings an exported entry point invokes are not in it. Without this
50
+ * the walk stops at the first such hop, and the difference is not academic: a
51
+ * library whose entry point raises its own code through an internal guard
52
+ * presented an empty union to its consumer, which then had its `catches:`
53
+ * rejected for the very code the entry point documents.
54
+ *
55
+ * Consulted only as a FALLBACK, after the flat set — the flat set is what the
56
+ * consumer's own resources resolve against, and a library-internal name must
57
+ * never shadow one of them.
58
+ */
59
+ moduleManifests: Map<string, ResourceManifest[]>;
60
+ /** Keyed `<module>\0<name>`: resource names are module-scoped, so two
61
+ * libraries each declaring a `query` are two different unions. */
43
62
  memo: Map<string, ThrowsUnion>;
44
63
  inProgress: Set<string>;
45
64
  }
@@ -50,6 +69,7 @@ export function createResolveCtx(
50
69
  aliases: AliasResolver,
51
70
  aliasesByModule: Map<string, AliasResolver> = new Map(),
52
71
  rootModules: Set<string> = new Set(),
72
+ moduleManifests: Map<string, ResourceManifest[]> = new Map(),
53
73
  ): ResolveCtx {
54
74
  return {
55
75
  allManifests,
@@ -57,11 +77,90 @@ export function createResolveCtx(
57
77
  aliases,
58
78
  aliasesByModule,
59
79
  rootModules,
80
+ moduleManifests,
60
81
  memo: new Map(),
61
82
  inProgress: new Set(),
62
83
  };
63
84
  }
64
85
 
86
+ /**
87
+ * A dispatch target named by a resolved `{kind, name}` ref.
88
+ *
89
+ * The flat set is the scope such a ref was resolved in, so it is asked first;
90
+ * the owning library's own documents are the fallback for a name the flattened
91
+ * view dropped. `kindMatches` applies to BOTH — one function, one rule, or the
92
+ * next caller inherits whichever half it happened to hit.
93
+ */
94
+ function findTarget(
95
+ ctx: ResolveCtx,
96
+ name: string,
97
+ ownerModule: string | undefined,
98
+ kindMatches: (m: ResourceManifest) => boolean,
99
+ ): ResourceManifest | undefined {
100
+ const flat = ctx.allManifests.find((m) => m.metadata?.name === name && kindMatches(m));
101
+ if (flat) return flat;
102
+ if (!ownerModule) return undefined;
103
+ return ctx.moduleManifests
104
+ .get(ownerModule)
105
+ ?.find((m) => m.metadata?.name === name && kindMatches(m));
106
+ }
107
+
108
+ /**
109
+ * The target of a `!ref` that still carries its sentinel — a library-internal
110
+ * reference inside a manifest forwarded into a consumer's flat set, where Phase
111
+ * 2.5 had nothing to resolve it against.
112
+ *
113
+ * **The declaring library is asked FIRST**, and that ordering is the whole rule:
114
+ * a bare name in a library manifest is unambiguously library-internal, so
115
+ * searching the consumer's flat set first let any consumer resource that
116
+ * happened to share the name supply another library's throw union — the same
117
+ * false `{∅}` this branch exists to remove, arrived at from the other side.
118
+ *
119
+ * An ALIAS-qualified source is the cross-module case and is resolved through the
120
+ * declaring module's own alias table, never by dropping the alias and matching
121
+ * the bare name anywhere.
122
+ *
123
+ * Ambiguity resolves to NOTHING rather than to a guess (`resolveScopedName`'s
124
+ * rule), and the caller reads that as unbounded — the safe direction here, since
125
+ * a union that cannot be enumerated must not read as empty.
126
+ */
127
+ function findSentinelTarget(
128
+ ctx: ResolveCtx,
129
+ target: RefSentinelTarget,
130
+ ownerModule: string | undefined,
131
+ ): ResourceManifest | undefined {
132
+ const named = (pool: readonly ResourceManifest[] | undefined): ResourceManifest[] =>
133
+ (pool ?? []).filter((m) => m.metadata?.name === target.name);
134
+
135
+ if (target.alias !== undefined && target.alias !== "Self") {
136
+ // A forwarded export: its module is whatever the DECLARING module aliases
137
+ // that prefix to, and it keeps its export name in the flat set.
138
+ // A root-owned manifest resolves aliases against the global table, which is
139
+ // what `scopeResolverForModule` returns undefined for — so fall back to it
140
+ // rather than reading a root's own alias as unresolvable.
141
+ const resolver = scopeResolverFor(ctx, ownerModule) ?? ctx.aliases;
142
+ const module = resolver.moduleForAlias(target.alias);
143
+ if (!module) return undefined;
144
+ return named(ctx.allManifests).find((m) => declaringModuleOf(m) === module);
145
+ }
146
+
147
+ const own = named(ctx.moduleManifests.get(ownerModule ?? ""));
148
+ if (own.length === 1) return own[0];
149
+ if (own.length > 1) return undefined;
150
+ return resolveScopedName(named(ctx.allManifests), declaringModuleOf, ownerModule);
151
+ }
152
+
153
+ const declaringModuleOf = (m: ResourceManifest): string | undefined =>
154
+ (m.metadata as { module?: string } | undefined)?.module;
155
+
156
+ /** Memo key. Module-scoped, because resource names are. */
157
+ function memoKey(manifest: ResourceManifest): string | undefined {
158
+ const name = manifest.metadata?.name as string | undefined;
159
+ if (!name) return undefined;
160
+ const mod = (manifest.metadata as { module?: string } | undefined)?.module ?? "";
161
+ return `${mod}\0${name}`;
162
+ }
163
+
65
164
  function emptyUnion(): ThrowsUnion {
66
165
  return { codes: new Map(), unbounded: false };
67
166
  }
@@ -114,7 +213,7 @@ export function resolveThrowsUnion(
114
213
  manifest: ResourceManifest,
115
214
  ctx: ResolveCtx,
116
215
  ): ThrowsUnion {
117
- const name = manifest.metadata?.name as string | undefined;
216
+ const name = memoKey(manifest);
118
217
 
119
218
  if (name) {
120
219
  const cached = ctx.memo.get(name);
@@ -314,7 +413,21 @@ function resolveStepInvokeThrows(
314
413
  const invokeRef = step[invokeField];
315
414
  if (!invokeRef || typeof invokeRef !== "object") return emptyUnion();
316
415
  const invokedKind = invokeRef.kind as string | undefined;
317
- if (!invokedKind) return emptyUnion();
416
+ // A reference that still carries its parse-time sentinel — a library-internal
417
+ // `!ref` inside a manifest forwarded into a consumer's flat set, where Phase
418
+ // 2.5 had nothing to resolve it against. The target is in the declaring
419
+ // library's own documents, so it is looked up there; only a name that is not
420
+ // there either is UNKNOWN, which is unbounded rather than empty. Reading it as
421
+ // empty is what made a library's exported entry point present a `{∅}` union to
422
+ // its consumer and get the consumer's `catches:` rejected for the code the
423
+ // entry point documents.
424
+ if (!invokedKind) {
425
+ const sentinel = refSentinelTarget(invokeRef);
426
+ if (!sentinel) return emptyUnion();
427
+ const target = findSentinelTarget(ctx, sentinel, ownerModule);
428
+ if (target) return resolveThrowsUnion(target, ctx);
429
+ return { codes: new Map(), unbounded: true };
430
+ }
318
431
 
319
432
  // The invoked kind's alias resolves in the OWNER manifest's lexical scope (the
320
433
  // composer that declares the step), so a library's step referencing its own
@@ -331,13 +444,15 @@ function resolveStepInvokeThrows(
331
444
  const invokeName = invokeRef.name as string | undefined;
332
445
  if (invokeName) {
333
446
  const scopedInvokedKind = scopeResolver?.resolveKind(invokedKind);
334
- const target = ctx.allManifests.find(
447
+ const target = findTarget(
448
+ ctx,
449
+ invokeName,
450
+ ownerModule,
335
451
  (m) =>
336
- m.metadata?.name === invokeName &&
337
- (m.kind === invokedKind ||
338
- ctx.aliases.resolveKind(m.kind) === invokedKind ||
339
- m.kind === ctx.aliases.resolveKind(invokedKind) ||
340
- (scopedInvokedKind !== undefined && m.kind === scopedInvokedKind)),
452
+ m.kind === invokedKind ||
453
+ ctx.aliases.resolveKind(m.kind) === invokedKind ||
454
+ m.kind === ctx.aliases.resolveKind(invokedKind) ||
455
+ (scopedInvokedKind !== undefined && m.kind === scopedInvokedKind),
341
456
  );
342
457
  if (target) return resolveThrowsUnion(target, ctx);
343
458
  }