@telorun/analyzer 0.72.0 → 0.74.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 (208) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +76 -20
  3. package/dist/artifact-axes.d.ts +14 -0
  4. package/dist/artifact-axes.d.ts.map +1 -0
  5. package/dist/artifact-axes.js +12 -0
  6. package/dist/artifact-layer-index.d.ts +2 -2
  7. package/dist/artifact-layer-index.d.ts.map +1 -1
  8. package/dist/artifact-layer-index.js +9 -4
  9. package/dist/artifact-selector.d.ts +47 -21
  10. package/dist/artifact-selector.d.ts.map +1 -1
  11. package/dist/artifact-selector.js +85 -16
  12. package/dist/builtins.d.ts.map +1 -1
  13. package/dist/builtins.js +144 -113
  14. package/dist/call-graph.d.ts +12 -1
  15. package/dist/call-graph.d.ts.map +1 -1
  16. package/dist/call-graph.js +22 -0
  17. package/dist/catch-scope.js +1 -1
  18. package/dist/cel-scope-query.d.ts.map +1 -1
  19. package/dist/cel-scope-query.js +32 -11
  20. package/dist/cel-scope.d.ts.map +1 -1
  21. package/dist/cel-scope.js +1 -0
  22. package/dist/eval-paths.d.ts +36 -5
  23. package/dist/eval-paths.d.ts.map +1 -1
  24. package/dist/eval-paths.js +47 -6
  25. package/dist/extends-resolution.d.ts +12 -0
  26. package/dist/extends-resolution.d.ts.map +1 -1
  27. package/dist/extends-resolution.js +23 -0
  28. package/dist/forwarded-declaration.d.ts +26 -0
  29. package/dist/forwarded-declaration.d.ts.map +1 -0
  30. package/dist/forwarded-declaration.js +31 -0
  31. package/dist/index.d.ts +10 -4
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +5 -2
  34. package/dist/kernel-globals.d.ts.map +1 -1
  35. package/dist/kernel-globals.js +21 -5
  36. package/dist/module-alias-scope.d.ts +11 -0
  37. package/dist/module-alias-scope.d.ts.map +1 -1
  38. package/dist/module-alias-scope.js +10 -0
  39. package/dist/module-file-claims.d.ts +17 -1
  40. package/dist/module-file-claims.d.ts.map +1 -1
  41. package/dist/module-file-claims.js +38 -3
  42. package/dist/module-named-files.d.ts +72 -0
  43. package/dist/module-named-files.d.ts.map +1 -0
  44. package/dist/module-named-files.js +115 -0
  45. package/dist/native-entries.d.ts +83 -0
  46. package/dist/native-entries.d.ts.map +1 -0
  47. package/dist/native-entries.js +169 -0
  48. package/dist/nearest-name.d.ts +22 -0
  49. package/dist/nearest-name.d.ts.map +1 -0
  50. package/dist/nearest-name.js +43 -0
  51. package/dist/normalize-inline-resources.d.ts +41 -13
  52. package/dist/normalize-inline-resources.d.ts.map +1 -1
  53. package/dist/normalize-inline-resources.js +236 -54
  54. package/dist/reference-field-map.d.ts +59 -1
  55. package/dist/reference-field-map.d.ts.map +1 -1
  56. package/dist/reference-field-map.js +200 -34
  57. package/dist/release/destinations.d.ts +59 -0
  58. package/dist/release/destinations.d.ts.map +1 -0
  59. package/dist/release/destinations.js +75 -0
  60. package/dist/release/index.d.ts +6 -2
  61. package/dist/release/index.d.ts.map +1 -1
  62. package/dist/release/index.js +3 -1
  63. package/dist/release/ledger.d.ts +20 -11
  64. package/dist/release/ledger.d.ts.map +1 -1
  65. package/dist/release/ledger.js +43 -14
  66. package/dist/release/payload-digest.d.ts +2 -1
  67. package/dist/release/payload-digest.d.ts.map +1 -1
  68. package/dist/release/payload-digest.js +2 -1
  69. package/dist/release/release-plan.d.ts +3 -2
  70. package/dist/release/release-plan.d.ts.map +1 -1
  71. package/dist/release/release-plan.js +12 -19
  72. package/dist/release/workspace-config.d.ts +114 -34
  73. package/dist/release/workspace-config.d.ts.map +1 -1
  74. package/dist/release/workspace-config.js +322 -38
  75. package/dist/release/workspace-schema.d.ts +41 -0
  76. package/dist/release/workspace-schema.d.ts.map +1 -0
  77. package/dist/release/workspace-schema.js +77 -0
  78. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  79. package/dist/resolve-ref-sentinels.js +8 -5
  80. package/dist/resolve-throws-union.d.ts +25 -4
  81. package/dist/resolve-throws-union.d.ts.map +1 -1
  82. package/dist/resolve-throws-union.js +62 -32
  83. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  84. package/dist/resolve-zone-requirements.js +2 -1
  85. package/dist/rewrite-synthetic-origins.d.ts +6 -1
  86. package/dist/rewrite-synthetic-origins.d.ts.map +1 -1
  87. package/dist/rewrite-synthetic-origins.js +107 -13
  88. package/dist/schema-compat.d.ts.map +1 -1
  89. package/dist/schema-compat.js +18 -5
  90. package/dist/schema-error-report.d.ts +7 -0
  91. package/dist/schema-error-report.d.ts.map +1 -1
  92. package/dist/schema-error-report.js +9 -4
  93. package/dist/schema-walk.d.ts +47 -13
  94. package/dist/schema-walk.d.ts.map +1 -1
  95. package/dist/schema-walk.js +150 -30
  96. package/dist/scope-declarations.d.ts +59 -0
  97. package/dist/scope-declarations.d.ts.map +1 -0
  98. package/dist/scope-declarations.js +45 -0
  99. package/dist/source-entries.d.ts +119 -0
  100. package/dist/source-entries.d.ts.map +1 -0
  101. package/dist/source-entries.js +349 -0
  102. package/dist/step-bodies.d.ts +24 -0
  103. package/dist/step-bodies.d.ts.map +1 -0
  104. package/dist/step-bodies.js +28 -0
  105. package/dist/telo-version.d.ts +1 -1
  106. package/dist/telo-version.js +1 -1
  107. package/dist/template-body.d.ts +10 -0
  108. package/dist/template-body.d.ts.map +1 -1
  109. package/dist/template-body.js +25 -0
  110. package/dist/validate-cel-context.d.ts +23 -8
  111. package/dist/validate-cel-context.d.ts.map +1 -1
  112. package/dist/validate-cel-context.js +51 -18
  113. package/dist/validate-exports.d.ts +28 -0
  114. package/dist/validate-exports.d.ts.map +1 -0
  115. package/dist/validate-exports.js +146 -0
  116. package/dist/validate-extends.d.ts.map +1 -1
  117. package/dist/validate-extends.js +37 -1
  118. package/dist/validate-module-artifact.d.ts +4 -2
  119. package/dist/validate-module-artifact.d.ts.map +1 -1
  120. package/dist/validate-module-artifact.js +43 -4
  121. package/dist/validate-native-entries.d.ts +15 -0
  122. package/dist/validate-native-entries.d.ts.map +1 -0
  123. package/dist/validate-native-entries.js +103 -0
  124. package/dist/validate-observed-state.d.ts +7 -1
  125. package/dist/validate-observed-state.d.ts.map +1 -1
  126. package/dist/validate-observed-state.js +24 -6
  127. package/dist/validate-provider-coherence.d.ts +4 -8
  128. package/dist/validate-provider-coherence.d.ts.map +1 -1
  129. package/dist/validate-provider-coherence.js +12 -123
  130. package/dist/validate-reference-forms.d.ts.map +1 -1
  131. package/dist/validate-reference-forms.js +2 -2
  132. package/dist/validate-references.d.ts.map +1 -1
  133. package/dist/validate-references.js +152 -65
  134. package/dist/validate-scope-reach.d.ts +31 -0
  135. package/dist/validate-scope-reach.d.ts.map +1 -0
  136. package/dist/validate-scope-reach.js +180 -0
  137. package/dist/validate-source-entries.d.ts +16 -0
  138. package/dist/validate-source-entries.d.ts.map +1 -0
  139. package/dist/validate-source-entries.js +90 -0
  140. package/dist/validate-template-body.d.ts +36 -0
  141. package/dist/validate-template-body.d.ts.map +1 -0
  142. package/dist/validate-template-body.js +246 -0
  143. package/dist/validate-throws-coverage.d.ts.map +1 -1
  144. package/dist/validate-throws-coverage.js +21 -39
  145. package/dist/value-type-keyword.d.ts +22 -5
  146. package/dist/value-type-keyword.d.ts.map +1 -1
  147. package/dist/value-type-keyword.js +16 -7
  148. package/package.json +4 -3
  149. package/src/analyzer.ts +97 -20
  150. package/src/artifact-axes.ts +24 -0
  151. package/src/artifact-layer-index.ts +9 -6
  152. package/src/artifact-selector.ts +110 -35
  153. package/src/builtins.ts +160 -113
  154. package/src/call-graph.ts +30 -0
  155. package/src/catch-scope.ts +1 -1
  156. package/src/cel-scope-query.ts +28 -9
  157. package/src/cel-scope.ts +1 -0
  158. package/src/eval-paths.ts +53 -6
  159. package/src/extends-resolution.ts +24 -0
  160. package/src/forwarded-declaration.ts +32 -0
  161. package/src/index.ts +37 -1
  162. package/src/kernel-globals.ts +28 -5
  163. package/src/migrations/CLAUDE.md +35 -0
  164. package/src/module-alias-scope.ts +17 -0
  165. package/src/module-file-claims.ts +63 -4
  166. package/src/module-named-files.ts +162 -0
  167. package/src/native-entries.ts +227 -0
  168. package/src/nearest-name.ts +47 -0
  169. package/src/normalize-inline-resources.ts +326 -70
  170. package/src/reference-field-map.ts +257 -52
  171. package/src/release/CLAUDE.md +32 -0
  172. package/src/release/destinations.ts +105 -0
  173. package/src/release/index.ts +33 -2
  174. package/src/release/ledger.ts +51 -21
  175. package/src/release/payload-digest.ts +2 -1
  176. package/src/release/release-plan.ts +16 -26
  177. package/src/release/workspace-config.ts +483 -47
  178. package/src/release/workspace-schema.ts +99 -0
  179. package/src/resolve-ref-sentinels.ts +7 -6
  180. package/src/resolve-throws-union.ts +87 -32
  181. package/src/resolve-zone-requirements.ts +2 -1
  182. package/src/rewrite-synthetic-origins.ts +107 -16
  183. package/src/schema-compat.ts +18 -4
  184. package/src/schema-error-report.ts +16 -4
  185. package/src/schema-walk.ts +162 -34
  186. package/src/scope-declarations.ts +98 -0
  187. package/src/source-entries.ts +502 -0
  188. package/src/step-bodies.ts +55 -0
  189. package/src/telo-version.ts +1 -1
  190. package/src/template-body.ts +25 -0
  191. package/src/validate-cel-context.ts +60 -18
  192. package/src/validate-exports.ts +185 -0
  193. package/src/validate-extends.ts +43 -0
  194. package/src/validate-module-artifact.ts +46 -3
  195. package/src/validate-native-entries.ts +147 -0
  196. package/src/validate-observed-state.ts +36 -7
  197. package/src/validate-provider-coherence.ts +17 -127
  198. package/src/validate-reference-forms.ts +2 -3
  199. package/src/validate-references.ts +151 -66
  200. package/src/validate-scope-reach.ts +213 -0
  201. package/src/validate-source-entries.ts +120 -0
  202. package/src/validate-template-body.ts +274 -0
  203. package/src/validate-throws-coverage.ts +24 -31
  204. package/src/value-type-keyword.ts +35 -9
  205. package/dist/validate-template-dispatch.d.ts +0 -27
  206. package/dist/validate-template-dispatch.d.ts.map +0 -1
  207. package/dist/validate-template-dispatch.js +0 -95
  208. package/src/validate-template-dispatch.ts +0 -99
