@telorun/analyzer 0.8.1 → 0.10.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 (54) hide show
  1. package/dist/analysis-registry.d.ts +5 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +7 -2
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +5 -5
  6. package/dist/definition-registry.d.ts +13 -1
  7. package/dist/definition-registry.d.ts.map +1 -1
  8. package/dist/definition-registry.js +58 -2
  9. package/dist/dependency-graph.d.ts +1 -1
  10. package/dist/dependency-graph.d.ts.map +1 -1
  11. package/dist/dependency-graph.js +8 -2
  12. package/dist/flatten-for-analyzer.d.ts +30 -0
  13. package/dist/flatten-for-analyzer.d.ts.map +1 -0
  14. package/dist/flatten-for-analyzer.js +119 -0
  15. package/dist/index.d.ts +6 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -0
  18. package/dist/loaded-types.d.ts +81 -0
  19. package/dist/loaded-types.d.ts.map +1 -0
  20. package/dist/loaded-types.js +1 -0
  21. package/dist/manifest-loader.d.ts +30 -9
  22. package/dist/manifest-loader.d.ts.map +1 -1
  23. package/dist/manifest-loader.js +197 -417
  24. package/dist/normalize-inline-resources.d.ts +1 -1
  25. package/dist/normalize-inline-resources.d.ts.map +1 -1
  26. package/dist/normalize-inline-resources.js +7 -2
  27. package/dist/parse-loaded-file.d.ts +12 -0
  28. package/dist/parse-loaded-file.d.ts.map +1 -0
  29. package/dist/parse-loaded-file.js +50 -0
  30. package/dist/position-metadata.d.ts +27 -0
  31. package/dist/position-metadata.d.ts.map +1 -0
  32. package/dist/position-metadata.js +88 -0
  33. package/dist/reference-field-map.d.ts +5 -0
  34. package/dist/reference-field-map.d.ts.map +1 -1
  35. package/dist/reference-field-map.js +9 -0
  36. package/dist/types.d.ts +6 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/validate-references.d.ts.map +1 -1
  39. package/dist/validate-references.js +68 -1
  40. package/package.json +3 -3
  41. package/src/analysis-registry.ts +11 -2
  42. package/src/analyzer.ts +17 -5
  43. package/src/definition-registry.ts +76 -3
  44. package/src/dependency-graph.ts +9 -1
  45. package/src/flatten-for-analyzer.ts +134 -0
  46. package/src/index.ts +18 -0
  47. package/src/loaded-types.ts +86 -0
  48. package/src/manifest-loader.ts +230 -459
  49. package/src/normalize-inline-resources.ts +8 -1
  50. package/src/parse-loaded-file.ts +70 -0
  51. package/src/position-metadata.ts +106 -0
  52. package/src/reference-field-map.ts +13 -0
  53. package/src/types.ts +6 -0
  54. package/src/validate-references.ts +74 -1
@@ -1,19 +1,22 @@
1
1
  import type { Environment } from "@marcbachmann/cel-js";
2
- import { isCompiledValue, type ResourceManifest } from "@telorun/sdk";
3
- import { defaultCustomTags } from "@telorun/templating";
4
- import { isMap, isPair, isScalar, isSeq, parseAllDocuments, type Document } from "yaml";
2
+ import type { ResourceManifest } from "@telorun/sdk";
5
3
  import { HttpSource } from "./sources/http-source.js";
6
4
  import { RegistrySource } from "./sources/registry-source.js";
7
5
  import { buildCelEnvironment } from "./cel-environment.js";
6
+ import type {
7
+ GraphLoadError,
8
+ ImportEdge,
9
+ LoadedFile,
10
+ LoadedGraph,
11
+ LoadedModule,
12
+ } from "./loaded-types.js";
8
13
  import { isModuleKind } from "./module-kinds.js";
9
- import { precompileDoc } from "./precompile.js";
14
+ import { parseLoadedFile } from "./parse-loaded-file.js";
10
15
  import {
11
16
  DEFAULT_MANIFEST_FILENAME,
12
17
  type LoadOptions,
13
18
  type LoaderInitOptions,
14
19
  type ManifestSource,
15
- type Position,
16
- type PositionIndex,
17
20
  } from "./types.js";
18
21
 
