@telorun/analyzer 0.21.0 → 0.23.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 (41) hide show
  1. package/README.md +9 -17
  2. package/dist/alias-resolver.d.ts +7 -0
  3. package/dist/alias-resolver.d.ts.map +1 -1
  4. package/dist/alias-resolver.js +14 -0
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +59 -15
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +10 -9
  9. package/dist/cel-environment.d.ts +8 -0
  10. package/dist/cel-environment.d.ts.map +1 -1
  11. package/dist/cel-environment.js +48 -0
  12. package/dist/flatten-for-analyzer.d.ts +52 -0
  13. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  14. package/dist/flatten-for-analyzer.js +192 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/resolve-ref-sentinels.d.ts +22 -7
  19. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  20. package/dist/resolve-ref-sentinels.js +46 -136
  21. package/dist/schema-compat.d.ts.map +1 -1
  22. package/dist/schema-compat.js +28 -8
  23. package/dist/validate-cel-context.d.ts.map +1 -1
  24. package/dist/validate-cel-context.js +5 -0
  25. package/dist/validate-reference-forms.d.ts +28 -0
  26. package/dist/validate-reference-forms.d.ts.map +1 -0
  27. package/dist/validate-reference-forms.js +91 -0
  28. package/dist/validate-references.d.ts.map +1 -1
  29. package/dist/validate-references.js +4 -37
  30. package/package.json +2 -2
  31. package/src/alias-resolver.ts +14 -0
  32. package/src/analyzer.ts +69 -19
  33. package/src/builtins.ts +10 -9
  34. package/src/cel-environment.ts +57 -0
  35. package/src/flatten-for-analyzer.ts +217 -4
  36. package/src/index.ts +7 -0
  37. package/src/resolve-ref-sentinels.ts +39 -133
  38. package/src/schema-compat.ts +27 -8
  39. package/src/validate-cel-context.ts +5 -0
  40. package/src/validate-reference-forms.ts +110 -0
  41. package/src/validate-references.ts +4 -39
@@ -1,7 +1,26 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
- import type { LoadedFile, LoadedGraph, LoadedModule } from "./loaded-types.js";
2
+ import type { LoadedGraph, LoadedModule } from "./loaded-types.js";
3
+ import type { LoadedFile } from "./loaded-types.js";
3
4
  import { isModuleKind } from "./module-kinds.js";
4
5
 
