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