@telorun/analyzer 0.69.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 (98) 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/canonical-json.d.ts +18 -0
  8. package/dist/canonical-json.d.ts.map +1 -0
  9. package/dist/canonical-json.js +26 -0
  10. package/dist/cel-access-chains.d.ts +14 -0
  11. package/dist/cel-access-chains.d.ts.map +1 -0
  12. package/dist/cel-access-chains.js +45 -0
  13. package/dist/cel-scope-query.d.ts +14 -0
  14. package/dist/cel-scope-query.d.ts.map +1 -1
  15. package/dist/cel-scope-query.js +36 -6
  16. package/dist/definition-registry.d.ts.map +1 -1
  17. package/dist/definition-registry.js +3 -4
  18. package/dist/flatten-for-analyzer.d.ts +2 -2
  19. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  20. package/dist/flatten-for-analyzer.js +1 -1
  21. package/dist/index.d.ts +5 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -1
  24. package/dist/manifest-diff.d.ts +111 -0
  25. package/dist/manifest-diff.d.ts.map +1 -0
  26. package/dist/manifest-diff.js +130 -0
  27. package/dist/manifest-visitor.d.ts.map +1 -1
  28. package/dist/manifest-visitor.js +11 -3
  29. package/dist/module-alias-scope.d.ts +65 -0
  30. package/dist/module-alias-scope.d.ts.map +1 -0
  31. package/dist/module-alias-scope.js +25 -0
  32. package/dist/{zone-module-documents.d.ts → module-documents.d.ts} +11 -7
  33. package/dist/module-documents.d.ts.map +1 -0
  34. package/dist/module-graph.d.ts.map +1 -1
  35. package/dist/module-graph.js +3 -25
  36. package/dist/ref-sentinel-target.d.ts +38 -0
  37. package/dist/ref-sentinel-target.d.ts.map +1 -0
  38. package/dist/ref-sentinel-target.js +13 -0
  39. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  40. package/dist/resolve-schema-type-refs.js +2 -1
  41. package/dist/resolve-throws-union.d.ts +18 -1
  42. package/dist/resolve-throws-union.d.ts.map +1 -1
  43. package/dist/resolve-throws-union.js +93 -9
  44. package/dist/resolve-zone-requirements.d.ts +3 -3
  45. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  46. package/dist/resolve-zone-requirements.js +12 -12
  47. package/dist/telo-version.d.ts +1 -1
  48. package/dist/telo-version.js +1 -1
  49. package/dist/template-body.d.ts.map +1 -1
  50. package/dist/template-body.js +2 -4
  51. package/dist/types.d.ts +3 -3
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/validate-invocation-contract.d.ts +5 -0
  54. package/dist/validate-invocation-contract.d.ts.map +1 -1
  55. package/dist/validate-invocation-contract.js +114 -3
  56. package/dist/validate-logging.d.ts.map +1 -1
  57. package/dist/validate-logging.js +2 -2
  58. package/dist/validate-references.d.ts +16 -5
  59. package/dist/validate-references.d.ts.map +1 -1
  60. package/dist/validate-references.js +30 -12
  61. package/dist/validate-resource-inputs.d.ts +1 -26
  62. package/dist/validate-resource-inputs.d.ts.map +1 -1
  63. package/dist/validate-resource-inputs.js +12 -2
  64. package/dist/validate-schema-type-refs.d.ts.map +1 -1
  65. package/dist/validate-schema-type-refs.js +2 -1
  66. package/dist/validate-throws-coverage.d.ts +5 -1
  67. package/dist/validate-throws-coverage.d.ts.map +1 -1
  68. package/dist/validate-throws-coverage.js +6 -2
  69. package/package.json +2 -2
  70. package/src/analysis-registry.ts +4 -5
  71. package/src/analyzer.ts +61 -10
  72. package/src/call-graph.ts +9 -3
  73. package/src/canonical-json.ts +24 -0
  74. package/src/cel-access-chains.ts +47 -0
  75. package/src/cel-scope-query.ts +45 -8
  76. package/src/definition-registry.ts +3 -6
  77. package/src/flatten-for-analyzer.ts +3 -3
  78. package/src/index.ts +10 -2
  79. package/src/manifest-diff.ts +219 -0
  80. package/src/manifest-visitor.ts +11 -3
  81. package/src/module-alias-scope.ts +94 -0
  82. package/src/{zone-module-documents.ts → module-documents.ts} +10 -6
  83. package/src/module-graph.ts +2 -24
  84. package/src/ref-sentinel-target.ts +46 -0
  85. package/src/resolve-schema-type-refs.ts +2 -1
  86. package/src/resolve-throws-union.ts +123 -8
  87. package/src/resolve-zone-requirements.ts +14 -14
  88. package/src/telo-version.ts +1 -1
  89. package/src/template-body.ts +2 -5
  90. package/src/types.ts +3 -3
  91. package/src/validate-invocation-contract.ts +128 -2
  92. package/src/validate-logging.ts +2 -3
  93. package/src/validate-references.ts +41 -11
  94. package/src/validate-resource-inputs.ts +26 -3
  95. package/src/validate-schema-type-refs.ts +2 -1
  96. package/src/validate-throws-coverage.ts +12 -1
  97. package/dist/zone-module-documents.d.ts.map +0 -1
  98. /package/dist/{zone-module-documents.js → module-documents.js} +0 -0
