@telorun/analyzer 0.8.1 → 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.
- package/dist/analysis-registry.d.ts +1 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +2 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +2 -2
- package/dist/flatten-for-analyzer.d.ts +30 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -0
- package/dist/flatten-for-analyzer.js +119 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/loaded-types.d.ts +81 -0
- package/dist/loaded-types.d.ts.map +1 -0
- package/dist/loaded-types.js +1 -0
- package/dist/manifest-loader.d.ts +30 -9
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +197 -417
- package/dist/parse-loaded-file.d.ts +12 -0
- package/dist/parse-loaded-file.d.ts.map +1 -0
- package/dist/parse-loaded-file.js +50 -0
- package/dist/position-metadata.d.ts +27 -0
- package/dist/position-metadata.d.ts.map +1 -0
- package/dist/position-metadata.js +88 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +62 -0
- package/package.json +3 -3
- package/src/analysis-registry.ts +2 -1
- package/src/analyzer.ts +4 -2
- package/src/flatten-for-analyzer.ts +134 -0
- package/src/index.ts +18 -0
- package/src/loaded-types.ts +86 -0
- package/src/manifest-loader.ts +230 -459
- package/src/parse-loaded-file.ts +70 -0
- package/src/position-metadata.ts +106 -0
- package/src/types.ts +6 -0
- package/src/validate-references.ts +68 -0
package/src/manifest-loader.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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.
|
|
74
|
-
if (cached && cached.text === text)
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
267
|
-
const
|
|
268
|
-
if (
|
|
269
|
-
const
|
|
270
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|