6
+ /** One parsed `exports.resources` / `exports.kinds` entry. `name` is the exported
7
+ * instance name or kind suffix (the part after the dot, or the whole entry); `alias`
8
+ * (when set) is this library's own import the entry RE-EXPORTS from. */
9
+ export interface ParsedExportEntry {
10
+ name: string;
11
+ alias?: string;
12
+ }
13
+
14
+ /** Parse a single dotted export entry: `Alias.Name` → `{name: "Name", alias: "Alias"}`,
15
+ * bare `Name` → `{name: "Name"}`. The single grammar for `exports.resources` and
16
+ * `exports.kinds`, shared by the kernel's import controller and the analyzer/editor so
17
+ * the dotted-name split can't drift. A leading dot (`.Name`) has no alias by design —
18
+ * the empty prefix isn't a valid alias. */
19
+ export function parseExportEntry(entry: string): ParsedExportEntry {
20
+ const dot = entry.indexOf(".");
21
+ return dot > 0 ? { name: entry.slice(dot + 1), alias: entry.slice(0, dot) } : { name: entry };
22
+ }
23
+
5
24
  /** The import-boundary forwarding rule, shared by `flattenForAnalyzer` (the
6
25
  * CLI / kernel loader path) and the telo-editor's workspace projection so the
7
26
  * two cannot drift. Given one module's stamped manifests and whether that
@@ -29,9 +48,14 @@ export function selectModuleManifestsForAnalysis(
29
48
  if (isRoot) return moduleManifests;
30
49
 
31
50
  const libDoc = moduleManifests.find((m) => isModuleKind(m.kind));
32
- const exportedResources = new Set<string>(
33
- (libDoc as { exports?: { resources?: string[] } } | undefined)?.exports?.resources ?? [],
34
- );
51
+ // An `exports.resources` entry is a bare name or a dotted `Alias.Name` (re-export). Only the
52
+ // export NAME matches a local instance below; re-exports are forwarded by `forwardReExports`.
53
+ const exportedResources = new Set<string>();
54
+ for (const entry of (libDoc as { exports?: { resources?: unknown[] } } | undefined)?.exports
55
+ ?.resources ?? []) {
56
+ if (typeof entry !== "string") continue;
57
+ exportedResources.add(parseExportEntry(entry).name);
58
+ }
35
59
 
36
60
  const out: ResourceManifest[] = [];
37
61
  for (const m of moduleManifests) {
@@ -96,6 +120,8 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
96
120
  }
97
121
  }
98
122
 
123
+ forwardReExports(graph, result);
124
+
99
125
  // Stamp resolved import identity on every Telo.Import in the result by
100
126
  // reading the edge's pre-resolved name/namespace — no re-derivation from
101
127
  // manifest metadata. The edge is keyed by (owner-file, alias) which is
@@ -120,6 +146,193 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
120
146
  return result;
121
147
  }
122
148
 
149
+ /** A re-export declared in a library's `exports.resources` as a dotted `Alias.Name`:
150
+ * module `module` re-exports the instance `name` reached through its own import
151
+ * aliased `alias`. */
152
+ export interface ReExportSpec {
153
+ module: string;
154
+ alias: string;
155
+ name: string;
156
+ }
157
+
158
+ /** Extract re-export specs from a library doc's `exports.resources` — the dotted `Alias.Name`
159
+ * entries (bare-name locals are forwarded by the BFS instead). Shared by the CLI graph path
160
+ * and the editor's workspace projection so the two cannot drift. */
161
+ export function reExportSpecsFromExports(
162
+ moduleName: string,
163
+ exportsResources: readonly unknown[] | undefined,
164
+ ): ReExportSpec[] {
165
+ const specs: ReExportSpec[] = [];
166
+ for (const entry of exportsResources ?? []) {
167
+ if (typeof entry !== "string") continue;
168
+ const { name, alias } = parseExportEntry(entry);
169
+ if (!alias || alias === "Self") continue;
170
+ specs.push({ module: moduleName, alias, name });
171
+ }
172
+ return specs;
173
+ }
174
+
175
+ /** Forward re-exported instances (`exports.resources: [!ref Alias.name]`) transitively so a
176
+ * consumer's `!ref Consumer.name` resolves in `resolveRefSentinels` (keyed by the RE-EXPORTING
177
+ * module). The owning instance is already forwarded under its own module; here we emit an
178
+ * additional copy stamped under each re-exporting module, with an already-canonical kind. A
179
+ * fixpoint loop forwards chains of arbitrary depth (`app → api → domain → …`): each pass can
180
+ * resolve a re-export whose source was emitted in a prior pass. Graph-agnostic: `aliasToModule`
181
+ * maps `(module, alias)` to the imported module's name. Mutates `result` in place. */
182
+ export function forwardReExportManifests(
183
+ result: ResourceManifest[],
184
+ specs: readonly ReExportSpec[],
185
+ aliasToModule: (module: string, alias: string) => string | undefined,
186
+ ): void {
187
+ // Index forwarded instances by `module\0name` (only re-export TARGETS are forwarded).
188
+ const forwarded = new Map<string, ResourceManifest>();
189
+ for (const m of result) {
190
+ const meta = m.metadata as { module?: string; name?: string; forwardedExport?: boolean };
191
+ if (meta?.forwardedExport && meta.module && meta.name) {
192
+ forwarded.set(`${meta.module}\0${meta.name}`, m);
193
+ }
194
+ }
195
+
196
+ // Canonicalize an authored/forwarded kind to a scope-independent `<module>.<Kind>` using the
197
+ // owning module's own import aliases. Idempotent: an already-canonical kind whose prefix isn't
198
+ // an alias of `ownerModule` is returned unchanged, so re-exports of re-exports stay stable.
199
+ const canonicalKind = (kind: string, ownerModule: string): string => {
200
+ if (kind.startsWith("Self.")) return `${ownerModule}.${kind.slice("Self.".length)}`;
201
+ const dot = kind.indexOf(".");
202
+ if (dot <= 0) return kind;
203
+ const target = aliasToModule(ownerModule, kind.slice(0, dot));
204
+ return target ? `${target}.${kind.slice(dot + 1)}` : kind;
205
+ };
206
+
207
+ // Fixpoint — bounded by the number of specs (each can be satisfied at most once).
208
+ for (let pass = 0; pass <= specs.length; pass++) {
209
+ let added = false;
210
+ for (const spec of specs) {
211
+ const key = `${spec.module}\0${spec.name}`;
212
+ if (forwarded.has(key)) continue;
213
+ const sourceModule = aliasToModule(spec.module, spec.alias);
214
+ if (!sourceModule) continue;
215
+ const src = forwarded.get(`${sourceModule}\0${spec.name}`);
216
+ if (!src) continue; // source not forwarded yet — a later pass may satisfy it
217
+ const kind = canonicalKind(src.kind as string, sourceModule);
218
+ const manifest: ResourceManifest = {
219
+ ...src,
220
+ kind,
221
+ metadata: {
222
+ ...src.metadata,
223
+ name: spec.name,
224
+ module: spec.module,
225
+ forwardedExport: true,
226
+ } as ResourceManifest["metadata"],
227
+ };
228
+ result.push(manifest);
229
+ forwarded.set(key, manifest);
230
+ added = true;
231
+ }
232
+ if (!added) break;
233
+ }
234
+ }
235
+
236
+ /** Resolve every library's `exports.kinds` to a per-module map `suffix → canonical
237
+ * <owningModule>.<Kind>`, following re-exports (`Alias.Kind`) transitively via a fixpoint.
238
+ * `modules` lists each library's name + its raw `exports.kinds`; `aliasToModule(module, alias)`
239
+ * maps one of that module's import aliases to the imported module's name. Graph-agnostic —
240
+ * shared by the CLI graph path and the editor's workspace projection. */
241
+ export function resolveExportedKinds(
242
+ modules: ReadonlyArray<{ module: string; exportsKinds: readonly string[] }>,
243
+ aliasToModule: (module: string, alias: string) => string | undefined,
244
+ ): Map<string, Map<string, string>> {
245
+ const out = new Map<string, Map<string, string>>();
246
+ const tableFor = (m: string): Map<string, string> => {
247
+ let t = out.get(m);
248
+ if (!t) out.set(m, (t = new Map()));
249
+ return t;
250
+ };
251
+ for (let pass = 0; pass <= modules.length; pass++) {
252
+ let changed = false;
253
+ for (const { module, exportsKinds } of modules) {
254
+ const table = tableFor(module);
255
+ for (const entry of exportsKinds) {
256
+ const { name: suffix, alias } = parseExportEntry(entry);
257
+ if (table.has(suffix)) continue;
258
+ if (!alias) {
259
+ table.set(suffix, `${module}.${suffix}`);
260
+ changed = true;
261
+ continue;
262
+ }
263
+ const source = aliasToModule(module, alias);
264
+ const canonical = source ? out.get(source)?.get(suffix) : undefined;
265
+ if (canonical) {
266
+ table.set(suffix, canonical);
267
+ changed = true;
268
+ }
269
+ }
270
+ }
271
+ if (!changed) break;
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /** Stamp `metadata.reExportedKinds` (suffix → canonical kind) onto every `Telo.Import` whose
277
+ * target re-exports kinds, so the analyzer can register the re-export mappings. Only entries
278
+ * that point at a module OTHER than the import's own target are stamped (genuine re-exports;
279
+ * a locally-defined kind resolves through the normal alias path). Stamped on `metadata` (which
280
+ * permits additional properties, like `resolvedModuleName`) since the `Telo.Import` schema
281
+ * forbids extra top-level fields. Shared by both paths. */
282
+ export function stampReExportedKinds(
283
+ imports: ReadonlyArray<{ manifest: ResourceManifest; targetModule: string }>,
284
+ exportedKinds: Map<string, Map<string, string>>,
285
+ ): void {
286
+ for (const { manifest, targetModule } of imports) {
287
+ const table = exportedKinds.get(targetModule);
288
+ if (!table) continue;
289
+ const reExported: Record<string, string> = {};
290
+ for (const [suffix, canonical] of table) {
291
+ if (canonical !== `${targetModule}.${suffix}`) reExported[suffix] = canonical;
292
+ }
293
+ if (Object.keys(reExported).length === 0) continue;
294
+ (manifest.metadata as Record<string, unknown>).reExportedKinds = reExported;
295
+ }
296
+ }
297
+
298
+ /** CLI/kernel adapter: collect re-export specs + alias map from a LoadedGraph. */
299
+ function forwardReExports(graph: LoadedGraph, result: ResourceManifest[]): void {
300
+ const ownerSourceOf = new Map<string, string>();
301
+ const specs: ReExportSpec[] = [];
302
+ const kindModules: Array<{ module: string; exportsKinds: string[] }> = [];
303
+ for (const [source, mod] of graph.modules) {
304
+ if (source === graph.rootSource) continue; // root is an Application — no exports
305
+ const libDoc = mod.owner.manifests.find((m) => m && isModuleKind(m.kind)) as
306
+ | (ResourceManifest & { exports?: { resources?: unknown[]; kinds?: string[] } })
307
+ | undefined;
308
+ const moduleName = libDoc?.metadata?.name as string | undefined;
309
+ if (!libDoc || !moduleName) continue;
310
+ ownerSourceOf.set(moduleName, mod.owner.source);
311
+ specs.push(...reExportSpecsFromExports(moduleName, libDoc.exports?.resources));
312
+ kindModules.push({ module: moduleName, exportsKinds: libDoc.exports?.kinds ?? [] });
313
+ }
314
+ const aliasToModule = (module: string, alias: string): string | undefined => {
315
+ const ownerSource = ownerSourceOf.get(module);
316
+ return ownerSource
317
+ ? (graph.importEdges.get(ownerSource)?.get(alias)?.targetModuleName ?? undefined)
318
+ : undefined;
319
+ };
320
+ forwardReExportManifests(result, specs, aliasToModule);
321
+
322
+ // Resolve every library's re-exported kinds and stamp them onto the consumer-facing
323
+ // Telo.Import manifests so the analyzer can register the re-export mappings.
324
+ const exportedKinds = resolveExportedKinds(kindModules, aliasToModule);
325
+ const imports: Array<{ manifest: ResourceManifest; targetModule: string }> = [];
326
+ for (const m of result) {
327
+ if (m.kind !== "Telo.Import") continue;
328
+ const owner = (m.metadata as { source?: string } | undefined)?.source;
329
+ const alias = m.metadata?.name as string | undefined;
330
+ const target = owner && alias ? graph.importEdges.get(owner)?.get(alias)?.targetModuleName : undefined;
331
+ if (target) imports.push({ manifest: m, targetModule: target });
332
+ }
333
+ stampReExportedKinds(imports, exportedKinds);
334
+ }
335
+
123
336
  /** Project a LoadedModule (owner + partials) to a flat ResourceManifest[]
124
337
  * with `metadata.module` stamped on non-module docs. The kernel's runtime
125
338
  * entry load uses this to convert a `Loader.loadModule` result into the
package/src/index.ts CHANGED
@@ -12,7 +12,14 @@ export type {
12
12
  export {
13
13
  flattenForAnalyzer,
14
14
  flattenLoadedModule,
15
+ forwardReExportManifests,
16
+ parseExportEntry,
17
+ reExportSpecsFromExports,
18
+ resolveExportedKinds,
15
19
  selectModuleManifestsForAnalysis,
20
+ stampReExportedKinds,
21
+ type ParsedExportEntry,
22
+ type ReExportSpec,
16
23
  } from "./flatten-for-analyzer.js";
17
24
  export { visitManifest } from "./manifest-visitor.js";
18
25
  export type {
@@ -1,8 +1,6 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
- import { isRefSentinel } from "@telorun/templating";
2
+ import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
- import type { DefinitionRegistry } from "./definition-registry.js";
5
- import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
6
4
  import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
5
 
8
6
  /** Resolved ref shape written in place of a `!ref` sentinel. `alias` is set only for
@@ -10,9 +8,23 @@ import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
10
8
  type ResolvedRef = { kind: string; name: string; alias?: string };
11
9
 
12
10
  /**
13
- * Walks every `x-telo-ref` slot in every non-system resource and rewrites
14
- * `!ref <name>` sentinels in-place to `{kind, name}` (local) or
15
- * `{kind, name, alias}` (cross-module).
11
+ * Rewrites every `!ref <name>` sentinel in each non-system resource's value tree
12
+ * to `{kind, name}` (local) or `{kind, name, alias}` (cross-module), in place.
13
+ *
14
+ * The walk is value-tree-driven, not field-map-driven: a `!ref` tag is an
15
+ * *explicit* reference marker, so any sentinel found anywhere is unambiguously a
16
+ * reference and is resolved. This reaches sites the field map intentionally does
17
+ * not descend — notably `Run.Sequence` step `invoke`s (behind a local `$ref`)
18
+ * and references nested inside inline definitions — so every downstream consumer
19
+ * (Phase-5 injection, the runtime controllers, the analyzer's step-context and
20
+ * dependency passes) sees the uniform `{kind, name}` shape regardless of where
21
+ * the reference was written.
22
+ *
23
+ * Resolving a sentinel here does NOT cause Phase-5 injection: that pass is
24
+ * driven by the field map, which still excludes step `invoke`s, so a resolved
25
+ * step invoke stays `{kind, name}` and is dispatched through
26
+ * `executeInvokeStep` (preserving `<Kind>.<Name>.Invoked` events) rather than
27
+ * being replaced with a live instance.
16
28
  *
17
29
  * Reference grammar — the tag's source string is split on the FIRST dot:
18
30
  * - `!ref writeLine` → local resource `writeLine`
@@ -22,8 +34,10 @@ type ResolvedRef = { kind: string; name: string; alias?: string };
22
34
  *
23
35
  * Aliases are PascalCase identifiers without dots and resource names carry no dots
24
36
  * (enforced as a hard diagnostic), so the first-dot split is unambiguous. When the
25
- * name doesn't resolve, the sentinel is left in place so `validateReferences` emits the
26
- * `UNRESOLVED_REFERENCE` diagnostic with full context.
37
+ * name doesn't resolve (e.g. a scope-local target, or a cross-module reference in
38
+ * partial single-file analysis), the sentinel is left in place — the runtime
39
+ * resolves scope-local names on demand, and `validateReferences` emits the
40
+ * `UNRESOLVED_REFERENCE` diagnostic for genuine misses.
27
41
  *
28
42
  * Forwarded foreign resources (an imported library's exported instances, carrying a
29
43
  * `metadata.module` that isn't a root module) are resolution TARGETS only — they are not
@@ -31,7 +45,6 @@ type ResolvedRef = { kind: string; name: string; alias?: string };
31
45
  */
32
46
  export function resolveRefSentinels(
33
47
  resources: ResourceManifest[],
34
- registry: DefinitionRegistry,
35
48
  aliases?: AliasResolver,
36
49
  aliasesByModule?: Map<string, AliasResolver>,
37
50
  // Extra foreign resources used only as cross-module resolution TARGETS (not mutated, not
@@ -96,134 +109,27 @@ export function resolveRefSentinels(
96
109
  return undefined;
97
110
  };
98
111
 
99
- const processResource = (r: ResourceManifest): void => {
100
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) return;
101
-
102
- const fieldMap =
103
- aliases && aliasesByModule
104
- ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
105
- : registry.getFieldMapForKind(r.kind, aliases);
106
- if (!fieldMap) return;
107
-
108
- for (const [fieldPath, entry] of fieldMap) {
109
- const parts = fieldPath.split(".");
110
- if (isRefEntry(entry)) {
111
- descend(r as Record<string, unknown>, parts, resolveTarget);
112
- } else if (isScopeEntry(entry)) {
113
- // x-telo-scope resources (e.g. a Run.Sequence `with` server) carry their own ref
114
- // slots. The top-level walk skips scope contents, so recurse so a scoped resource's
115
- // `!ref` (e.g. an Http.Server mount) is canonicalized to {kind, name} like any other.
116
- forEachScopeResource(r as Record<string, unknown>, parts, processResource);
117
- }
112
+ // Resolve every `!ref` sentinel in the tree; leave opaque tagged / precompiled
113
+ // nodes (e.g. `!cel`) untouched and don't descend into them.
114
+ const walk = (value: unknown): unknown => {
115
+ if (isRefSentinel(value)) {
116
+ return resolveTarget(value.source) ?? value;
118
117
  }
118
+ if (value === null || typeof value !== "object") return value;
119
+ if (isTaggedSentinel(value)) return value;
120
+ if ((value as { __compiled?: unknown }).__compiled) return value;
121
+ if (Array.isArray(value)) {
122
+ for (let i = 0; i < value.length; i++) value[i] = walk(value[i]);
123
+ return value;
124
+ }
125
+ const obj = value as Record<string, unknown>;
126
+ for (const key of Object.keys(obj)) obj[key] = walk(obj[key]);
127
+ return value;
119
128
  };
120
129
 
121
130
  for (const r of resources) {
122
131
  if (isForeign(r)) continue;
123
- processResource(r);
124
- }
125
- }
126
-
127
- /**
128
- * Navigates `obj` along the scope field path (dot notation, `[]` = array items) and
129
- * invokes `cb` on every resource-like object found — any value carrying a `kind` string.
130
- *
131
- * Two-phase design:
132
- *
133
- * Phase 1 — path-walk: steps through each `parts` segment. `[]`-suffixed parts spread
134
- * the array into individual elements so `current` always ends up holding scalars or
135
- * plain objects, never intermediate arrays. Non-`[]` parts push the value as-is.
136
- *
137
- * Phase 2 — terminal visit: after the walk, `current` contains the values at the end
138
- * of the path. These are always scalars or plain objects because of the `[]` spreading
139
- * above, EXCEPT when a scope field is typed as an array in the schema but the path
140
- * was authored WITHOUT a `[]` suffix. The `visit` function handles that case by
141
- * recursing one level into arrays so `cb` is always called on resource objects, not
142
- * on their container.
143
- */
144
- function forEachScopeResource(
145
- obj: Record<string, unknown>,
146
- parts: string[],
147
- cb: (resource: ResourceManifest) => void,
148
- ): void {
149
- let current: unknown[] = [obj];
150
- for (const part of parts) {
151
- const isArr = part.endsWith("[]");
152
- const key = isArr ? part.slice(0, -2) : part;
153
- const next: unknown[] = [];
154
- for (const node of current) {
155
- if (!node || typeof node !== "object") continue;
156
- const val = (node as Record<string, unknown>)[key];
157
- if (val == null) continue;
158
- if (isArr && Array.isArray(val)) next.push(...val);
159
- else if (!isArr) next.push(val);
160
- }
161
- current = next;
162
- }
163
- const visit = (node: unknown): void => {
164
- if (Array.isArray(node)) {
165
- for (const elem of node) visit(elem);
166
- } else if (node && typeof node === "object" && typeof (node as { kind?: unknown }).kind === "string") {
167
- cb(node as ResourceManifest);
168
- }
169
- };
170
- for (const node of current) visit(node);
171
- }
172
-
173
- /** Walks `obj` along `fieldPath` parts (dot notation with `[]` for arrays and `{}` for
174
- * additionalProperties-typed maps) and replaces any `!ref` sentinel at the terminal slot
175
- * with its resolved `{kind, name, alias?}`. Mutates the parent container in place. */
176
- function descend(
177
- obj: unknown,
178
- parts: string[],
179
- resolve: (source: string) => ResolvedRef | undefined,
180
- ): void {
181
- if (obj == null || typeof obj !== "object" || parts.length === 0) return;
182
- const [head, ...rest] = parts;
183
-
184
- if (head === "{}") {
185
- const container = obj as Record<string, unknown>;
186
- for (const key of Object.keys(container)) {
187
- const child = container[key];
188
- if (rest.length === 0) {
189
- if (isRefSentinel(child)) {
190
- const target = resolve(child.source);
191
- if (target) container[key] = target;
192
- }
193
- } else {
194
- descend(child, rest, resolve);
195
- }
196
- }
197
- return;
198
- }
199
-
200
- const isArr = head.endsWith("[]");
201
- const key = isArr ? head.slice(0, -2) : head;
202
- const container = obj as Record<string, unknown>;
203
- const val = container[key];
204
- if (val == null) return;
205
-
206
- if (isArr) {
207
- if (!Array.isArray(val)) return;
208
- for (let i = 0; i < val.length; i++) {
209
- if (rest.length === 0) {
210
- const elem = val[i];
211
- if (isRefSentinel(elem)) {
212
- const target = resolve(elem.source);
213
- if (target) val[i] = target;
214
- }
215
- } else {
216
- descend(val[i], rest, resolve);
217
- }
218
- }
219
- } else {
220
- if (rest.length === 0) {
221
- if (isRefSentinel(val)) {
222
- const target = resolve(val.source);
223
- if (target) container[key] = target;
224
- }
225
- } else {
226
- descend(val, rest, resolve);
227
- }
132
+ if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
133
+ walk(r as Record<string, unknown>);
228
134
  }
229
135
  }
@@ -1,6 +1,6 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
3
+ import { isRefSentinel, isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
4
4
 
5
5
  const Ajv = (AjvModule as any).default ?? AjvModule;
6
6
 
@@ -138,20 +138,39 @@ export interface SchemaIssue {
138
138
  path: string;
139
139
  }
140
140
 
141
+ /** Does `schema` compile as-authored? Used to tell a malformed module schema
142
+ * (the author's problem) apart from a fault we introduced while normalizing it. */
143
+ function schemaCompiles(schema: Record<string, any>): boolean {
144
+ try {
145
+ ajv.compile(schema);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
141
152
  /** Validate actual data against a JSON Schema. Returns issues with path info, or empty array if valid. */
142
153
  export function validateAgainstSchema(data: unknown, schema: Record<string, any>): SchemaIssue[] {
143
154
  let validate = compiledSchemaValidators.get(schema);
144
155
  if (!validate) {
156
+ // Normalize outside the try: a fault in our own ref-slot normalization must
157
+ // surface, never be mistaken for the module author's schema being malformed.
158
+ // Drop the legacy scalar `type` an older published module may still pin on
159
+ // its `x-telo-ref` slots so a resolved reference object validates.
160
+ const normalized = normalizeRefSlots(schema) as Record<string, any>;
145
161
  try {
146
- validate = ajv.compile(schema);
147
- compiledSchemaValidators.set(schema, validate);
148
- } catch {
149
- // A schema that won't compile is reported loudly (once, on the owning
150
- // definition) by the analyzer's definition-schema compile check
151
- // (`DefinitionRegistry.schemaCompileError`), so returning `[]` here does
152
- // not silently accept resources of that kind.
162
+ validate = ajv.compile(normalized);
163
+ } catch (err) {
164
+ // The normalized schema didn't compile. If the original schema is itself
165
+ // malformed, that is the module author's error already surfaced once,
166
+ // anchored on the definition, by the analyzer's `SCHEMA_COMPILE_ERROR`
167
+ // pre-check (`DefinitionRegistry.schemaCompileError`); re-reporting it per
168
+ // resource would be noise, so skip. If the original compiles and only the
169
+ // normalized form fails, the fault is ours — let it throw.
170
+ if (schemaCompiles(schema)) throw err;
153
171
  return [];
154
172
  }
173
+ compiledSchemaValidators.set(schema, validate);
155
174
  }
156
175
  if (validate(data)) return [];
157
176
  return (validate.errors ?? []).map((err: any) => ({
@@ -51,6 +51,11 @@ export function resolveTypeFieldToSchema(
51
51
  if (obj.type || obj.properties) {
52
52
  return obj;
53
53
  }
54
+ // Named type reference resolved from a `!ref` → { kind, name } — resolve the
55
+ // named Telo.Type the same way as the bare-string form.
56
+ if (typeof obj.name === "string") {
57
+ return resolveTypeFieldToSchema(obj.name, allManifests);
58
+ }
54
59
  }
55
60
 
56
61
  return undefined;
@@ -0,0 +1,110 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isTaggedSentinel } from "@telorun/templating";
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { visitManifest } from "./manifest-visitor.js";
6
+ import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
8
+
9
+ const SOURCE = "telo-analyzer";
10
+
11
+ /**
12
+ * Reference-form validation — the single enforcement point for "a reference is
13
+ * written `!ref <name>` (or `!ref <Alias>.<name>`), nothing else".
14
+ *
15
+ * Runs on the RAW manifest set, BEFORE inline-resource extraction and `!ref`
16
+ * sentinel resolution. That ordering is load-bearing: only at this point is an
17
+ * author-written value still distinguishable from the resolver's own
18
+ * substitution. After normalization both an author's `{kind, name}` and a
19
+ * resolved `!ref` are the same `{kind, name}` object, so no later pass — and no
20
+ * JSON Schema — can tell them apart.
21
+ *
22
+ * At every `x-telo-ref` slot the only accepted value is:
23
+ * - a `!ref` sentinel (or any tagged sentinel — e.g. a `${{ }}` ref passed
24
+ * through a template), or
25
+ * - an inline definition: a plain object with a `kind` and NO `name` (the
26
+ * extractor assigns the name), or
27
+ * - a `${{ }}` CEL expression string (a reference flowed through CEL).
28
+ *
29
+ * Rejected, each with an actionable diagnostic pointing at `!ref`:
30
+ * - the object form `{ kind, name }` (the old reference object), and
31
+ * - a bare string (the old name / dotted-FQN reference).
32
+ */
33
+ export function validateReferenceForms(
34
+ resources: ResourceManifest[],
35
+ registry: DefinitionRegistry,
36
+ aliases?: AliasResolver,
37
+ aliasesByModule?: Map<string, AliasResolver>,
38
+ ): AnalysisDiagnostic[] {
39
+ if (!aliases) return [];
40
+ const diagnostics: AnalysisDiagnostic[] = [];
41
+
42
+ const isForeign = (r: ResourceManifest): boolean =>
43
+ (r.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true;
44
+ const localResources = resources.filter((r) => !isForeign(r));
45
+
46
+ visitManifest(
47
+ localResources,
48
+ registry,
49
+ {
50
+ onRef: (e) => {
51
+ const value = e.value;
52
+ // `!ref` and `!cel`/`${{ }}` sentinels are the supported shapes.
53
+ if (isTaggedSentinel(value)) return;
54
+
55
+ const r = e.source;
56
+ const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
57
+ const resourceData = { kind: r.kind, name: r.metadata!.name as string };
58
+ const filePath = (r.metadata as { source?: string } | undefined)?.source;
59
+ const path = e.concretePath;
60
+
61
+ if (typeof value === "string") {
62
+ // A `${{ }}` reference flowed through CEL is fine; any other bare
63
+ // string at a ref slot is the removed string / dotted-FQN form.
64
+ if (value.includes("${{")) return;
65
+ diagnostics.push({
66
+ severity: DiagnosticSeverity.Error,
67
+ code: "INVALID_REFERENCE_FORM",
68
+ source: SOURCE,
69
+ message: `${resourceLabel}: string reference at '${path}' → '${value}' is not supported; write it as '!ref ${refHint(value)}'`,
70
+ data: { resource: resourceData, filePath, path },
71
+ });
72
+ return;
73
+ }
74
+
75
+ if (value && typeof value === "object" && !Array.isArray(value)) {
76
+ const obj = value as Record<string, unknown>;
77
+ // A plain object is an inline definition unless it names a resource —
78
+ // a `name` makes it the removed `{ kind, name }` reference object.
79
+ if (typeof obj.name === "string" && typeof obj.kind === "string") {
80
+ diagnostics.push({
81
+ severity: DiagnosticSeverity.Error,
82
+ code: "INVALID_REFERENCE_FORM",
83
+ source: SOURCE,
84
+ message: `${resourceLabel}: object reference '{ kind, name }' at '${path}' is not supported; write it as '!ref ${obj.name}'`,
85
+ data: { resource: resourceData, filePath, path },
86
+ });
87
+ }
88
+ }
89
+ },
90
+ },
91
+ {
92
+ aliases,
93
+ aliasesByModule,
94
+ skipKinds: SYSTEM_KINDS,
95
+ expand: true,
96
+ discoverNestedRefs: true,
97
+ },
98
+ );
99
+
100
+ return diagnostics;
101
+ }
102
+
103
+ /** Best-effort name for the `!ref` suggestion in a string-ref diagnostic: a
104
+ * dotted-FQN (`Http.Api.UsersApi`) keeps its last segment, an alias-qualified
105
+ * name (`Console.writeLine`) is left intact, a bare name passes through. */
106
+ function refHint(value: string): string {
107
+ const dotCount = (value.match(/\./g) ?? []).length;
108
+ if (dotCount >= 2) return value.slice(value.lastIndexOf(".") + 1);
109
+ return value;
110
+ }