@@ -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,
@@ -127,6 +127,14 @@ export type {
127
127
  ResourceGraphNode,
128
128
  StepGraphNode,
129
129
  } from "./call-graph.js";
130
+ export { celResourceReads } from "./cel-access-chains.js";
131
+ export { declarationSignature, diffManifests } from "./manifest-diff.js";
132
+ export type {
133
+ DiffEntry,
134
+ ManifestDiff,
135
+ ManifestDiffOptions,
136
+ ResourceChangeKind,
137
+ } from "./manifest-diff.js";
130
138
  export {
131
139
  AMBIENT_CAPABILITIES,
132
140
  buildModuleGraph,
@@ -238,7 +246,7 @@ export type {
238
246
  ZoneExportRequirements,
239
247
  ZoneRequirementSpec,
240
248
  } from "./resolve-zone-requirements.js";
241
- export type { ZoneModuleDocuments } from "./zone-module-documents.js";
249
+ export type { ModuleDocuments } from "./module-documents.js";
242
250
  export { validateZoneSlotDeclarations } from "./validate-zone-slots.js";
243
251
  export {
244
252
  RESOURCE_RULES_ANNOTATION,
@@ -0,0 +1,219 @@
1
+ /**
2
+ * What changed between two loads of one manifest set.
3
+ *
4
+ * The question a reconciling host asks before it touches anything: which
5
+ * resources survive an edit untouched, which have to be rebuilt, which are new,
6
+ * and which are gone. It is here rather than in the kernel because it is pure
7
+ * data in and data out and the editor wants the same answer — to show which
8
+ * resources a save would restart, in a browser, where no kernel runs.
9
+ *
10
+ * **This is the DECLARATION half of the question and only that half.** A field
11
+ * written `!cel "variables.port"` has the same declaration whatever the
12
+ * environment says, so a host that resolves configuration outside the manifest
13
+ * has to supply what moved there (`modulesWithChangedConfig`). Keeping the two
14
+ * apart is what lets this run in a browser at all.
15
+ */
16
+ import type { ResourceManifest } from "@telorun/sdk";
17
+ import { isCompiledValue } from "@telorun/sdk";
18
+ import { nodeIdFor } from "./call-graph.js";
19
+ import { canonicalJson } from "./canonical-json.js";
20
+ import { DERIVED_METADATA_FIELDS } from "./module-metadata-scope.js";
21
+
22
+ /** How one resource differs between the two sets. `unchanged` is reported as a
23
+ * bare id rather than an entry, since there is nothing to say about it. */
24
+ export type ResourceChangeKind = "added" | "removed" | "changed";
25
+
26
+ export interface DiffEntry {
27
+ /** {@link nodeIdFor} — module-scoped, so two libraries each declaring a
28
+ * resource named `store` are two entries rather than one. */
29
+ readonly id: string;
30
+ readonly change: ResourceChangeKind;
31
+ /** The declaration as it stood. Absent for an addition. */
32
+ readonly previous?: ResourceManifest;
33
+ /** The declaration as it now stands. Absent for a removal. */
34
+ readonly next?: ResourceManifest;
35
+ }
36
+
37
+ export interface ManifestDiff {
38
+ /** Additions, removals and changes. Order follows the next set, then the
39
+ * previous set for removals, so a caller rendering this reads it in
40
+ * declaration order rather than in hash order. */
41
+ readonly entries: readonly DiffEntry[];
42
+ /** Ids present in both sets with an identical declaration. */
43
+ readonly unchanged: readonly string[];
44
+ /** Ids whose current instance is no longer valid: every removal and every
45
+ * change. What a reconciling host unwinds, once it has closed this set under
46
+ * the resources that HOLD them — which is the call graph's answer, not this
47
+ * one's. */
48
+ readonly stale: readonly string[];
49
+ /** Declarations that have to be created: every addition and every change.
50
+ * Named for the kernel's own `pendingResources`, which is where they go. */
51
+ readonly pending: readonly ResourceManifest[];
52
+ }
53
+
54
+ export interface ManifestDiffOptions {
55
+ /**
56
+ * Modules whose resolved configuration moved between the two loads, by
57
+ * `metadata.module`.
58
+ *
59
+ * Every resource of such a module is reported `changed` even where its
60
+ * declaration is identical, because a declaration is not the whole of what a
61
+ * resource is built from: `!cel "variables.port"` reads the same and means
62
+ * something else once the environment behind it moves. Only the host that
63
+ * resolved that environment can know, so it is an input rather than something
64
+ * derived here.
65
+ */
66
+ readonly modulesWithChangedConfig?: ReadonlySet<string>;
67
+
68
+ /**
69
+ * Signatures of the previous set, by node id, taken while those manifests
70
+ * were still declarations.
71
+ *
72
+ * A host that INSTALLS manifests does not keep declarations: the kernel
73
+ * registers the very objects it loaded, and resolving references writes live
74
+ * instances into them — a boot target's `!ref`, or a slot nested past the
75
+ * create-time shallow copy. Signing such an object afterwards renders those
76
+ * slots opaque and reports a change that never happened, which on a module
77
+ * document means escalating every reconciliation there is.
78
+ *
79
+ * An id absent from the map is signed from its manifest, so a caller that
80
+ * really is holding declarations passes nothing.
81
+ */
82
+ readonly previousSignatures?: ReadonlyMap<string, string>;
83
+ }
84
+
85
+ /** A value that is not part of the declaration and must not be walked into:
86
+ * a live instance a host injected over a reference slot, a function, a class
87
+ * instance of any kind. Rendering one as a constant keeps the walk finite and
88
+ * acyclic; comparing two sets across the injection boundary is not supported
89
+ * and is what {@link declarationSignature} documents against. */
90
+ const OPAQUE = '"\\u0000opaque"';
91
+
92
+ const isPlainObject = (value: object): boolean => {
93
+ const proto = Object.getPrototypeOf(value);
94
+ return proto === Object.prototype || proto === null;
95
+ };
96
+
97
+ /**
98
+ * A resource's declaration, rendered so that two equal declarations render
99
+ * identically.
100
+ *
101
+ * Three things are normalized away, and each would otherwise report a change
102
+ * that is not one:
103
+ *
104
+ * - **Loader stamps** (`DERIVED_METADATA_FIELDS`). `metadata.sourceLine` is the
105
+ * load-bearing one: inserting a line anywhere in a file shifts it for every
106
+ * resource below, so leaving it in would mark a whole file changed on any
107
+ * edit and defeat the mechanism entirely.
108
+ * - **A compiled expression** renders as its source text. What the author wrote
109
+ * is the declaration; what it evaluates to depends on configuration, which
110
+ * `modulesWithChangedConfig` carries instead.
111
+ * - **Anything that is not plain data** renders as a constant. A host that has
112
+ * injected live instances over its reference slots has manifests that are no
113
+ * longer declarations, and walking one reaches a controller's object graph,
114
+ * which is cyclic. Both sides must be declarations as loaded.
115
+ *
116
+ * The result is compared as a string rather than hashed: there is no collision
117
+ * to reason about, and a missed change here is a resource that silently keeps
118
+ * running against a declaration it no longer matches.
119
+ */
120
+ export function declarationSignature(manifest: ResourceManifest): string {
121
+ const seen = new WeakSet<object>();
122
+
123
+ const render = (value: unknown): unknown => {
124
+ if (isCompiledValue(value)) {
125
+ const source = (value as { source?: unknown }).source;
126
+ return { "\u0000cel": typeof source === "string" ? source : null };
127
+ }
128
+ if (!value || typeof value !== "object") return value;
129
+ if (seen.has(value)) return "\u0000cycle";
130
+ if (Array.isArray(value)) {
131
+ seen.add(value);
132
+ return value.map(render);
133
+ }
134
+ if (!isPlainObject(value)) return OPAQUE;
135
+ seen.add(value);
136
+ const out: Record<string, unknown> = {};
137
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
138
+ out[key] = render(entry);
139
+ }
140
+ return out;
141
+ };
142
+
143
+ const authoredMetadata: Record<string, unknown> = {};
144
+ const metadata = manifest.metadata as Record<string, unknown> | undefined;
145
+ for (const [key, value] of Object.entries(metadata ?? {})) {
146
+ if (DERIVED_METADATA_FIELDS.has(key)) continue;
147
+ authoredMetadata[key] = render(value);
148
+ }
149
+
150
+ const body: Record<string, unknown> = {};
151
+ for (const [key, value] of Object.entries(manifest as Record<string, unknown>)) {
152
+ if (key === "metadata") continue;
153
+ body[key] = render(value);
154
+ }
155
+
156
+ return canonicalJson({ ...body, metadata: authoredMetadata });
157
+ }
158
+
159
+ /**
160
+ * Classify every resource of two loads against each other.
161
+ *
162
+ * **Identity is `nodeIdFor`**, which is module-scoped, so a name is compared
163
+ * only against the same name in the same module. One consequence is worth
164
+ * stating because it is visible: an INLINE resource's name is synthesized from
165
+ * its position (`api_routes_0_handler`), so inserting a route above one renames
166
+ * every inline below it and this reports those as a removal plus an addition.
167
+ * That costs a restart it did not have to cost; it never misses a change, which
168
+ * is the direction that matters.
169
+ */
170
+ export function diffManifests(
171
+ previous: readonly ResourceManifest[],
172
+ next: readonly ResourceManifest[],
173
+ options: ManifestDiffOptions = {},
174
+ ): ManifestDiff {
175
+ const before = new Map<string, ResourceManifest>();
176
+ for (const manifest of previous) before.set(nodeIdFor(manifest), manifest);
177
+
178
+ const changedConfig = options.modulesWithChangedConfig;
179
+ const configMoved = (manifest: ResourceManifest): boolean => {
180
+ if (!changedConfig || changedConfig.size === 0) return false;
181
+ const module = (manifest.metadata as { module?: string } | undefined)?.module;
182
+ return module !== undefined && changedConfig.has(module);
183
+ };
184
+
185
+ const entries: DiffEntry[] = [];
186
+ const unchanged: string[] = [];
187
+ const stale: string[] = [];
188
+ const pending: ResourceManifest[] = [];
189
+ const survived = new Set<string>();
190
+
191
+ for (const manifest of next) {
192
+ const id = nodeIdFor(manifest);
193
+ const prior = before.get(id);
194
+ if (!prior) {
195
+ entries.push({ id, change: "added", next: manifest });
196
+ pending.push(manifest);
197
+ continue;
198
+ }
199
+ survived.add(id);
200
+ const priorSignature =
201
+ options.previousSignatures?.get(id) ?? declarationSignature(prior);
202
+ const same = !configMoved(manifest) && priorSignature === declarationSignature(manifest);
203
+ if (same) {
204
+ unchanged.push(id);
205
+ continue;
206
+ }
207
+ entries.push({ id, change: "changed", previous: prior, next: manifest });
208
+ stale.push(id);
209
+ pending.push(manifest);
210
+ }
211
+
212
+ for (const [id, manifest] of before) {
213
+ if (survived.has(id)) continue;
214
+ entries.push({ id, change: "removed", previous: manifest });
215
+ stale.push(id);
216
+ }
217
+
218
+ return { entries, unchanged, stale, pending };
219
+ }
@@ -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. */
@@ -65,6 +65,8 @@ import { isStepSlot } from "./step-slot.js";
65
65
  import { isInlineResource, resolveFieldEntries } from "./reference-field-map.js";
66
66
  import { findZoneProviders } from "./resolve-zone-containment.js";
67
67
  import { possibleUses, readRefSlot, type RefUse } from "./ref-slot.js";
68
+ import { canonicalJson } from "./canonical-json.js";
69
+ import { accessChains } from "./cel-access-chains.js";
68
70
 
69
71
  /** How a node's declaration reached the module, which is what decides where a
70
72
  * view may draw it — an inline child exists nowhere but its parent's YAML, so
@@ -591,15 +593,6 @@ export function contentKey(value: unknown): string {
591
593
  return hash.toString(36);
592
594
  }
593
595
 
594
- function canonicalJson(value: unknown): string {
595
- if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
596
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
597
- const entries = Object.entries(value as Record<string, unknown>)
598
- .filter(([, v]) => v !== undefined)
599
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
600
- return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
601
- }
602
-
603
596
  /** Mints ids that are unique without being positional: the name where one
604
597
  * exists, a content key where none does, and a `~n` suffix only when two
605
598
  * siblings are genuinely indistinguishable. */
@@ -1940,21 +1933,6 @@ function buildKindPlane(
1940
1933
  return out;
1941
1934
  }
1942
1935
 
1943
- /** The CEL environment used to PARSE a chain out of an expression. One per
1944
- * process: building it is the expensive half, and nothing here evaluates. */
1945
- let parseEnv: ReturnType<typeof buildCelEnvironment> | undefined;
1946
-
1947
- /** Access chains an expression reads, or none when it does not parse — a syntax
1948
- * error is the engine pass's to report, not this one's. */
1949
- function accessChains(source: string): string[][] {
1950
- try {
1951
- parseEnv ??= buildCelEnvironment();
1952
- return extractAccessChains(parseEnv.parse(source).ast);
1953
- } catch {
1954
- return [];
1955
- }
1956
- }
1957
-
1958
1936
  /**
1959
1937
  * Every `resources.<name>…` read in one resource's CEL, as an edge.
1960
1938
  *
@@ -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) {