@telorun/analyzer 0.1.1 → 0.1.2

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.
@@ -1,3 +1,34 @@
1
+ /**
2
+ * Resolve a type field value (string name, inline type, or raw schema) to a JSON Schema.
3
+ * - String: look up the named type in allManifests (Type.JsonSchema resources)
4
+ * - Object with `kind` + `schema`: inline type definition → return the `schema`
5
+ * - Object with `type` or `properties`: raw JSON Schema, return as-is
6
+ */
7
+ export function resolveTypeFieldToSchema(value, allManifests) {
8
+ if (!value)
9
+ return undefined;
10
+ if (typeof value === "string") {
11
+ // Named type reference — find a Kernel.Type resource by name
12
+ const typeManifest = allManifests.find((m) => m.metadata?.name === value &&
13
+ typeof m.kind === "string" &&
14
+ /\bType\b/.test(m.kind) &&
15
+ typeof m.schema === "object" &&
16
+ m.schema !== null);
17
+ return typeManifest?.schema;
18
+ }
19
+ if (typeof value === "object" && value !== null) {
20
+ const obj = value;
21
+ // Inline type resource: { kind: "Type.JsonSchema", schema: {...} }
22
+ if (obj.schema && typeof obj.schema === "object") {
23
+ return obj.schema;
24
+ }
25
+ // Raw JSON Schema (has type or properties)
26
+ if (obj.type || obj.properties) {
27
+ return obj;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
1
32
  /**
2
33
  * Extract all member-access chains from a CEL AST.
3
34
  * Returns arrays like ["request", "query", "name"] for `request.query.name`.
@@ -87,36 +118,36 @@ export function validateChainAgainstSchema(chain, schema) {
87
118
  const key = chain[i];
88
119
  if (!current || typeof current !== "object")
89
120
  return null;
90
- // Open schema: no properties declared or explicitly allows additional properties
91
121
  const props = current.properties;
92
122
  if (!props)
93
123
  return null;
124
+ if (key in props) {
125
+ // Known property — drill into it even if additionalProperties is true
126
+ current = props[key];
127
+ continue;
128
+ }
129
+ // Unknown property — only flag if schema is closed
94
130
  if (current.additionalProperties === true)
95
131
  return null;
96
- if (!(key in props)) {
97
- const path = chain.slice(0, i + 1).join(".");
98
- const available = Object.keys(props).join(", ");
99
- return `'${path}' is not defined (available: ${available})`;
100
- }
101
- current = props[key];
132
+ const path = chain.slice(0, i + 1).join(".");
133
+ const available = Object.keys(props).join(", ");
134
+ return `'${path}' is not defined (available: ${available})`;
102
135
  }
103
136
  return null;
104
137
  }
105
138
  /**
106
- * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].handler.inputs.name")
107
- * falls within the container region of a context scope (e.g. "$.routes[*].handler").
139
+ * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
140
+ * falls within the scope of a context (e.g. "$.routes[*].inputs").
108
141
  *
109
- * The container is derived by stripping the last dot-separated segment from the scope, so that
110
- * sibling fields within the same parent (e.g. routes[*].response) also match.
142
+ * The scope is matched directly (no sibling sharing): a context at "$.routes[*].inputs" only
143
+ * applies to expressions whose path starts with "routes[N].inputs", not to other sibling fields.
111
144
  */
112
145
  export function pathMatchesScope(exprPath, scope) {
113
146
  const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
114
- const lastDot = stripped.lastIndexOf(".");
115
- if (lastDot <= 0)
147
+ if (!stripped)
116
148
  return false;
117
- const container = stripped.slice(0, lastDot); // e.g. "routes[*]"
118
149
  // Split on wildcard array segments; each [*] must match a concrete [N] in exprPath
119
- const parts = container.split("[*]");
150
+ const parts = stripped.split("[*]");
120
151
  let remaining = exprPath;
121
152
  for (let i = 0; i < parts.length; i++) {
122
153
  const part = parts[i];
@@ -134,3 +165,80 @@ export function pathMatchesScope(exprPath, scope) {
134
165
  // Expression must end here or continue into a child path
135
166
  return remaining === "" || remaining[0] === "." || remaining[0] === "[";
136
167
  }
168
+ /**
169
+ * Resolves `x-telo-context-from` annotations in a context schema using the concrete
170
+ * manifest item. Navigates the manifest item at the given slash-separated path and merges
171
+ * the result as named properties into the annotated node (locking additionalProperties: false).
172
+ *
173
+ * Example: `x-telo-context-from: "request/schema"` on the `request` context node replaces
174
+ * the open `request` schema with a closed schema whose properties are the keys of
175
+ * `manifestItem.request.schema` (e.g. `query`, `body`, `params`, `headers`).
176
+ */
177
+ export function resolveContextAnnotations(schema, manifestItem, allManifests) {
178
+ if (!schema || typeof schema !== "object")
179
+ return schema;
180
+ const from = schema["x-telo-context-from"];
181
+ if (from) {
182
+ const resolved = navigatePath(manifestItem, from.split("/"));
183
+ // `resolved` is a map of property names → sub-schemas (e.g. { query: {...}, body: {...} })
184
+ return {
185
+ ...schema,
186
+ properties: { ...(schema.properties ?? {}), ...(resolved ?? {}) },
187
+ additionalProperties: false,
188
+ };
189
+ }
190
+ const refFrom = schema["x-telo-context-ref-from"];
191
+ if (refFrom && allManifests) {
192
+ const slashIdx = refFrom.indexOf("/");
193
+ const refProp = slashIdx === -1 ? refFrom : refFrom.slice(0, slashIdx);
194
+ const subpath = slashIdx === -1 ? undefined : refFrom.slice(slashIdx + 1);
195
+ const ref = manifestItem[refProp];
196
+ if (ref &&
197
+ typeof ref === "object" &&
198
+ typeof ref.kind === "string" &&
199
+ typeof ref.name === "string" &&
200
+ subpath) {
201
+ const refManifest = allManifests.find((m) => m.kind === ref.kind && m.metadata?.name === ref.name);
202
+ if (refManifest) {
203
+ const resolved = resolveTypeFieldToSchema(navigatePath(refManifest, subpath.split("/")), allManifests);
204
+ if (resolved && typeof resolved === "object") {
205
+ return resolved;
206
+ }
207
+ }
208
+ }
209
+ // Fallback: open schema (no false errors when outputType is not declared)
210
+ return { ...schema, additionalProperties: true };
211
+ }
212
+ if (schema.properties) {
213
+ const props = {};
214
+ for (const [k, v] of Object.entries(schema.properties)) {
215
+ props[k] = resolveContextAnnotations(v, manifestItem, allManifests);
216
+ }
217
+ return { ...schema, properties: props };
218
+ }
219
+ return schema;
220
+ }
221
+ /**
222
+ * Extracts the concrete manifest array item for a given expression path + scope.
223
+ * e.g. exprPath="routes[0].inputs.q", scope="$.routes[*].inputs" → manifest.routes[0]
224
+ */
225
+ export function getManifestItem(exprPath, scope, manifest) {
226
+ const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
227
+ const wildcardIdx = stripped.indexOf("[*]");
228
+ if (wildcardIdx === -1)
229
+ return manifest;
230
+ const arrayProp = stripped.slice(0, wildcardIdx); // e.g. "routes"
231
+ const m = exprPath.match(new RegExp(`^${arrayProp}\\[(\\d+)\\]`));
232
+ if (!m)
233
+ return manifest;
234
+ return manifest[arrayProp]?.[Number(m[1])] ?? manifest;
235
+ }
236
+ function navigatePath(obj, segments) {
237
+ let cur = obj;
238
+ for (const seg of segments) {
239
+ if (cur === null || typeof cur !== "object")
240
+ return undefined;
241
+ cur = cur[seg];
242
+ }
243
+ return cur;
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -22,7 +22,7 @@
22
22
  "ajv-formats": "^3.0.1",
23
23
  "yaml": "^2.8.3",
24
24
  "jsonpath-plus": "^10.3.0",
25
- "@telorun/sdk": "0.2.6"
25
+ "@telorun/sdk": "0.2.7"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^20.0.0",
@@ -17,7 +17,7 @@ export class HttpAdapter implements ManifestAdapter {
17
17
  }
18
18
 
19
19
  resolveRelative(base: string, relative: string): string {
20
- const baseWithSlash = base.endsWith("/") ? base : `${base}/`;
21
- return new URL(relative, baseWithSlash).href;
20
+ const baseDir = base.endsWith("/") ? base : base.slice(0, base.lastIndexOf("/") + 1);
21
+ return new URL(relative, baseDir).href;
22
22
  }
23
23
  }
@@ -1,8 +1,10 @@
1
1
  import type { ManifestAdapter } from "../types.js";
2
2
 
3
- const REGISTRY_BASE = "https://registry.telo.run";
3
+ const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
4
4
 
5
5
  export class RegistryAdapter implements ManifestAdapter {
6
+ constructor(private registryUrl = DEFAULT_REGISTRY_URL) {}
7
+
6
8
  supports(url: string): boolean {
7
9
  return (
8
10
  !url.startsWith("http://") &&
@@ -26,18 +28,38 @@ export class RegistryAdapter implements ManifestAdapter {
26
28
  }
27
29
 
28
30
  resolveRelative(base: string, relative: string): string {
29
- const baseUrl = this.supports(base)
30
- ? this.toRegistryUrl(base).replace("/module.yaml", "")
31
- : base;
31
+ const baseUrl = this.supports(base) ? this.toRegistryModuleBase(base) : base;
32
32
  const baseWithSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
33
33
  return new URL(relative, baseWithSlash).href;
34
34
  }
35
35
 
36
+ private toRegistryModuleBase(moduleRef: string): string {
37
+ const parsed = this.parseModuleRef(moduleRef);
38
+ const normalizedBase = this.registryUrl.replace(/\/+$/, "");
39
+ return `${normalizedBase}/${parsed.modulePath}/${parsed.version}`;
40
+ }
41
+
36
42
  private toRegistryUrl(moduleRef: string): string {
43
+ return `${this.toRegistryModuleBase(moduleRef)}/module.yaml`;
44
+ }
45
+
46
+ private parseModuleRef(moduleRef: string): { modulePath: string; version: string } {
37
47
  const atIdx = moduleRef.lastIndexOf("@");
48
+ if (atIdx <= 0 || atIdx === moduleRef.length - 1) {
49
+ throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
50
+ }
51
+
38
52
  const modulePath = moduleRef.slice(0, atIdx);
39
- const version = moduleRef.slice(atIdx + 1);
40
- const versionSegment = version.startsWith("v") ? version.substring(1) : version;
41
- return `${REGISTRY_BASE}/${modulePath}/${versionSegment}/module.yaml`;
53
+ if (!modulePath.includes("/")) {
54
+ throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
55
+ }
56
+
57
+ const rawVersion = moduleRef.slice(atIdx + 1);
58
+ const version = rawVersion.startsWith("v") ? rawVersion.substring(1) : rawVersion;
59
+ if (!version) {
60
+ throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
61
+ }
62
+
63
+ return { modulePath, version };
42
64
  }
43
65
  }
@@ -61,6 +61,16 @@ export class AnalysisRegistry {
61
61
  return KERNEL_BUILTINS;
62
62
  }
63
63
 
64
+ resolveDefinition(kind: string): ResourceDefinition | undefined {
65
+ const ctx = this._context();
66
+ const resolved = ctx.aliases?.resolveKind(kind);
67
+ return ctx.definitions?.resolve(kind) ?? (resolved ? ctx.definitions?.resolve(resolved) : undefined);
68
+ }
69
+
70
+ allKinds(): string[] {
71
+ return this._context().definitions?.kinds() ?? [];
72
+ }
73
+
64
74
  /** @internal Bridge for StaticAnalyzer — do not use outside the analyzer package. */
65
75
  _context(): AnalysisContext {
66
76
  return { aliases: this.aliases, definitions: this.defs };
package/src/analyzer.ts CHANGED
@@ -6,15 +6,18 @@ import { DefinitionRegistry } from "./definition-registry.js";
6
6
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
7
7
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
8
8
  import {
9
- type SchemaIssue,
10
9
  celTypeSatisfiesJsonSchema,
11
10
  substituteCelFields,
12
11
  validateAgainstSchema,
12
+ type SchemaIssue,
13
13
  } from "./schema-compat.js";
14
14
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
15
15
  import {
16
16
  extractAccessChains,
17
+ getManifestItem,
17
18
  pathMatchesScope,
19
+ resolveContextAnnotations,
20
+ resolveTypeFieldToSchema,
18
21
  validateChainAgainstSchema,
19
22
  } from "./validate-cel-context.js";
20
23
  import { validateReferences } from "./validate-references.js";
@@ -78,6 +81,87 @@ function extractContextsFromSchema(
78
81
  return results;
79
82
  }
80
83
 
84
+ /**
85
+ * Build a `steps` context schema from `x-telo-step-context` annotation.
86
+ * Walks each step in the manifest array, resolves the invoked resource's outputType,
87
+ * and builds `steps.<name>.result` context entries.
88
+ */
89
+ function buildStepContextSchema(
90
+ manifest: Record<string, any>,
91
+ defSchema: Record<string, any>,
92
+ allManifests: Record<string, any>[],
93
+ ): Record<string, any> | undefined {
94
+ const props = defSchema.properties as Record<string, any> | undefined;
95
+ if (!props) return undefined;
96
+
97
+ for (const [fieldName, fieldSchema] of Object.entries(props)) {
98
+ const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
99
+ if (!stepCtx) continue;
100
+
101
+ const invokeField = stepCtx.invoke;
102
+ const outputTypeField = stepCtx.outputType;
103
+ if (!invokeField || !outputTypeField) continue;
104
+
105
+ const steps = manifest[fieldName];
106
+ if (!Array.isArray(steps)) continue;
107
+
108
+ const stepProperties: Record<string, any> = {};
109
+ const collectSteps = (items: unknown[]) => {
110
+ for (const step of items) {
111
+ if (!step || typeof step !== "object") continue;
112
+ const s = step as Record<string, any>;
113
+ const name = s.name;
114
+ if (typeof name === "string") {
115
+ const invoke = s[invokeField] as Record<string, any> | undefined;
116
+ let outputSchema: Record<string, any> | undefined;
117
+ if (invoke && typeof invoke === "object") {
118
+ const invokedKind = invoke.kind as string | undefined;
119
+ const invokedName = invoke.name as string | undefined;
120
+ if (invokedName) {
121
+ const invokedManifest = allManifests.find(
122
+ (m) =>
123
+ (m.metadata as any)?.name === invokedName &&
124
+ (!invokedKind || m.kind === invokedKind),
125
+ ) as Record<string, any> | undefined;
126
+ if (invokedManifest) {
127
+ outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
128
+ }
129
+ } else {
130
+ outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
131
+ }
132
+ }
133
+ stepProperties[name] = {
134
+ type: "object",
135
+ properties: {
136
+ result: outputSchema ?? { type: "object", additionalProperties: true },
137
+ },
138
+ };
139
+ }
140
+ // Recurse into nested step arrays (then, else, do, catch, finally, try, default, cases)
141
+ for (const nested of ["then", "else", "do", "catch", "finally", "try", "default"]) {
142
+ if (Array.isArray(s[nested])) collectSteps(s[nested]);
143
+ }
144
+ // cases is an object map of arrays
145
+ if (s.cases && typeof s.cases === "object") {
146
+ for (const arr of Object.values(s.cases)) {
147
+ if (Array.isArray(arr)) collectSteps(arr);
148
+ }
149
+ }
150
+ }
151
+ };
152
+ collectSteps(steps);
153
+
154
+ if (Object.keys(stepProperties).length > 0) {
155
+ return {
156
+ type: "object",
157
+ properties: stepProperties,
158
+ };
159
+ }
160
+ }
161
+
162
+ return undefined;
163
+ }
164
+
81
165
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
82
166
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
83
167
 
@@ -132,7 +216,14 @@ function collectCelTypeIssues(
132
216
  const itemSchema = (schema.items ?? {}) as Record<string, any>;
133
217
  for (let i = 0; i < data.length; i++) {
134
218
  issues.push(
135
- ...collectCelTypeIssues(data[i], itemSchema, `${path}[${i}]`, definition, manifest, baseEnv),
219
+ ...collectCelTypeIssues(
220
+ data[i],
221
+ itemSchema,
222
+ `${path}[${i}]`,
223
+ definition,
224
+ manifest,
225
+ baseEnv,
226
+ ),
136
227
  );
137
228
  }
138
229
  } else if (data !== null && typeof data === "object") {
@@ -310,6 +401,15 @@ export class StaticAnalyzer {
310
401
  const mDefinition =
311
402
  defs.resolve(m.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
312
403
 
404
+ // Pre-compute step context for manifests with x-telo-step-context
405
+ const stepContextSchema = mDefinition?.schema
406
+ ? buildStepContextSchema(
407
+ m as Record<string, any>,
408
+ mDefinition.schema as Record<string, any>,
409
+ allManifests as Record<string, any>[],
410
+ )
411
+ : undefined;
412
+
313
413
  walkCelExpressions(m, "", (expr, path) => {
314
414
  let parsed: ReturnType<typeof celEnvironment.parse> | undefined;
315
415
  try {
@@ -325,24 +425,52 @@ export class StaticAnalyzer {
325
425
  return;
326
426
  }
327
427
 
428
+ const accessChains = extractAccessChains(parsed.ast);
429
+
328
430
  const contexts = mDefinition?.schema ? extractContextsFromSchema(mDefinition.schema) : [];
329
431
  const invocationContext = (m.metadata as any)?.xTeloInvocationContext as
330
432
  | Record<string, any>
331
433
  | undefined;
332
- if (contexts.length === 0 && !invocationContext) return;
434
+
435
+ // If no static context but we have step context, inject it
436
+ if (contexts.length === 0 && !invocationContext && !stepContextSchema) return;
333
437
 
334
438
  let matchedContext: Record<string, any> | undefined;
439
+ let matchedScope: string | undefined;
335
440
  for (const ctx of contexts) {
336
441
  if (pathMatchesScope(path, ctx.scope)) {
337
442
  matchedContext = ctx.schema;
443
+ matchedScope = ctx.scope;
338
444
  break;
339
445
  }
340
446
  }
341
447
  if (!matchedContext) matchedContext = invocationContext;
448
+
449
+ // Merge step context into the effective context
450
+ if (stepContextSchema) {
451
+ const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
452
+ matchedContext = {
453
+ ...base,
454
+ properties: {
455
+ ...(base.properties ?? {}),
456
+ steps: stepContextSchema,
457
+ },
458
+ };
459
+ }
460
+
342
461
  if (!matchedContext) return;
343
462
 
344
- for (const chain of extractAccessChains(parsed.ast)) {
345
- const err = validateChainAgainstSchema(chain, matchedContext);
463
+ const manifestItem = matchedScope
464
+ ? getManifestItem(path, matchedScope, m as Record<string, any>)
465
+ : (m as Record<string, any>);
466
+ const effectiveContext = resolveContextAnnotations(
467
+ matchedContext,
468
+ manifestItem,
469
+ allManifests as Record<string, any>[],
470
+ );
471
+
472
+ for (const chain of accessChains) {
473
+ const err = validateChainAgainstSchema(chain, effectiveContext);
346
474
  if (!err) continue;
347
475
  diagnostics.push({
348
476
  severity: DiagnosticSeverity.Error,
package/src/index.ts CHANGED
@@ -7,9 +7,7 @@ export { Loader } from "./manifest-loader.js";
7
7
  export { DiagnosticSeverity } from "./types.js";
8
8
  export type {
9
9
  AnalysisDiagnostic,
10
- AnalysisOptions,
11
- LoadOptions,
12
- ManifestAdapter,
10
+ AnalysisOptions, LoaderInitOptions, LoadOptions, ManifestAdapter,
13
11
  Position,
14
12
  PositionIndex,
15
13
  Range
@@ -3,13 +3,32 @@ import { isMap, isPair, isScalar, isSeq, parseAllDocuments, type Document } from
3
3
  import { HttpAdapter } from "./adapters/http-adapter.js";
4
4
  import { RegistryAdapter } from "./adapters/registry-adapter.js";
5
5
  import { precompileDoc } from "./precompile.js";
6
- import type { LoadOptions, ManifestAdapter, Position, PositionIndex } from "./types.js";
6
+ import type {
7
+ LoadOptions,
8
+ LoaderInitOptions,
9
+ ManifestAdapter,
10
+ Position,
11
+ PositionIndex,
12
+ } from "./types.js";
7
13
 
8
14
  export class Loader {
9
- protected adapters: ManifestAdapter[] = [new HttpAdapter(), new RegistryAdapter()];
15
+ protected adapters: ManifestAdapter[];
10
16
 
11
- constructor(extraAdapters: ManifestAdapter[] = []) {
12
- this.adapters.unshift(...extraAdapters);
17
+ constructor(extraAdaptersOrOptions: ManifestAdapter[] | LoaderInitOptions = []) {
18
+ const options: LoaderInitOptions = Array.isArray(extraAdaptersOrOptions)
19
+ ? { extraAdapters: extraAdaptersOrOptions }
20
+ : extraAdaptersOrOptions;
21
+
22
+ const includeHttpAdapter = options.includeHttpAdapter ?? true;
23
+ const includeRegistryAdapter = options.includeRegistryAdapter ?? true;
24
+
25
+ this.adapters = [];
26
+ if (includeHttpAdapter) this.adapters.push(new HttpAdapter());
27
+ if (includeRegistryAdapter) this.adapters.push(new RegistryAdapter(options.registryUrl));
28
+
29
+ if (options.extraAdapters?.length) {
30
+ this.adapters.unshift(...options.extraAdapters);
31
+ }
13
32
  }
14
33
 
15
34
  register(adapter: ManifestAdapter): this {
@@ -23,6 +42,11 @@ export class Loader {
23
42
  return a;
24
43
  }
25
44
 
45
+ async resolveEntryPoint(url: string): Promise<string> {
46
+ const { source } = await this.pick(url).read(url);
47
+ return source;
48
+ }
49
+
26
50
  async loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
27
51
  const { text, source } = await this.pick(url).read(url);
28
52
  const parsedDocuments = parseAllDocuments(text);
@@ -79,7 +103,16 @@ export class Loader {
79
103
  if (moduleName) {
80
104
  for (const manifest of resolved) {
81
105
  if (manifest.kind !== "Kernel.Module" && !manifest.metadata?.module) {
106
+ const pi = (manifest.metadata as any)?.positionIndex;
82
107
  manifest.metadata = { ...manifest.metadata, module: moduleName };
108
+ if (pi) {
109
+ Object.defineProperty(manifest.metadata, "positionIndex", {
110
+ value: pi,
111
+ enumerable: false,
112
+ writable: true,
113
+ configurable: true,
114
+ });
115
+ }
83
116
  }
84
117
  }
85
118
  }
@@ -87,6 +120,47 @@ export class Loader {
87
120
  return resolved;
88
121
  }
89
122
 
123
+ async loadModuleGraph(
124
+ entryUrl: string,
125
+ onError?: (url: string, error: Error) => void,
126
+ ): Promise<Map<string, ResourceManifest[]>> {
127
+ const visited = new Set<string>([entryUrl]);
128
+ const result = new Map<string, ResourceManifest[]>();
129
+
130
+ const entry = await this.loadModule(entryUrl);
131
+ result.set(entryUrl, entry);
132
+
133
+ const queue: ResourceManifest[] = [...entry];
134
+
135
+ while (queue.length > 0) {
136
+ const m = queue.shift()!;
137
+ if (m.kind !== "Kernel.Import") continue;
138
+ const importSource = (m as any).source as string | undefined;
139
+ if (!importSource) continue;
140
+ const base = (m.metadata as any)?.source ?? entryUrl;
141
+ const importUrl =
142
+ importSource.startsWith(".") || importSource.startsWith("/")
143
+ ? this.pick(base).resolveRelative(base, importSource)
144
+ : importSource;
145
+ if (visited.has(importUrl)) continue;
146
+ visited.add(importUrl);
147
+ let imported: ResourceManifest[];
148
+ try {
149
+ imported = await this.loadModule(importUrl);
150
+ } catch (err) {
151
+ const error = err instanceof Error ? err : new Error(String(err));
152
+ onError?.(importUrl, error);
153
+ continue;
154
+ }
155
+ result.set(importUrl, imported);
156
+ for (const im of imported) {
157
+ if (im.kind === "Kernel.Import") queue.push(im);
158
+ }
159
+ }
160
+
161
+ return result;
162
+ }
163
+
90
164
  async loadManifests(entryUrl: string): Promise<ResourceManifest[]> {
91
165
  const visited = new Set<string>([entryUrl]);
92
166
  const entry = await this.loadModule(entryUrl);
@@ -100,7 +174,10 @@ export class Loader {
100
174
  const importSource = (m as any).source as string | undefined;
101
175
  if (!importSource) continue;
102
176
  const base = (m.metadata as any)?.source ?? entryUrl;
103
- const importUrl = this.pick(base).resolveRelative(base, importSource);
177
+ const importUrl =
178
+ importSource.startsWith(".") || importSource.startsWith("/")
179
+ ? this.pick(base).resolveRelative(base, importSource)
180
+ : importSource;
104
181
  if (visited.has(importUrl)) continue;
105
182
  visited.add(importUrl);
106
183
  let imported: ResourceManifest[];
@@ -113,11 +190,20 @@ export class Loader {
113
190
  }
114
191
  const importedModule = imported.find((im) => im.kind === "Kernel.Module");
115
192
  if (importedModule?.metadata?.name) {
193
+ const pi = (m.metadata as any)?.positionIndex;
116
194
  m.metadata = {
117
195
  ...m.metadata,
118
196
  resolvedModuleName: importedModule.metadata.name as string,
119
197
  resolvedNamespace: (importedModule.metadata as any).namespace ?? null,
120
198
  };
199
+ if (pi) {
200
+ Object.defineProperty(m.metadata, "positionIndex", {
201
+ value: pi,
202
+ enumerable: false,
203
+ writable: true,
204
+ configurable: true,
205
+ });
206
+ }
121
207
  }
122
208
  for (const im of imported) {
123
209
  if (im.kind === "Kernel.Definition") importedDefs.push(im);