@@ -1,6 +1,7 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { OBSERVED_STATE_KEY } from "@telorun/sdk";
3
3
  import { effectiveStatusSchema } from "./extends-resolution.js";
4
+ import { isForwardedDeclaration } from "./forwarded-declaration.js";
4
5
  import { parseExportEntry } from "./flatten-for-analyzer.js";
5
6
  import {
6
7
  moduleScopedDefResolver,
@@ -129,11 +130,20 @@ export function collectRunReachableNames(graph: CallGraph): Set<string> {
129
130
 
130
131
  /** What a resource name resolves to for CEL purposes. `status` is present only
131
132
  * when the kind declares one; `scoped` marks a resource declared inside an
132
- * `x-telo-scope` slot, which resolves only within that scope's regions. */
133
+ * `x-telo-scope` slot, which resolves only within that scope's regions.
134
+ * `forwardedFrom` marks a dependency's declaration, keyed by
135
+ * `forwardedResourceKey`: it is in no consumer's `resources`, but a read inside
136
+ * that dependency's own manifests still resolves to it. */
133
137
  export interface AnalyzedResource {
134
138
  kind: string;
135
139
  status?: Record<string, any>;
136
140
  scoped?: boolean;
141
+ forwardedFrom?: string;
142
+ }
143
+
144
+ /** The index key of a name a dependency declares, as read from inside it. */
145
+ export function forwardedResourceKey(module: string, name: string): string {
146
+ return `${module}\0${name}`;
137
147
  }
138
148
 
139
149
  /**
@@ -157,16 +167,35 @@ export function buildObservedStateIndex(
157
167
  /** `module` is the resource's DECLARING module: an exported instance is
158
168
  * written with that library's aliases (`kind: Self.Listener`), which the
159
169
  * consumer's table cannot resolve. */
160
- const record = (kind: string, key: string, scoped: boolean, module?: string): void => {
170
+ const record = (
171
+ kind: string,
172
+ key: string,
173
+ scoped: boolean,
174
+ module?: string,
175
+ forwardedFrom?: string,
176
+ ): void => {
161
177
  const status = effectiveStatusSchema(resolve.in(kind, module), resolve);
162
- out.set(key, { kind, ...(status ? { status } : {}), ...(scoped ? { scoped } : {}) });
178
+ out.set(key, {
179
+ kind,
180
+ ...(status ? { status } : {}),
181
+ ...(scoped ? { scoped } : {}),
182
+ ...(forwardedFrom !== undefined ? { forwardedFrom } : {}),
183
+ });
163
184
  };
164
185
 
165
186
  for (const manifest of manifests) {
166
187
  const kind = manifest.kind as string | undefined;
167
188
  const name = manifest.metadata?.name as string | undefined;
168
189
  if (!kind || SYSTEM_KINDS.has(kind)) continue;
169
- if (name) record(kind, name, false, manifest.metadata?.module as string | undefined);
190
+ const module = manifest.metadata?.module as string | undefined;
191
+ // A dependency's declarations publish into ITS module's `resources`, not this
192
+ // one's: they are keyed by that module, and an export is also indexed under
193
+ // `<Alias>.<name>` below.
194
+ const forwardedFrom = isForwardedDeclaration(manifest) ? module : undefined;
195
+ if (forwardedFrom === undefined && isForwardedDeclaration(manifest)) continue;
196
+ const keyOf = (n: string) =>
197
+ forwardedFrom === undefined ? n : forwardedResourceKey(forwardedFrom, n);
198
+ if (name) record(kind, keyOf(name), false, module, forwardedFrom);
170
199
 
171
200
  const schema = resolve(kind)?.schema as Record<string, any> | undefined;
172
201
  if (!schema) continue;
@@ -177,7 +206,7 @@ export function buildObservedStateIndex(
177
206
  const scopedKind = (scopedEntry as ResourceManifest)?.kind;
178
207
  const scopedName = (scopedEntry as ResourceManifest)?.metadata?.name;
179
208
  if (typeof scopedKind === "string" && typeof scopedName === "string") {
180
- record(scopedKind, scopedName, true);
209
+ record(scopedKind, keyOf(scopedName), true, forwardedFrom, forwardedFrom);
181
210
  }
182
211
  }
183
212
  }
@@ -254,8 +283,8 @@ export function buildObservedStateResourcesSchema(
254
283
  open: boolean,
255
284
  ): Record<string, any> {
256
285
  const properties: Record<string, any> = {};
257
- for (const [key, { status }] of index) {
258
- if (!status) continue;
286
+ for (const [key, { status, forwardedFrom }] of index) {
287
+ if (!status || forwardedFrom !== undefined) continue;
259
288
  applyObservedStateNode(properties, key, status);
260
289
  }
261
290
  return open
@@ -1,7 +1,11 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
- import { controllerBearingAncestor, type DefResolver } from "./extends-resolution.js";
4
+ import {
5
+ controllerBearingAncestor,
6
+ inheritedCapability,
7
+ type DefResolver,
8
+ } from "./extends-resolution.js";
5
9
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
6
10
 
7
11
  const SOURCE = "telo-analyzer";
@@ -16,20 +20,16 @@ const SOURCE = "telo-analyzer";
16
20
  * `capability` is not `Telo.Provider`.
17
21
  * - PROVIDE_DISPATCHER_CONFLICT: `provide:` co-exists with `invoke:` or `run:`
18
22
  * on the same definition.
19
- * - PROVIDE_TARGET_UNKNOWN: `provide.name` does not resolve to an entry in
20
- * `resources:`.
21
- * - PROVIDE_TARGET_NOT_INVOCABLE: `provide.name` resolves to a resource whose
22
- * kind is registered but not a `Telo.Invocable`.
23
23
  * - PROVIDER_MISSING_IMPLEMENTATION: definition with `capability: Telo.Provider`
24
24
  * declares neither `controllers:` (TS-backed) nor `provide:` (template-backed).
25
25
  * - MOUNT_ON_NON_MOUNT: `mount:` declared on a definition whose `capability` is
26
26
  * not `Telo.Mount`.
27
27
  * - MOUNT_DISPATCHER_CONFLICT: `mount:` co-exists with another dispatch
28
28
  * entry-point (`invoke:` / `run:` / `provide:`).
29
- * - MOUNT_TARGET_UNKNOWN: `mount.name` does not resolve to an entry in
30
- * `resources:`.
31
- * - MOUNT_TARGET_NOT_MOUNTABLE: `mount.name` resolves to a resource whose kind
32
- * is registered but not a `Telo.Mount`.
29
+ *
30
+ * What a dispatch slot NAMES — that it is a `!ref`, that the entry exists, that
31
+ * its capability carries the method is `validate-template-body`'s, for every
32
+ * slot alike.
33
33
  */
34
34
  export function validateProviderCoherence(
35
35
  manifests: ResourceManifest[],
@@ -57,13 +57,19 @@ export function validateProviderCoherence(
57
57
  const label = `${m.kind}/${name}`;
58
58
 
59
59
  const md = m as Record<string, unknown>;
60
- const capability = typeof md.capability === "string" ? md.capability : undefined;
60
+ const resolveDef: DefResolver = (k) =>
61
+ registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
62
+ // The INHERITED capability, not the declared one: an `extends` child writes
63
+ // none of its own, so reading `md.capability` reported a child that inherits
64
+ // `Telo.Provider` and declares `provide:` as PROVIDE_ON_NON_PROVIDER
65
+ // "(found '<unset>')" — the same defect `celRuleApplies` and
66
+ // `validate-template-body` were fixed for, in the file next door.
67
+ const capability = inheritedCapability(m as ResourceDefinition, resolveDef);
61
68
  const provide = md.provide;
62
69
  const invoke = md.invoke;
63
70
  const run = md.run;
64
71
  const mount = md.mount;
65
72
  const controllers = md.controllers;
66
- const resources = md.resources;
67
73
 
68
74
  const hasProvide = provide !== undefined && provide !== null;
69
75
  const hasInvoke = invoke !== undefined && invoke !== null;
@@ -96,70 +102,6 @@ export function validateProviderCoherence(
96
102
  });
97
103
  }
98
104
 
99
- if (hasProvide && typeof provide === "object" && !Array.isArray(provide)) {
100
- const provideObj = provide as { kind?: unknown; name?: unknown };
101
- const providedName = typeof provideObj.name === "string" ? provideObj.name : undefined;
102
- const providedKind = typeof provideObj.kind === "string" ? provideObj.kind : undefined;
103
- if (providedName && Array.isArray(resources)) {
104
- const match = resources.find((r) => {
105
- const meta = (r as { metadata?: { name?: unknown } })?.metadata;
106
- return typeof meta?.name === "string" && meta.name === providedName;
107
- }) as { kind?: unknown } | undefined;
108
- if (!match) {
109
- diagnostics.push({
110
- severity: DiagnosticSeverity.Error,
111
- code: "PROVIDE_TARGET_UNKNOWN",
112
- source: SOURCE,
113
- message:
114
- `${label}: 'provide.name: ${providedName}' does not match any entry's ` +
115
- `metadata.name in 'resources:'.`,
116
- data: { resource, filePath, path: "provide.name" },
117
- });
118
- } else if (typeof match.kind === "string") {
119
- // `provide.kind` is the type contract the analyzer uses to type
120
- // `result` CEL against the target's `outputType`. The runtime
121
- // dispatches on `provide.name` and ignores `provide.kind`, so a
122
- // mismatch silently degrades `result` typing to an open schema
123
- // (and at runtime quietly invokes the actually-matched resource).
124
- // Flag the divergence so result-typing never lies.
125
- if (providedKind) {
126
- const providedCanonical = aliases.resolveKind(providedKind) ?? providedKind;
127
- const matchCanonical = aliases.resolveKind(match.kind) ?? match.kind;
128
- if (providedCanonical !== matchCanonical) {
129
- diagnostics.push({
130
- severity: DiagnosticSeverity.Error,
131
- code: "PROVIDE_KIND_MISMATCH",
132
- source: SOURCE,
133
- message:
134
- `${label}: 'provide.kind: ${providedKind}' disagrees with the matched ` +
135
- `'resources:' entry's kind '${match.kind}' (matched by metadata.name ` +
136
- `'${providedName}'). The runtime dispatches by name, so 'provide.kind' ` +
137
- `is decorative — but the analyzer types 'result:' against it, and a ` +
138
- `mismatch silently turns off that typing.`,
139
- data: { resource, filePath, path: "provide.kind" },
140
- });
141
- }
142
- }
143
- const resolvedKind = aliases.resolveKind(match.kind) ?? match.kind;
144
- const targetDef = registry.resolve(resolvedKind) ?? registry.resolve(match.kind);
145
- if (targetDef && targetDef.kind === "Telo.Definition") {
146
- const targetCap = (targetDef as { capability?: unknown }).capability;
147
- if (typeof targetCap === "string" && targetCap !== "Telo.Invocable") {
148
- diagnostics.push({
149
- severity: DiagnosticSeverity.Error,
150
- code: "PROVIDE_TARGET_NOT_INVOCABLE",
151
- source: SOURCE,
152
- message:
153
- `${label}: 'provide.name: ${providedName}' resolves to a ${match.kind} ` +
154
- `(capability '${targetCap}'); 'provide:' requires a Telo.Invocable target.`,
155
- data: { resource, filePath, path: "provide.name" },
156
- });
157
- }
158
- }
159
- }
160
- }
161
- }
162
-
163
105
  if (hasMount && capability !== "Telo.Mount") {
164
106
  diagnostics.push({
165
107
  severity: DiagnosticSeverity.Error,
@@ -185,61 +127,9 @@ export function validateProviderCoherence(
185
127
  });
186
128
  }
187
129
 
188
- if (hasMount) {
189
- // Resolve the target's name from either form: the bare string (the
190
- // primary, documented form — `mount: api`) or the object's `name`. A CEL
191
- // target (`${{ … }}`) can only be checked at runtime, so skip those.
192
- let mountedName: string | undefined;
193
- if (typeof mount === "string") {
194
- if (!mount.includes("${{")) mountedName = mount;
195
- } else if (typeof mount === "object" && !Array.isArray(mount)) {
196
- const mountObj = mount as { name?: unknown };
197
- if (typeof mountObj.name === "string" && !mountObj.name.includes("${{")) {
198
- mountedName = mountObj.name;
199
- }
200
- }
201
- const mountPath = typeof mount === "string" ? "mount" : "mount.name";
202
- if (mountedName && Array.isArray(resources)) {
203
- const match = resources.find((r) => {
204
- const meta = (r as { metadata?: { name?: unknown } })?.metadata;
205
- return typeof meta?.name === "string" && meta.name === mountedName;
206
- }) as { kind?: unknown } | undefined;
207
- if (!match) {
208
- diagnostics.push({
209
- severity: DiagnosticSeverity.Error,
210
- code: "MOUNT_TARGET_UNKNOWN",
211
- source: SOURCE,
212
- message:
213
- `${label}: '${mountPath}: ${mountedName}' does not match any entry's ` +
214
- `metadata.name in 'resources:'.`,
215
- data: { resource, filePath, path: mountPath },
216
- });
217
- } else if (typeof match.kind === "string") {
218
- const resolvedKind = aliases.resolveKind(match.kind) ?? match.kind;
219
- const targetDef = registry.resolve(resolvedKind) ?? registry.resolve(match.kind);
220
- if (targetDef && targetDef.kind === "Telo.Definition") {
221
- const targetCap = (targetDef as { capability?: unknown }).capability;
222
- if (typeof targetCap === "string" && targetCap !== "Telo.Mount") {
223
- diagnostics.push({
224
- severity: DiagnosticSeverity.Error,
225
- code: "MOUNT_TARGET_NOT_MOUNTABLE",
226
- source: SOURCE,
227
- message:
228
- `${label}: '${mountPath}: ${mountedName}' resolves to a ${match.kind} ` +
229
- `(capability '${targetCap}'); 'mount:' requires a Telo.Mount target.`,
230
- data: { resource, filePath, path: mountPath },
231
- });
232
- }
233
- }
234
- }
235
- }
236
- }
237
-
238
130
  // A definition that inherits a controller by delegation (concrete `extends`,
239
131
  // no own controller/template) satisfies the implementation requirement
240
132
  // through its parent — `base:` supplies the parent's config.
241
- const resolveDef: DefResolver = (k) =>
242
- registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
243
133
  const inheritsController =
244
134
  typeof md.extends === "string" &&
245
135
  controllerBearingAncestor(m as ResourceDefinition, resolveDef) !== undefined;
@@ -2,6 +2,7 @@ import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { isTaggedSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { isForwardedDeclaration } from "./forwarded-declaration.js";
5
6
  import { visitManifest } from "./manifest-visitor.js";
6
7
  import { satisfiesValueBranch } from "./reference-field-map.js";
7
8
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
@@ -50,9 +51,7 @@ export function validateReferenceForms(
50
51
  if (!aliases) return [];
51
52
  const diagnostics: AnalysisDiagnostic[] = [];
52
53
 
53
- const isForeign = (r: ResourceManifest): boolean =>
54
- (r.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true;
55
- const localResources = resources.filter((r) => !isForeign(r));
54
+ const localResources = resources.filter((r) => !isForwardedDeclaration(r));
56
55
 
57
56
  visitManifest(
58
57
  localResources,
@@ -15,10 +15,143 @@ import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } fro
15
15
  import type { AliasResolver } from "./alias-resolver.js";
16
16
  import type { DefinitionRegistry } from "./definition-registry.js";
17
17
  import { moduleAliasScope } from "./module-alias-scope.js";
18
+ import { isModuleKind } from "./module-kinds.js";
18
19
  import { isInjectedDeclaration } from "./resource-input.js";
20
+ import { outsideScopesOf } from "./scope-declarations.js";
21
+ import { isForwardedDeclaration, isForwardedExport } from "./forwarded-declaration.js";
19
22
 
20
23
  const SOURCE = "telo-analyzer";
21
24
 
25
+ /** What a name IS on one side of a collision. The three shapes share the
26
+ * kernel's one namespace but read as different things to an author — an alias
27
+ * is not a resource, and a module's own name is not a declaration inside it —
28
+ * so calling all three "resource name" describes two of them wrongly. */
29
+ function describeNamed(m: ResourceManifest, name: string): string {
30
+ if (m.kind === "Telo.Import") return `import alias '${name}'`;
31
+ if (isModuleKind(m.kind)) return `this module's own name (${m.kind} '${name}')`;
32
+ return `resource ${m.kind}/${name}`;
33
+ }
34
+
35
+ /**
36
+ * WHAT SHARES A NAME AT THE KERNEL.
37
+ *
38
+ * `registerManifest` keys on `metadata.name` alone, per module context, for
39
+ * EVERY kind — so an import alias, a kind definition and an ordinary resource
40
+ * are one namespace. This check used to model a narrower one: `Telo.Import` was
41
+ * excluded outright, on the stated grounds that an alias "lives in a separate
42
+ * namespace from resources", and `Telo.Definition` / `Telo.Abstract` fell out
43
+ * through the ref-validation skip set, which answers a different question. All
44
+ * three passed `telo check` and then died at boot with `ERR_DUPLICATE_RESOURCE`
45
+ * — an application named after one of its own imports (`metadata.name: Schedule`
46
+ * beside `Schedule: oci://…/scheduler`) being the shape that reaches an author,
47
+ * since nothing about the two lines looks like one name written twice.
48
+ *
49
+ * Grouped per DECLARING MODULE, which is what makes including imports sound: a
50
+ * library's own `Telo.Import` / `Telo.Definition` docs are forwarded into a
51
+ * consumer's flat set, and two modules sharing an alias (`Console` in an app and
52
+ * again in a library it imports) is ordinary rather than a collision. A module
53
+ * doc carries no `metadata.module` and IS its own scope — that is what puts an
54
+ * application's own name in the same group as its imports.
55
+ *
56
+ * Kept apart from the resolution lookup it used to share a map with: that one
57
+ * must hold resolution targets alone, so the two disagree about membership by
58
+ * design and merging them re-keyed every bare-name reference.
59
+ */
60
+ function duplicateNameDiagnostics(
61
+ resources: ResourceManifest[],
62
+ isForeign: (r: ResourceManifest) => boolean,
63
+ moduleOf: (r: ResourceManifest) => string | undefined,
64
+ ): AnalysisDiagnostic[] {
65
+ const diagnostics: AnalysisDiagnostic[] = [];
66
+
67
+ const moduleDocNames = new Set<string>();
68
+ for (const r of resources) {
69
+ if (isModuleKind(r.kind) && typeof r.metadata?.name === "string") {
70
+ moduleDocNames.add(r.metadata.name);
71
+ }
72
+ }
73
+ // A module doc belongs in the scope its OWN resources are in. Flattened, they
74
+ // carry `metadata.module` equal to its name, so it scopes to itself — which is
75
+ // what puts an application's name beside its imports. Unflattened (a hand-built
76
+ // fixture, a single-file editor analysis) nothing is stamped, so its resources
77
+ // sit in the unnamed scope and the doc has to join them there or a collision
78
+ // with one of them splits across two groups and goes unreported.
79
+ const stampedModules = new Set<string>();
80
+ for (const r of resources) {
81
+ const m = moduleOf(r);
82
+ if (m) stampedModules.add(m);
83
+ }
84
+
85
+ // scope → name → declarations, so no separator has to be safe against a name.
86
+ const byScope = new Map<string, Map<string, ResourceManifest[]>>();
87
+ const seen = new Set<string>();
88
+ for (const r of resources) {
89
+ if (!r.metadata?.name || isForeign(r)) continue;
90
+ const name = r.metadata.name as string;
91
+ const scope = isModuleKind(r.kind)
92
+ ? (stampedModules.has(name) ? name : "")
93
+ : (moduleOf(r) ?? "");
94
+ // A scope with no module doc in this set is an imported library's, whose
95
+ // doc `selectModuleManifestsForAnalysis` drops — its internals are its own
96
+ // author's to fix, and its own `telo check` reports them.
97
+ if (scope !== "" && moduleDocNames.size > 0 && !moduleDocNames.has(scope)) continue;
98
+ // Dedup pipeline echoes — the same physical document emitted twice through
99
+ // an analyzer host's pipeline (telo studio walks a file reachable as both an
100
+ // entry module and an `include:` partial). Keyed on (kind, name, source,
101
+ // sourceLine), so two textually-distinct docs in one file keep separate
102
+ // fingerprints and still trip the diagnostic.
103
+ const meta = r.metadata as unknown as { source?: string; sourceLine?: number };
104
+ const fingerprint = `${r.kind} ${name} ${meta.source} ${meta.sourceLine}`;
105
+ if (seen.has(fingerprint)) continue;
106
+ seen.add(fingerprint);
107
+ let names = byScope.get(scope);
108
+ if (!names) byScope.set(scope, (names = new Map()));
109
+ const existing = names.get(name);
110
+ if (existing) existing.push(r);
111
+ else names.set(name, [r]);
112
+ }
113
+
114
+ for (const names of byScope.values()) {
115
+ for (const [name, list] of names) {
116
+ if (list.length <= 1) continue;
117
+ const [first, ...rest] = list;
118
+ for (const dup of rest) {
119
+ // Two imports sharing an alias is ONE defect with its own diagnostic
120
+ // (`DUPLICATE_IMPORT_ALIAS`), which says what to do about it; reporting
121
+ // it again here would describe one mistake as two.
122
+ if (dup.kind === "Telo.Import" && first.kind === "Telo.Import") continue;
123
+ const dupMeta = dup.metadata as { source?: string; sourceLine?: number } | undefined;
124
+ // The precomputed range matters because editor hosts resolve positions
125
+ // via a `${file}::${kind}::${name}` lookup, which collides on duplicates.
126
+ const range =
127
+ typeof dupMeta?.sourceLine === "number"
128
+ ? {
129
+ start: { line: dupMeta.sourceLine, character: 0 },
130
+ end: { line: dupMeta.sourceLine, character: Number.MAX_SAFE_INTEGER },
131
+ }
132
+ : undefined;
133
+ diagnostics.push({
134
+ severity: DiagnosticSeverity.Error,
135
+ code: "DUPLICATE_RESOURCE_NAME",
136
+ source: SOURCE,
137
+ message:
138
+ `${dup.kind}/${name}: ${describeNamed(dup, name)} collides with ` +
139
+ `${describeNamed(first, name)} declared earlier — the kernel registers both ` +
140
+ `under '${name}' in one namespace, so boot fails with ERR_DUPLICATE_RESOURCE. ` +
141
+ `Rename one of them.`,
142
+ ...(range ? { range } : {}),
143
+ data: {
144
+ resource: { kind: dup.kind, name },
145
+ filePath: dupMeta?.source,
146
+ path: "metadata.name",
147
+ },
148
+ });
149
+ }
150
+ }
151
+ }
152
+ return diagnostics;
153
+ }
154
+
22
155
  /**
23
156
  * Liskov substitutability at a kind constraint: is `resolved` (a canonical
24
157
  * `<module>.<Kind>`) accepted where `targetKind` is required?
@@ -162,50 +295,25 @@ export function validateReferences(
162
295
  const aliasesByModule = context.aliasesByModule;
163
296
  if (!aliases || !registry) return diagnostics;
164
297
 
165
- // Build outer resource lookup by name for resolution check, collecting
166
- // every entry per name so we can surface name collisions as diagnostics
167
- // (the kernel's resource registry shares one namespace across all
168
- // non-system kinds e.g. `Telo.Application HelloApi` and `Http.Api
169
- // HelloApi` collide at boot with `ERR_DUPLICATE_RESOURCE`. Catching it
170
- // statically removes a class of "everything analyzes clean, then the
171
- // kernel refuses to start" surprises.)
298
+ // Build the outer resource lookup by name, for the resolution checks below.
299
+ // WHICH NAMES COLLIDE is a different question over a different namespace and
300
+ // is answered by `duplicateNameDiagnostics`; this map answers only "what does
301
+ // a bare `!ref <name>` resolve to", so it holds resolution TARGETS alone — an
302
+ // import alias and a kind definition are neither, and admitting them here
303
+ // would resolve a reference to something no ref slot can accept.
172
304
  //
173
- // Telo.Import is excluded from the duplicate check on top of the
174
- // SYSTEM_KINDS skip: its `metadata.name` is an alias, not a resource
175
- // identity (aliases live in a separate namespace from resources, and
176
- // colliding aliases vs. resource names is benign — the alias is only
177
- // ever read as a kind prefix).
178
- // Group manifests by name to detect collisions. Two subtleties:
179
- //
180
- // 1. Some analyzer hosts emit the SAME physical document twice through
181
- // their pipeline — e.g. telo studio's `toAnalysisManifests` walks
182
- // each workspace module's documents independently, and a file
183
- // reachable from two angles (entry module + `include:` partial)
184
- // shows up twice. The fingerprint includes `sourceLine` so identical
185
- // docs (same kind, name, source, AND source line) collapse to one,
186
- // while two textually-separate documents in the same file (different
187
- // source lines) keep separate fingerprints and trip the diagnostic.
188
- // 2. The diagnostic carries a precomputed `range` pointing at the
189
- // duplicate's source line — editor hosts that resolve diagnostic
190
- // positions via a `${file}::${kind}::${name}` lookup would otherwise
191
- // collide on duplicates (Map.set overwrites) and place the squiggle
192
- // ambiguously. The explicit `range` short-circuits that lookup.
193
- // Dedup pipeline echoes — the same physical document emitted twice
194
- // through an analyzer host's pipeline. Keyed on (kind, name, source,
195
- // sourceLine), so two textually-distinct docs in the same file (same
196
- // source, different sourceLine) keep separate fingerprints and still
197
- // trip the diagnostic. `analyze()` enforces that every non-system
198
- // manifest carries both positional fields — no defensive guard needed.
305
+ // The list per name survives because resolution falls back to the FIRST
306
+ // occurrence when a collision exists, which keeps the rest of the pass
307
+ // behaving as it did before duplicates were reported at all.
199
308
  // Forwarded foreign exports (an imported library's exported instances, carrying a
200
309
  // metadata.module that isn't a root module) are resolution TARGETS only: excluded from
201
310
  // duplicate detection and local name resolution, and never walked as ref sources.
202
311
  const moduleOf = (r: ResourceManifest): string | undefined =>
203
312
  (r.metadata as { module?: string } | undefined)?.module;
204
- // Forwarded exports are flagged by flattenForAnalyzer (`metadata.forwardedExport`); they're
205
- // cross-module resolution targets only — excluded from duplicate detection and local name
206
- // resolution, and never walked as ref sources.
207
- const isForeign = (r: ResourceManifest): boolean =>
208
- (r.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true;
313
+ // A dependency's code a forwarded export, or what extraction pulled out of one — is
314
+ // excluded from duplicate detection and local name resolution, and never walked as a
315
+ // ref source. Only an EXPORT is a cross-module resolution target.
316
+ const isForeign = isForwardedDeclaration;
209
317
  // Forwarded exported instances keyed `${module}\0${name}` — the lookup that resolves
210
318
  // whether a cross-module `!ref Alias.name` names a real exported instance.
211
319
  const byModuleName = new Map<string, ResourceManifest>();
@@ -222,7 +330,7 @@ export function validateReferences(
222
330
  if (typeof m === "string") loadedModules.add(m);
223
331
  continue;
224
332
  }
225
- if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForeign(r)) continue;
333
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForwardedExport(r)) continue;
226
334
  const m = moduleOf(r);
227
335
  if (!m) continue;
228
336
  byModuleName.set(`${m}\0${r.metadata.name as string}`, r);
@@ -246,33 +354,7 @@ export function validateReferences(
246
354
  if (existing) existing.push(r);
247
355
  else byNameAll.set(name, [r]);
248
356
  }
249
- for (const [name, list] of byNameAll) {
250
- if (list.length <= 1) continue;
251
- const [first, ...rest] = list;
252
- const firstLabel = `${first.kind}/${name}`;
253
- for (const dup of rest) {
254
- const dupMeta = dup.metadata as { source?: string; sourceLine?: number } | undefined;
255
- const range =
256
- typeof dupMeta?.sourceLine === "number"
257
- ? {
258
- start: { line: dupMeta.sourceLine, character: 0 },
259
- end: { line: dupMeta.sourceLine, character: Number.MAX_SAFE_INTEGER },
260
- }
261
- : undefined;
262
- diagnostics.push({
263
- severity: DiagnosticSeverity.Error,
264
- code: "DUPLICATE_RESOURCE_NAME",
265
- source: SOURCE,
266
- message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
267
- ...(range ? { range } : {}),
268
- data: {
269
- resource: { kind: dup.kind, name },
270
- filePath: dupMeta?.source,
271
- path: "metadata.name",
272
- },
273
- });
274
- }
275
- }
357
+ diagnostics.push(...duplicateNameDiagnostics(resources, isForeign, moduleOf));
276
358
  // The dot rule that used to live here is now the strictest special case of
277
359
  // the identifier grammar in `validate-identifier-names.ts` — a dot is one of
278
360
  // several characters that make a name unreferenceable, and checking one of
@@ -341,6 +423,9 @@ export function validateReferences(
341
423
  visibleScopeManifests.find((m) => m.metadata?.name === localName) ??
342
424
  byName.get(localName);
343
425
  if (!target) {
426
+ // A scoped name out of this declaration's reach has its own
427
+ // diagnostic, which says why; this one would say only "not found".
428
+ if (outsideScopesOf(r).some((scope) => scope.names.includes(localName))) return;
344
429
  diagnostics.push({
345
430
  severity: DiagnosticSeverity.Error,
346
431
  code: "UNRESOLVED_REFERENCE",