19
22
  const SYSTEM_KINDS = new Set([
@@ -24,10 +27,11 @@ const SYSTEM_KINDS = new Set([
24
27
  ]);
25
28
 
26
29
  export class Loader {
27
- private readonly moduleCache = new Map<
28
- string,
29
- { text: string; manifests: ResourceManifest[] }
30
- >();
30
+ /** LoadedFile cache keyed by `${compile ? "compiled" : "raw"}:${source}`.
31
+ * Same dual-keying as the legacy ResourceManifest[] cache: a compile-mode
32
+ * caller (kernel) and a raw-mode caller (analyzer/editor) on the same file
33
+ * get distinct entries, so neither sees the wrong manifest tree. */
34
+ private readonly fileCache = new Map<string, LoadedFile>();
31
35
 
32
36
  protected sources: ManifestSource[];
33
37
  private readonly celEnv: Environment;
@@ -67,105 +71,225 @@ export class Loader {
67
71
  return source;
68
72
  }
69
73
 
70
- async loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
74
+ // --- New API: returns LoadedFile / LoadedModule / LoadedGraph ----------
75
+
76
+ /** Read one file via the source chain and parse it into a LoadedFile.
77
+ * The result is shared with `Loader.fileCache`. Callers that want a
78
+ * private mutable copy must call `parseLoadedFile` directly with the
79
+ * LoadedFile's `text`. */
80
+ async loadFile(url: string, options?: LoadOptions): Promise<LoadedFile> {
71
81
  const { text, source } = await this.pick(url).read(url);
72
82
  const cacheKey = `${options?.compile ? "compiled" : "raw"}:${source}`;
73
- const cached = this.moduleCache.get(cacheKey);
74
- if (cached && cached.text === text) {
75
- return cloneManifestArray(cached.manifests);
83
+ const cached = this.fileCache.get(cacheKey);
84
+ if (cached && cached.text === text) return cached;
85
+
86
+ const loaded = parseLoadedFile(source, url, text, {
87
+ compile: options?.compile,
88
+ celEnv: this.celEnv,
89
+ });
90
+ this.fileCache.set(cacheKey, loaded);
91
+ return loaded;
92
+ }
93
+
94
+ /** Load an owner file plus every partial reachable through its `include:`
95
+ * list. Globs are expanded via the owning source's `expandGlob`. The
96
+ * partials list is empty when the owner declares no `include:`. */
97
+ async loadModule(url: string, options?: LoadOptions): Promise<LoadedModule> {
98
+ const owner = await this.loadFile(url, options);
99
+ this.assertSingleModuleDeclaration(owner);
100
+ this.assertNoSystemKindsInPartialContext(owner, /*isPartial*/ false);
101
+
102
+ const moduleManifest = owner.manifests.find((m) => m && isModuleKind(m.kind));
103
+ const includePatterns = (moduleManifest as { include?: string[] } | undefined)?.include;
104
+
105
+ if (!includePatterns?.length) return { owner, partials: [] };
106
+
107
+ const picked = this.pick(owner.source);
108
+ const includedFiles = await this.resolveIncludes(owner.source, includePatterns, picked);
109
+ const partials: LoadedFile[] = [];
110
+ for (const includedUrl of includedFiles) {
111
+ const partial = await this.loadFile(includedUrl, options);
112
+ this.assertNoSystemKindsInPartialContext(partial, /*isPartial*/ true);
113
+ partials.push(partial);
76
114
  }
77
115
 
78
- const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
79
- const rawDocs = parsedDocuments.map((d) => d.toJSON());
80
- const offsets = documentLineOffsets(text);
81
- const lineOffsets = buildLineOffsets(text);
82
-
83
- const resolved: ResourceManifest[] = [];
84
- let docIdx = 0;
85
- for (const rawDoc of rawDocs) {
86
- const currentDocIdx = docIdx++;
87
- const sourceLine = offsets[currentDocIdx] ?? 0;
88
- const positionIndex = buildPositionIndex(parsedDocuments[currentDocIdx], lineOffsets);
89
- if (rawDoc === null || rawDoc === undefined) continue;
90
-
91
- let compiledDocs: unknown[];
92
- if (options?.compile) {
93
- try {
94
- const result = precompileDoc(rawDoc, this.celEnv);
95
- compiledDocs = Array.isArray(result) ? result : [result];
96
- } catch (error) {
97
- throw new Error(
98
- `Failed to compile manifest in ${source}: ${error instanceof Error ? error.message : String(error)}`,
99
- );
116
+ return { owner, partials };
117
+ }
118
+
119
+ /** Load a module and every transitively-imported library. Returns the full
120
+ * LoadedGraph: `entry`, `modules` keyed by canonical source, and
121
+ * `importEdges` mapping each importing file's PascalCase aliases to their
122
+ * target's canonical source. */
123
+ async loadGraph(entryUrl: string, options?: LoadOptions): Promise<LoadedGraph> {
124
+ const entry = await this.loadModule(entryUrl, options);
125
+ const rootSource = entry.owner.source;
126
+
127
+ const modules = new Map<string, LoadedModule>();
128
+ modules.set(rootSource, entry);
129
+ const importEdges = new Map<string, Map<string, ImportEdge>>();
130
+ const errors: GraphLoadError[] = [];
131
+
132
+ const queue: LoadedModule[] = [entry];
133
+ const visited = new Set<string>([rootSource]);
134
+
135
+ while (queue.length > 0) {
136
+ const mod = queue.shift()!;
137
+
138
+ for (const file of [mod.owner, ...mod.partials]) {
139
+ const aliases = importEdges.get(file.source) ?? new Map<string, ImportEdge>();
140
+
141
+ for (let i = 0; i < file.manifests.length; i++) {
142
+ const m = file.manifests[i];
143
+ if (!m || m.kind !== "Telo.Import") continue;
144
+ const importSource = (m as { source?: string }).source;
145
+ if (!importSource) continue;
146
+ const alias = m.metadata?.name as string | undefined;
147
+ if (!alias) continue;
148
+ // Source line of this Telo.Import doc — read from the LoadedFile's
149
+ // position table since `parseLoadedFile` doesn't stamp `sourceLine`
150
+ // onto manifest metadata. Used to pin import-resolution diagnostics
151
+ // to the line where the import was declared.
152
+ const sourceLine = file.positions[i]?.sourceLine ?? 0;
153
+
154
+ let resolvedTarget: string;
155
+ try {
156
+ resolvedTarget = this.resolveImportUrl(file.source, importSource);
157
+ } catch (err) {
158
+ errors.push({
159
+ url: importSource,
160
+ fromSource: file.source,
161
+ error: err instanceof Error ? err : new Error(String(err)),
162
+ });
163
+ continue;
164
+ }
165
+
166
+ // Resolve the file we'll fetch through the source chain to get the
167
+ // canonical `source` URL — same identity used as the modules-map key.
168
+ let targetCanonical: string;
169
+ let targetModule: LoadedModule | undefined;
170
+ if (modules.has(resolvedTarget)) {
171
+ targetCanonical = resolvedTarget;
172
+ targetModule = modules.get(resolvedTarget);
173
+ } else {
174
+ try {
175
+ const loaded = await this.loadModule(resolvedTarget, options);
176
+ targetCanonical = loaded.owner.source;
177
+ if (!modules.has(targetCanonical)) {
178
+ modules.set(targetCanonical, loaded);
179
+ targetModule = loaded;
180
+ } else {
181
+ targetModule = modules.get(targetCanonical);
182
+ }
183
+ } catch (err) {
184
+ const e = err instanceof Error ? err : new Error(String(err));
185
+ (e as { sourceLine?: number }).sourceLine = sourceLine;
186
+ errors.push({ url: resolvedTarget, fromSource: file.source, error: e });
187
+ continue;
188
+ }
189
+ }
190
+
191
+ // Resolve target identity from its Telo.Library doc and stamp it
192
+ // on the edge — flattenForAnalyzer reads from the edge directly,
193
+ // never re-deriving from manifest.metadata.
194
+ let targetModuleName: string | null = null;
195
+ let targetNamespace: string | null = null;
196
+ if (targetModule) {
197
+ const lib = targetModule.owner.manifests.find(
198
+ (d) => d?.kind === "Telo.Library",
199
+ );
200
+ const libName = lib?.metadata?.name;
201
+ if (typeof libName === "string") targetModuleName = libName;
202
+ const libNs = (lib?.metadata as { namespace?: string | null } | undefined)
203
+ ?.namespace;
204
+ if (typeof libNs === "string") targetNamespace = libNs;
205
+ }
206
+
207
+ aliases.set(alias, {
208
+ targetSource: targetCanonical,
209
+ targetModuleName,
210
+ targetNamespace,
211
+ });
212
+
213
+ if (targetModule && !visited.has(targetCanonical)) {
214
+ visited.add(targetCanonical);
215
+ this.assertImportTargetIsLibrary(targetModule, importSource, sourceLine);
216
+ queue.push(targetModule);
217
+ }
100
218
  }
101
- } else {
102
- compiledDocs = [rawDoc];
103
- }
104
219
 
105
- for (const doc of compiledDocs) {
106
- if (doc === null || doc === undefined) continue;
107
- const manifest = doc as ResourceManifest;
108
- const metadata = { ...manifest.metadata, source, sourceLine };
109
- // positionIndex is non-enumerable so it is invisible to spread, JSON.stringify,
110
- // and schema validation — but still accessible via (m.metadata as any).positionIndex.
111
- Object.defineProperty(metadata, "positionIndex", {
112
- value: positionIndex,
113
- enumerable: false,
114
- writable: true,
115
- configurable: true,
116
- });
117
- resolved.push({ ...manifest, metadata });
220
+ if (aliases.size > 0) importEdges.set(file.source, aliases);
118
221
  }
119
222
  }
120
223
 
121
- const moduleManifests = resolved.filter((m) => isModuleKind(m.kind));
224
+ return { rootSource, entry, modules, importEdges, errors };
225
+ }
226
+
227
+ private resolveImportUrl(fromSource: string, importSource: string): string {
228
+ if (importSource.startsWith(".") || importSource.startsWith("/")) {
229
+ return this.pick(fromSource).resolveRelative(fromSource, importSource);
230
+ }
231
+ return importSource;
232
+ }
233
+
234
+ private assertSingleModuleDeclaration(file: LoadedFile): void {
235
+ const moduleManifests = file.manifests.filter(
236
+ (m): m is ResourceManifest => !!m && isModuleKind(m.kind),
237
+ );
122
238
  if (moduleManifests.length > 1) {
123
239
  const kinds = moduleManifests.map((m) => m.kind).join(", ");
124
240
  throw new Error(
125
- `File '${source}' contains ${moduleManifests.length} module declarations (${kinds}). ` +
241
+ `File '${file.source}' contains ${moduleManifests.length} module declarations (${kinds}). ` +
126
242
  `A file may declare at most one Telo.Application or Telo.Library.`,
127
243
  );
128
244
  }
129
- const moduleManifest = moduleManifests[0];
130
- const moduleName = moduleManifest?.metadata?.name as string | undefined;
131
- if (moduleName) {
132
- for (const manifest of resolved) {
133
- if (!isModuleKind(manifest.kind) && !manifest.metadata?.module) {
134
- const pi = (manifest.metadata as any)?.positionIndex;
135
- manifest.metadata = { ...manifest.metadata, module: moduleName };
136
- if (pi) {
137
- Object.defineProperty(manifest.metadata, "positionIndex", {
138
- value: pi,
139
- enumerable: false,
140
- writable: true,
141
- configurable: true,
142
- });
143
- }
144
- }
145
- }
146
- }
245
+ }
147
246
 
148
- // Expand include directives load partial files into the same module scope.
149
- // Results with includes are NOT cached because partial file content is not
150
- // tracked in the cache key — the cache would serve stale data if a partial changes.
151
- let hasIncludes = false;
152
- if (moduleManifest) {
153
- const includePatterns = (moduleManifest as any).include as string[] | undefined;
154
- if (includePatterns?.length) {
155
- hasIncludes = true;
156
- const picked = this.pick(source);
157
- const includedFiles = await this.resolveIncludes(source, includePatterns, picked);
158
- for (const includedUrl of includedFiles) {
159
- const partialManifests = await this.loadPartialFile(includedUrl, moduleName, options);
160
- resolved.push(...partialManifests);
161
- }
247
+ private assertNoSystemKindsInPartialContext(file: LoadedFile, isPartial: boolean): void {
248
+ if (!isPartial) return;
249
+ for (const m of file.manifests) {
250
+ if (!m) continue;
251
+ const kind = m.kind;
252
+ if (typeof kind === "string" && SYSTEM_KINDS.has(kind)) {
253
+ throw new Error(
254
+ `Included file '${file.source}' contains '${kind}' which is not allowed in partial files. ` +
255
+ `Only the owner telo.yaml may declare ${kind} resources.`,
256
+ );
162
257
  }
163
258
  }
259
+ }
164
260
 
165
- if (!hasIncludes) {
166
- this.moduleCache.set(cacheKey, { text, manifests: resolved });
261
+ private assertImportTargetIsLibrary(
262
+ target: LoadedModule,
263
+ importSource: string,
264
+ sourceLine: number,
265
+ ): void {
266
+ const importedLibrary = target.owner.manifests.find((m) => m?.kind === "Telo.Library");
267
+ const importedApplication = target.owner.manifests.find(
268
+ (m) => m?.kind === "Telo.Application",
269
+ );
270
+ if (importedApplication) {
271
+ const e = new Error(
272
+ `Telo.Import target '${importSource}' is a Telo.Application. ` +
273
+ `Only Telo.Library modules may be imported. Applications are run directly, not imported.`,
274
+ );
275
+ (e as { sourceLine?: number }).sourceLine = sourceLine;
276
+ throw e;
277
+ }
278
+ if (!importedLibrary) {
279
+ const kinds = target.owner.manifests
280
+ .map((m) => m?.kind)
281
+ .filter((k): k is string => typeof k === "string");
282
+ const detail = kinds.length
283
+ ? `Fetched ${target.owner.manifests.length} document(s) with kinds [${kinds.join(", ")}].`
284
+ : `Fetched manifest contained no recognizable Telo documents — check that the source ` +
285
+ `serves a Telo.Library manifest and not an upstream error page.`;
286
+ const e = new Error(
287
+ `Telo.Import target '${importSource}' did not resolve to a Telo.Library. ` +
288
+ `Fetched from: ${target.owner.source}. ${detail}`,
289
+ );
290
+ (e as { sourceLine?: number }).sourceLine = sourceLine;
291
+ throw e;
167
292
  }
168
- return cloneManifestArray(resolved);
169
293
  }
170
294
 
171
295
  private async resolveIncludes(
@@ -184,392 +308,39 @@ export class Loader {
184
308
  }
185
309
  return source.expandGlob(ownerSource, patterns);
186
310
  }
187
- // Literal relative paths — deduplicate in case the same file appears under multiple patterns.
188
311
  return [...new Set(patterns.map((p) => source.resolveRelative(ownerSource, p)))];
189
312
  }
190
313
 
191
- private async loadPartialFile(
192
- url: string,
193
- ownerModuleName: string | undefined,
194
- options?: LoadOptions,
195
- ): Promise<ResourceManifest[]> {
196
- const { text, source } = await this.pick(url).read(url);
197
-
198
- const parsedDocuments = parseAllDocuments(text, { customTags: defaultCustomTags() });
199
- const rawDocs = parsedDocuments.map((d) => d.toJSON());
200
- const offsets = documentLineOffsets(text);
201
- const lineOffsets = buildLineOffsets(text);
202
- const resolved: ResourceManifest[] = [];
203
- let docIdx = 0;
204
-
205
- for (const rawDoc of rawDocs) {
206
- const currentDocIdx = docIdx++;
207
- const sourceLine = offsets[currentDocIdx] ?? 0;
208
- const positionIndex = buildPositionIndex(parsedDocuments[currentDocIdx], lineOffsets);
209
- if (rawDoc === null || rawDoc === undefined) continue;
210
-
211
- const kind = rawDoc.kind as string | undefined;
212
- if (kind && SYSTEM_KINDS.has(kind)) {
213
- throw new Error(
214
- `Included file '${source}' contains '${kind}' which is not allowed in partial files. ` +
215
- `Only the owner telo.yaml may declare ${kind} resources.`,
216
- );
217
- }
218
-
219
- let compiledDocs: unknown[];
220
- if (options?.compile) {
221
- try {
222
- const result = precompileDoc(rawDoc, this.celEnv);
223
- compiledDocs = Array.isArray(result) ? result : [result];
224
- } catch (error) {
225
- throw new Error(
226
- `Failed to compile manifest in ${source}: ${error instanceof Error ? error.message : String(error)}`,
227
- );
228
- }
229
- } else {
230
- compiledDocs = [rawDoc];
231
- }
232
-
233
- for (const doc of compiledDocs) {
234
- if (doc === null || doc === undefined) continue;
235
- const manifest = doc as ResourceManifest;
236
- const metadata = {
237
- ...manifest.metadata,
238
- source,
239
- sourceLine,
240
- ...(ownerModuleName && !manifest.metadata?.module ? { module: ownerModuleName } : {}),
241
- };
242
- Object.defineProperty(metadata, "positionIndex", {
243
- value: positionIndex,
244
- enumerable: false,
245
- writable: true,
246
- configurable: true,
247
- });
248
- resolved.push({ ...manifest, metadata });
249
- }
250
- }
251
-
252
- return resolved;
253
- }
254
-
255
- async loadModuleForFile(
314
+ /** Find the owning telo.yaml for `fileUrl` (or use it directly if it's an
315
+ * owner) and return the `LoadedGraph` rooted at that owner. Returns
316
+ * `null` only when `fileUrl` is neither an owner nor reachable from one
317
+ * via parent-directory traversal. */
318
+ async loadGraphForFile(
256
319
  fileUrl: string,
257
- ): Promise<{
258
- ownerUrl: string;
259
- manifests: ResourceManifest[];
260
- sourceManifests: Map<string, ResourceManifest[]>;
261
- } | null> {
262
- // Try loading as a regular module first (it might be a telo.yaml itself).
263
- // Use loadManifests (not loadModule) so imported definitions are included —
264
- // otherwise the analyzer won't know about kinds from Telo.Import sources.
320
+ ): Promise<{ graph: LoadedGraph; ownerUrl: string } | null> {
265
321
  try {
266
- const docs = await this.loadModule(fileUrl);
267
- const hasModule = docs.some((d) => isModuleKind(d.kind));
268
- if (hasModule) {
269
- const { source } = await this.pick(fileUrl).read(fileUrl);
270
- const manifests = await this.loadManifests(fileUrl);
271
- return { ownerUrl: source, manifests, sourceManifests: groupBySource(manifests) };
322
+ const owner = await this.loadFile(fileUrl);
323
+ const isOwner = owner.manifests.some((m) => m && isModuleKind(m.kind));
324
+ if (isOwner) {
325
+ const graph = await this.loadGraph(fileUrl);
326
+ return { graph, ownerUrl: graph.rootSource };
272
327
  }
273
328
  } catch (err) {
274
- // If the file looks like an owner manifest (named telo.yaml), rethrow —
275
- // a broken owner shouldn't silently fall through to parent lookup.
276
329
  const normalized = fileUrl.replace(/\\/g, "/");
277
- if (normalized.endsWith(`/${DEFAULT_MANIFEST_FILENAME}`) || normalized === DEFAULT_MANIFEST_FILENAME) {
330
+ if (
331
+ normalized.endsWith(`/${DEFAULT_MANIFEST_FILENAME}`) ||
332
+ normalized === DEFAULT_MANIFEST_FILENAME
333
+ ) {
278
334
  throw err;
279
335
  }
280
- // Otherwise fall through to owner lookup — this is likely a partial file
281
336
  }
282
337
 
283
- // Find the owning telo.yaml via parent-directory traversal
284
338
  const source = this.pick(fileUrl);
285
339
  if (!source.resolveOwnerOf) return null;
286
340
  const ownerUrl = await source.resolveOwnerOf(fileUrl);
287
341
  if (!ownerUrl) return null;
288
-
289
- // Load the owner module (which will load included files via include expansion)
290
- const manifests = await this.loadManifests(ownerUrl);
291
- return { ownerUrl, manifests, sourceManifests: groupBySource(manifests) };
292
- }
293
-
294
- async loadModuleGraph(
295
- entryUrl: string,
296
- onError?: (url: string, error: Error) => void,
297
- ): Promise<Map<string, ResourceManifest[]>> {
298
- const visited = new Set<string>([entryUrl]);
299
- const result = new Map<string, ResourceManifest[]>();
300
-
301
- const entry = await this.loadModule(entryUrl);
302
- result.set(entryUrl, entry);
303
-
304
- const queue: ResourceManifest[] = [...entry];
305
-
306
- while (queue.length > 0) {
307
- const m = queue.shift()!;
308
- if (m.kind !== "Telo.Import") continue;
309
- const importSource = (m as any).source as string | undefined;
310
- if (!importSource) continue;
311
- const base = (m.metadata as any)?.source ?? entryUrl;
312
- const importUrl =
313
- importSource.startsWith(".") || importSource.startsWith("/")
314
- ? this.pick(base).resolveRelative(base, importSource)
315
- : importSource;
316
- if (visited.has(importUrl)) continue;
317
- visited.add(importUrl);
318
- let imported: ResourceManifest[];
319
- try {
320
- imported = await this.loadModule(importUrl);
321
- } catch (err) {
322
- const error = err instanceof Error ? err : new Error(String(err));
323
- onError?.(importUrl, error);
324
- continue;
325
- }
326
- result.set(importUrl, imported);
327
- for (const im of imported) {
328
- if (im.kind === "Telo.Import") queue.push(im);
329
- }
330
- }
331
-
332
- return result;
333
- }
334
-
335
- async loadManifests(entryUrl: string): Promise<ResourceManifest[]> {
336
- const visited = new Set<string>([entryUrl]);
337
- // Cache resolved library identity per import URL so a Telo.Import re-encountered
338
- // through a different chain still gets `resolvedModuleName` / `resolvedNamespace`
339
- // stamped — without re-loading the target. The early `visited` short-circuit used
340
- // to silently leave duplicate Telo.Imports unstamped, which broke alias resolution
341
- // when the same library was imported by two different files in the same analysis set.
342
- const libraryIdentityByUrl = new Map<
343
- string,
344
- { name: string; namespace: string | null }
345
- >();
346
- const entry = await this.loadModule(entryUrl);
347
-
348
- // Forward Telo.Definition, Telo.Abstract, AND Telo.Import docs from imported
349
- // libraries to the analyzer so its downstream passes can see them:
350
- // - Definitions / Abstracts feed cross-package `x-telo-ref` resolution and
351
- // `extends` target validation.
352
- // - Imports feed the per-library alias resolver — alias-form `extends` inside
353
- // a library (e.g. ai-openai's `extends: Ai.Model`) resolves against THAT
354
- // library's own `Telo.Import` declarations, not the root manifest's. Without
355
- // forwarding the imports, importing such a library would surface a spurious
356
- // EXTENDS_MALFORMED for an alias the library legitimately uses internally.
357
- // Alias resolution itself stays in the analyzer; the loader's only semantic
358
- // action is stamping `resolvedModuleName` / `resolvedNamespace` — recording the
359
- // result of loading. Identity is cached per URL (see libraryIdentityByUrl above)
360
- // because the same library can be reached through multiple chains, and every
361
- // Telo.Import doc — including the duplicates short-circuited by `visited` —
362
- // must end up stamped, otherwise per-scope alias resolution falls back to a
363
- // path-derived string (e.g. "abstract-lib.yaml") and produces wrong canonical
364
- // kinds.
365
- const importedDefs: ResourceManifest[] = [];
366
- const queue: ResourceManifest[] = [...entry];
367
-
368
- while (queue.length > 0) {
369
- const m = queue.shift()!;
370
- if (m.kind !== "Telo.Import") continue;
371
- const importSource = (m as any).source as string | undefined;
372
- if (!importSource) continue;
373
- const base = (m.metadata as any)?.source ?? entryUrl;
374
- const importUrl =
375
- importSource.startsWith(".") || importSource.startsWith("/")
376
- ? this.pick(base).resolveRelative(base, importSource)
377
- : importSource;
378
-
379
- if (!visited.has(importUrl)) {
380
- visited.add(importUrl);
381
- let imported: ResourceManifest[];
382
- try {
383
- imported = await this.loadModule(importUrl);
384
- } catch (err) {
385
- const e = err instanceof Error ? err : new Error(String(err));
386
- (e as any).sourceLine = (m.metadata as any)?.sourceLine ?? 0;
387
- throw e;
388
- }
389
- // Import target must be a Telo.Library. Check the Library branch
390
- // explicitly rather than "anything that's a module kind" so that a
391
- // future third kind can't silently slip past as a valid import target.
392
- const importedLibrary = imported.find((im) => im.kind === "Telo.Library");
393
- const importedApplication = imported.find((im) => im.kind === "Telo.Application");
394
- if (importedApplication) {
395
- const e = new Error(
396
- `Telo.Import target '${importSource}' is a Telo.Application. ` +
397
- `Only Telo.Library modules may be imported. Applications are run directly, not imported.`,
398
- );
399
- (e as any).sourceLine = (m.metadata as any)?.sourceLine ?? 0;
400
- throw e;
401
- }
402
- if (!importedLibrary) {
403
- const kinds = imported
404
- .map((im) => im.kind)
405
- .filter((k): k is string => typeof k === "string");
406
- // Prefer the URL the source layer actually fetched (stamped onto every
407
- // loaded manifest's metadata.source) over the raw input — for registry
408
- // refs the input is e.g. "std/foo@1.0.0", not a URL.
409
- const fetchedFrom =
410
- ((imported[0]?.metadata as { source?: string } | undefined)?.source) ?? importUrl;
411
- const detail = kinds.length
412
- ? `Fetched ${imported.length} document(s) with kinds [${kinds.join(", ")}].`
413
- : `Fetched manifest contained no recognizable Telo documents — check that the source ` +
414
- `serves a Telo.Library manifest and not an upstream error page.`;
415
- const e = new Error(
416
- `Telo.Import target '${importSource}' did not resolve to a Telo.Library. ` +
417
- `Fetched from: ${fetchedFrom}. ${detail}`,
418
- );
419
- (e as any).sourceLine = (m.metadata as any)?.sourceLine ?? 0;
420
- throw e;
421
- }
422
- if (importedLibrary?.metadata?.name) {
423
- libraryIdentityByUrl.set(importUrl, {
424
- name: importedLibrary.metadata.name as string,
425
- namespace: ((importedLibrary.metadata as any).namespace as string | null) ?? null,
426
- });
427
- }
428
- for (const im of imported) {
429
- if (
430
- im.kind === "Telo.Definition" ||
431
- im.kind === "Telo.Abstract" ||
432
- im.kind === "Telo.Import"
433
- ) {
434
- importedDefs.push(im);
435
- }
436
- if (im.kind === "Telo.Import") queue.push(im);
437
- }
438
- }
439
-
440
- // Stamp m with cached identity (works for both fresh and duplicate visits).
441
- const identity = libraryIdentityByUrl.get(importUrl);
442
- if (identity) {
443
- const pi = (m.metadata as any)?.positionIndex;
444
- m.metadata = {
445
- ...m.metadata,
446
- resolvedModuleName: identity.name,
447
- resolvedNamespace: identity.namespace,
448
- };
449
- if (pi) {
450
- Object.defineProperty(m.metadata, "positionIndex", {
451
- value: pi,
452
- enumerable: false,
453
- writable: true,
454
- configurable: true,
455
- });
456
- }
457
- }
458
- }
459
-
460
- return [...entry, ...importedDefs];
461
- }
462
- }
463
-
464
- function cloneManifestArray(manifests: ResourceManifest[]): ResourceManifest[] {
465
- return manifests.map((manifest) => cloneManifestValue(manifest));
466
- }
467
-
468
- function cloneManifestValue<T>(value: T): T {
469
- if (Array.isArray(value)) {
470
- return value.map((entry) => cloneManifestValue(entry)) as T;
471
- }
472
- if (isCompiledValue(value)) {
473
- return value;
474
- }
475
- if (value !== null && typeof value === "object") {
476
- const source = value as Record<string, unknown>;
477
- const clone: Record<string, unknown> = {};
478
- for (const [key, entry] of Object.entries(source)) {
479
- clone[key] = cloneManifestValue(entry);
480
- }
481
- const positionIndex = Object.getOwnPropertyDescriptor(source, "positionIndex");
482
- if (positionIndex) {
483
- Object.defineProperty(clone, "positionIndex", positionIndex);
484
- }
485
- return clone as T;
486
- }
487
- return value;
488
- }
489
-
490
- function groupBySource(manifests: ResourceManifest[]): Map<string, ResourceManifest[]> {
491
- const map = new Map<string, ResourceManifest[]>();
492
- for (const m of manifests) {
493
- const src = (m.metadata?.source as string) ?? "unknown";
494
- let list = map.get(src);
495
- if (!list) {
496
- list = [];
497
- map.set(src, list);
498
- }
499
- list.push(m);
500
- }
501
- return map;
502
- }
503
-
504
- function documentLineOffsets(text: string): number[] {
505
- const offsets = [0];
506
- const lines = text.split("\n");
507
- for (let i = 0; i < lines.length; i++) {
508
- const t = lines[i].trimEnd();
509
- if (t === "---" || t.startsWith("--- ")) offsets.push(i + 1);
510
- }
511
- return offsets;
512
- }
513
-
514
- /** Builds a byte-offset-to-line/character lookup table from raw text. */
515
- function buildLineOffsets(text: string): number[] {
516
- const offsets: number[] = [0];
517
- for (let i = 0; i < text.length; i++) {
518
- if (text[i] === "\n") offsets.push(i + 1);
519
- }
520
- return offsets;
521
- }
522
-
523
- function offsetToPosition(offset: number, lineOffsets: number[]): Position {
524
- let lo = 0;
525
- let hi = lineOffsets.length - 1;
526
- while (lo < hi) {
527
- const mid = (lo + hi + 1) >> 1;
528
- if (lineOffsets[mid] <= offset) lo = mid;
529
- else hi = mid - 1;
530
- }
531
- return { line: lo, character: offset - lineOffsets[lo] };
532
- }
533
-
534
- /** Walks the YAML AST and records source ranges for every field value, keyed by
535
- * dotted path (e.g. "kind", "config.handler", "config.routes[0].path"). */
536
- function buildPositionIndex(doc: Document, lineOffsets: number[]): PositionIndex {
537
- const index: PositionIndex = new Map();
538
-
539
- function recordNode(node: any, path: string): void {
540
- if (!node || !node.range) return;
541
- const [start, , end] = node.range as [number, number, number];
542
- index.set(path, {
543
- start: offsetToPosition(start, lineOffsets),
544
- end: offsetToPosition(end, lineOffsets),
545
- });
546
- }
547
-
548
- function walk(node: any, path: string): void {
549
- if (isMap(node)) {
550
- for (const pair of node.items) {
551
- if (!isPair(pair)) continue;
552
- const key = isScalar(pair.key) ? String(pair.key.value) : null;
553
- if (key == null) continue;
554
- const childPath = path ? `${path}.${key}` : key;
555
- if (pair.value != null) {
556
- recordNode(pair.value, childPath);
557
- walk(pair.value, childPath);
558
- }
559
- }
560
- } else if (isSeq(node)) {
561
- for (let i = 0; i < node.items.length; i++) {
562
- const item = node.items[i];
563
- const childPath = `${path}[${i}]`;
564
- recordNode(item, childPath);
565
- walk(item, childPath);
566
- }
567
- }
568
- }
569
-
570
- if (doc.contents) {
571
- walk(doc.contents, "");
342
+ const graph = await this.loadGraph(ownerUrl);
343
+ return { graph, ownerUrl: graph.rootSource };
572
344
  }
573
345
 
574
- return index;
575
346
  }