@telorun/analyzer 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +39 -8
  3. package/dist/builtins.d.ts.map +1 -1
  4. package/dist/builtins.js +64 -1
  5. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  6. package/dist/flatten-for-analyzer.js +8 -4
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/inline-imports.d.ts +34 -0
  11. package/dist/inline-imports.d.ts.map +1 -0
  12. package/dist/inline-imports.js +106 -0
  13. package/dist/manifest-loader.d.ts +10 -1
  14. package/dist/manifest-loader.d.ts.map +1 -1
  15. package/dist/manifest-loader.js +45 -22
  16. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  17. package/dist/resolve-ref-sentinels.js +3 -11
  18. package/dist/types.d.ts +9 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/validate-references.d.ts.map +1 -1
  21. package/dist/validate-references.js +25 -22
  22. package/package.json +5 -4
  23. package/src/alias-resolver.ts +9 -0
  24. package/src/analyzer.ts +64 -5
  25. package/src/builtins.ts +64 -0
  26. package/src/flatten-for-analyzer.ts +20 -0
  27. package/src/index.ts +2 -0
  28. package/src/inline-imports.ts +130 -0
  29. package/src/manifest-loader.ts +52 -21
  30. package/src/resolve-ref-sentinels.ts +89 -44
  31. package/src/types.ts +9 -0
  32. package/src/validate-references.ts +103 -8
  33. package/dist/adapters/http-adapter.d.ts +0 -10
  34. package/dist/adapters/http-adapter.d.ts.map +0 -1
  35. package/dist/adapters/http-adapter.js +0 -18
  36. package/dist/adapters/node-adapter.d.ts +0 -17
  37. package/dist/adapters/node-adapter.d.ts.map +0 -1
  38. package/dist/adapters/node-adapter.js +0 -71
  39. package/dist/adapters/registry-adapter.d.ts +0 -15
  40. package/dist/adapters/registry-adapter.d.ts.map +0 -1
  41. package/dist/adapters/registry-adapter.js +0 -53
@@ -4,7 +4,6 @@ import { isInlineResource, resolveFieldEntries, resolveFieldValues } from "./ref
4
4
  import { navigateJsonPointer } from "./schema-compat.js";
5
5
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
6
6
  import { DiagnosticSeverity } from "./types.js";
7
- import { isModuleKind } from "./module-kinds.js";
8
7
  const SOURCE = "telo-analyzer";
9
8
  /**
10
9
  * Checks whether `kind` satisfies the ref constraint in `entry`.
@@ -99,33 +98,37 @@ export function validateReferences(resources, context) {
99
98
  // Forwarded foreign exports (an imported library's exported instances, carrying a
100
99
  // metadata.module that isn't a root module) are resolution TARGETS only: excluded from
101
100
  // duplicate detection and local name resolution, and never walked as ref sources.
102
- const rootModules = new Set();
103
- for (const r of resources) {
104
- if (isModuleKind(r.kind) && typeof r.metadata?.name === "string") {
105
- rootModules.add(r.metadata.name);
106
- }
107
- }
108
101
  const moduleOf = (r) => r.metadata?.module;
109
- const isForeign = (r) => {
110
- const mod = moduleOf(r);
111
- return typeof mod === "string" && !rootModules.has(mod);
112
- };
102
+ // Forwarded exports are flagged by flattenForAnalyzer (`metadata.forwardedExport`); they're
103
+ // cross-module resolution targets only — excluded from duplicate detection and local name
104
+ // resolution, and never walked as ref sources.
105
+ const isForeign = (r) => r.metadata?.forwardedExport === true;
106
+ // Forwarded exported instances keyed `${module}\0${name}` — the lookup that resolves
107
+ // whether a cross-module `!ref Alias.name` names a real exported instance.
113
108
  const byModuleName = new Map();
109
+ /** Modules whose import subtree was actually loaded in this analysis. A resolved
110
+ * `Telo.Import` carries `resolvedModuleName` (stamped only once the edge — and thus the
111
+ * imported module — resolved); forwarded exports carry `metadata.module`. Either marks
112
+ * the module loaded independent of how many instances it exports, so a loaded library
113
+ * that exports nothing still reports invalid cross-module refs, while partial single-file
114
+ * analysis (neither present) is skipped to avoid false `UNRESOLVED_REFERENCE`. */
115
+ const loadedModules = new Set();
114
116
  for (const r of resources) {
117
+ if (r.kind === "Telo.Import") {
118
+ const m = r.metadata?.resolvedModuleName;
119
+ if (typeof m === "string")
120
+ loadedModules.add(m);
121
+ continue;
122
+ }
115
123
  if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForeign(r))
116
124
  continue;
117
- byModuleName.set(`${moduleOf(r)}\0${r.metadata.name}`, r);
125
+ const m = moduleOf(r);
126
+ if (!m)
127
+ continue;
128
+ byModuleName.set(`${m}\0${r.metadata.name}`, r);
129
+ loadedModules.add(m);
118
130
  }
119
- /** Whether the analysis set actually carries the named module's exported instances.
120
- * False in partial single-file analysis (the import subtree wasn't loaded) — used to
121
- * skip cross-module resolution checks rather than emit false `UNRESOLVED_REFERENCE`. */
122
- const moduleLoaded = (module) => {
123
- const prefix = `${module}\0`;
124
- for (const key of byModuleName.keys())
125
- if (key.startsWith(prefix))
126
- return true;
127
- return false;
128
- };
131
+ const moduleLoaded = (module) => loadedModules.has(module);
129
132
  const localResources = resources.filter((r) => !isForeign(r));
130
133
  const byNameAll = new Map();
131
134
  const seen = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -42,15 +42,16 @@
42
42
  "ajv-formats": "^3.0.1",
43
43
  "jsonpath-plus": "^10.3.0",
44
44
  "yaml": "^2.8.3",
45
- "@telorun/templating": "0.4.0"
45
+ "@telorun/templating": "0.4.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
- "vitest": "^2.1.8"
50
+ "vitest": "^2.1.8",
51
+ "@telorun/sdk": "0.17.0"
51
52
  },
52
53
  "peerDependencies": {
53
- "@telorun/sdk": "0.13.0"
54
+ "@telorun/sdk": "*"
54
55
  },
55
56
  "scripts": {
56
57
  "build": "tsc -p tsconfig.lib.json",
@@ -11,6 +11,15 @@ export class AliasResolver {
11
11
  }
12
12
  }
13
13
 
14
+ /** Real module name an alias points at (e.g. "Console" → "console"), or undefined.
15
+ * Used to resolve an alias-qualified instance reference "Console.writeLine" to the
16
+ * forwarded resource declared in that module. The `exports.resources` gate is enforced
17
+ * upstream by `flattenForAnalyzer` (only exported instances are forwarded), so a name
18
+ * that isn't exported simply won't be found. */
19
+ moduleForAlias(alias: string): string | undefined {
20
+ return this.importAliases.get(alias);
21
+ }
22
+
14
23
  /** Resolves "Http.Api" → "http-server.Api". Returns undefined if alias is unknown. */
15
24
  resolveKind(kind: string): string | undefined {
16
25
  if (!kind) {
package/src/analyzer.ts CHANGED
@@ -621,6 +621,11 @@ export class StaticAnalyzer {
621
621
  }
622
622
  }
623
623
  const aliasesByModule = ctx?.aliasesByModule ?? new Map<string, AliasResolver>();
624
+ // Per-module-scope seen aliases for DUPLICATE_IMPORT_ALIAS. Authored
625
+ // Telo.Import docs and synthetic-from-inline-`imports:` share one alias
626
+ // namespace per module, so a repeat — across either form — is an error
627
+ // rather than the silent last-writer-wins the resolver would otherwise do.
628
+ const seenAliasByScope = new Map<string, Set<string>>();
624
629
  for (const m of manifests) {
625
630
  if (isModuleKind(m.kind)) {
626
631
  const namespace = ((m.metadata as any).namespace as string | undefined) ?? null;
@@ -633,16 +638,19 @@ export class StaticAnalyzer {
633
638
  // through the same alias machinery as user-declared Telo.Imports —
634
639
  // honours the library's `exports.kinds` list, no special cases.
635
640
  if (moduleName) {
636
- const exportedKinds: string[] = ((m as any).exports?.kinds as string[] | undefined) ?? [];
641
+ // `Self` resolves the library's own kinds UNGATED a library may reference
642
+ // its own kinds regardless of `exports.kinds`, which gates importers, not
643
+ // internal use. This is what lets a library declare an instance of a kind it
644
+ // does not export (e.g. console's `writeLine`) to enforce a singleton.
637
645
  if (rootModules.has(moduleName)) {
638
- aliases.registerImport("Self", moduleName, exportedKinds);
646
+ aliases.registerImport("Self", moduleName, []);
639
647
  } else {
640
648
  let libResolver = aliasesByModule.get(moduleName);
641
649
  if (!libResolver) {
642
650
  libResolver = new AliasResolver();
643
651
  aliasesByModule.set(moduleName, libResolver);
644
652
  }
645
- libResolver.registerImport("Self", moduleName, exportedKinds);
653
+ libResolver.registerImport("Self", moduleName, []);
646
654
  }
647
655
  }
648
656
  }
@@ -656,6 +664,35 @@ export class StaticAnalyzer {
656
664
  | null
657
665
  | undefined;
658
666
  const ownModule = (m.metadata as { module?: string } | undefined)?.module;
667
+ if (alias) {
668
+ const scopeKey = ownModule ?? "";
669
+ let seen = seenAliasByScope.get(scopeKey);
670
+ if (!seen) {
671
+ seen = new Set<string>();
672
+ seenAliasByScope.set(scopeKey, seen);
673
+ }
674
+ if (seen.has(alias)) {
675
+ diagnostics.push({
676
+ severity: DiagnosticSeverity.Error,
677
+ code: "DUPLICATE_IMPORT_ALIAS",
678
+ source: SOURCE,
679
+ message:
680
+ `Duplicate import alias '${alias}'. An alias may be declared once per module — ` +
681
+ `across both inline 'imports:' entries and 'Telo.Import' documents. ` +
682
+ `Rename or remove the duplicate.`,
683
+ data: {
684
+ resource: { kind: "Telo.Import", name: alias },
685
+ filePath: (m.metadata as { source?: string } | undefined)?.source,
686
+ path: "metadata.name",
687
+ },
688
+ });
689
+ // Keep the first alias→target mapping intact; don't re-register the
690
+ // duplicate (last-writer-wins would shadow the original and cascade
691
+ // misleading follow-on diagnostics).
692
+ continue;
693
+ }
694
+ seen.add(alias);
695
+ }
659
696
  if (alias && source) {
660
697
  const targetModule =
661
698
  resolvedModuleName ?? source.split("/").filter(Boolean).pop() ?? source;
@@ -825,6 +862,15 @@ export class StaticAnalyzer {
825
862
  continue;
826
863
  }
827
864
 
865
+ // Forwarded exports (flagged by flattenForAnalyzer) are an imported library's exported
866
+ // instances, already validated in their own module's standalone analysis; their
867
+ // `kind`/CEL are authored in that module's scope (e.g. `Self.X` → that module, not the
868
+ // consumer). Re-validating against the consumer's scope yields false UNDEFINED_KIND /
869
+ // scope-mismatch errors, so skip — they participate here only as resolution targets.
870
+ if ((m.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true) {
871
+ continue;
872
+ }
873
+
828
874
  const resource = { kind: m.kind, name: m.metadata?.name as string };
829
875
 
830
876
  // Resolve kind through alias if needed; direct lookup takes priority so that
@@ -1196,7 +1242,14 @@ export class StaticAnalyzer {
1196
1242
  );
1197
1243
  }
1198
1244
 
1199
- normalize(manifests: ResourceManifest[], registry: AnalysisRegistry): ResourceManifest[] {
1245
+ normalize(
1246
+ manifests: ResourceManifest[],
1247
+ registry: AnalysisRegistry,
1248
+ // Forwarded foreign exports used only as cross-module resolution targets (see
1249
+ // resolveRefSentinels). The kernel passes its analyzer-flattened set so the
1250
+ // entry-only runtime pass can still resolve `!ref Alias.name`.
1251
+ crossModuleTargets?: ResourceManifest[],
1252
+ ): ResourceManifest[] {
1200
1253
  const ctx = registry._context();
1201
1254
  const normalized = normalizeInlineResources(
1202
1255
  manifests,
@@ -1207,7 +1260,13 @@ export class StaticAnalyzer {
1207
1260
  // Resolve !ref sentinels after normalize so both the original and
1208
1261
  // inline-extracted manifests get their refs canonicalized to
1209
1262
  // {kind, name} for the kernel that consumes this output.
1210
- resolveRefSentinels(normalized, ctx.definitions!, ctx.aliases, ctx.aliasesByModule);
1263
+ resolveRefSentinels(
1264
+ normalized,
1265
+ ctx.definitions!,
1266
+ ctx.aliases,
1267
+ ctx.aliasesByModule,
1268
+ crossModuleTargets ?? [],
1269
+ );
1211
1270
  return normalized;
1212
1271
  }
1213
1272
 
package/src/builtins.ts CHANGED
@@ -301,6 +301,37 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
301
301
  type: "array",
302
302
  items: { type: "string" },
303
303
  },
304
+ // Inline imports — name-keyed map sugar for separate `Telo.Import`
305
+ // documents. The key is the PascalCase alias (the import's
306
+ // `metadata.name`). Each value is either a bare source string
307
+ // (shorthand for `{ source }`) or the full object form. The loader
308
+ // desugars each entry into a synthetic `Telo.Import` before discovery;
309
+ // authored `Telo.Import` docs keep working alongside this. See
310
+ // analyzer/nodejs/src/inline-imports.ts.
311
+ imports: {
312
+ type: "object",
313
+ additionalProperties: {
314
+ oneOf: [
315
+ { type: "string" },
316
+ {
317
+ type: "object",
318
+ required: ["source"],
319
+ properties: {
320
+ source: { type: "string" },
321
+ variables: { type: "object" },
322
+ secrets: { type: "object" },
323
+ runtime: {
324
+ oneOf: [
325
+ { type: "string" },
326
+ { type: "array", items: { type: "string" } },
327
+ ],
328
+ },
329
+ },
330
+ additionalProperties: false,
331
+ },
332
+ ],
333
+ },
334
+ },
304
335
  // Application-level environment contract. Each entry layers `env:`
305
336
  // (required, names the source env var) and `default:` (optional, used
306
337
  // when the env var is unset) on top of an open JSON Schema property
@@ -394,10 +425,43 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
394
425
  type: "array",
395
426
  items: { type: "string" },
396
427
  },
428
+ // Inline imports — same name-keyed map sugar as Telo.Application; the
429
+ // loader desugars each entry into a synthetic Telo.Import. See the
430
+ // Application schema above and analyzer/nodejs/src/inline-imports.ts.
431
+ imports: {
432
+ type: "object",
433
+ additionalProperties: {
434
+ oneOf: [
435
+ { type: "string" },
436
+ {
437
+ type: "object",
438
+ required: ["source"],
439
+ properties: {
440
+ source: { type: "string" },
441
+ variables: { type: "object" },
442
+ secrets: { type: "object" },
443
+ runtime: {
444
+ oneOf: [
445
+ { type: "string" },
446
+ { type: "array", items: { type: "string" } },
447
+ ],
448
+ },
449
+ },
450
+ additionalProperties: false,
451
+ },
452
+ ],
453
+ },
454
+ },
397
455
  exports: {
398
456
  type: "object",
399
457
  properties: {
400
458
  kinds: { type: "array", items: { type: "string" } },
459
+ // `variables` / `secrets` are reserved on the resources.<Alias> value-flow
460
+ // surface, so a library may not export instances under those names.
461
+ resources: {
462
+ type: "array",
463
+ items: { type: "string", not: { enum: ["variables", "secrets"] } },
464
+ },
401
465
  },
402
466
  additionalProperties: true,
403
467
  },
@@ -44,6 +44,10 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
44
44
  if (!targetModule) continue;
45
45
 
46
46
  const stamped = collectModuleManifests(targetModule);
47
+ const libDoc = stamped.find((m) => isModuleKind(m.kind));
48
+ const exportedResources = new Set<string>(
49
+ (libDoc as { exports?: { resources?: string[] } } | undefined)?.exports?.resources ?? [],
50
+ );
47
51
  for (const m of stamped) {
48
52
  if (
49
53
  m.kind === "Telo.Definition" ||
@@ -51,6 +55,22 @@ export function flattenForAnalyzer(graph: LoadedGraph): ResourceManifest[] {
51
55
  m.kind === "Telo.Import"
52
56
  ) {
53
57
  result.push(m);
58
+ } else if (
59
+ !isModuleKind(m.kind) &&
60
+ typeof m.metadata?.name === "string" &&
61
+ exportedResources.has(m.metadata.name as string)
62
+ ) {
63
+ // Forward instances listed in the library's `exports.resources` so the
64
+ // consumer's analyzer can resolve, gate, kind-check, and draw topology edges
65
+ // for cross-module `!ref Alias.name`. The gate IS this forwarding — only
66
+ // exported names cross the boundary. `metadata.forwardedExport` marks them as
67
+ // cross-module resolution targets only (keyed by `metadata.module`), so ref
68
+ // resolution / validation treats them as targets, never as local sources to
69
+ // re-validate or walk.
70
+ result.push({
71
+ ...m,
72
+ metadata: { ...m.metadata, forwardedExport: true } as ResourceManifest["metadata"],
73
+ });
54
74
  }
55
75
  }
56
76
  }
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
25
25
  export type { ModuleKind } from "./module-kinds.js";
26
26
  export { parseLoadedFile } from "./parse-loaded-file.js";
27
27
  export type { ParseOptions } from "./parse-loaded-file.js";
28
+ export { desugarLoadedFile, inlineImportManifests } from "./inline-imports.js";
29
+ export type { SyntheticImport } from "./inline-imports.js";
28
30
  export { residualEntrySchema, residualEntrySchemaMap } from "./residual-schema.js";
29
31
  export {
30
32
  buildDocumentPositions,
@@ -0,0 +1,130 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { LoadedFile } from "./loaded-types.js";
3
+ import { isModuleKind } from "./module-kinds.js";
4
+ import type { DocumentPosition } from "./position-metadata.js";
5
+ import type { PositionIndex } from "./types.js";
6
+
7
+ /** A synthetic `Telo.Import` produced by desugaring an `imports:` map entry,
8
+ * paired with the position metadata that pins its diagnostics back to the
9
+ * authoring line in the module document. */
10
+ export interface SyntheticImport {
11
+ manifest: ResourceManifest;
12
+ position: DocumentPosition;
13
+ }
14
+
15
+ /**
16
+ * Desugar a module document's inline `imports:` map into synthetic
17
+ * `Telo.Import` manifests. Each entry value is either a bare source string
18
+ * (shorthand for `{ source }`) or the full object form carrying
19
+ * `variables` / `secrets` / `runtime`. Malformed entries (object without a
20
+ * string `source`) are skipped here — the module document's own schema
21
+ * validation reports them against the precise `imports.<Alias>.source` path.
22
+ *
23
+ * The synthetic manifests are indistinguishable from authored `Telo.Import`
24
+ * documents downstream (alias registration, discovery, the kernel's
25
+ * import-controller), so the feature is purely additive at the declaration
26
+ * site. Pure and browser-safe — no I/O, no Node built-ins.
27
+ */
28
+ export function inlineImportManifests(
29
+ moduleManifest: ResourceManifest,
30
+ modulePosition: DocumentPosition | undefined,
31
+ ): SyntheticImport[] {
32
+ const raw = (moduleManifest as { imports?: unknown }).imports;
33
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
34
+
35
+ const out: SyntheticImport[] = [];
36
+ for (const [alias, value] of Object.entries(raw as Record<string, unknown>)) {
37
+ const scalar = typeof value === "string";
38
+ const entry = scalar
39
+ ? { source: value as string }
40
+ : value && typeof value === "object" && !Array.isArray(value)
41
+ ? (value as Record<string, unknown>)
42
+ : undefined;
43
+ if (!entry || typeof entry.source !== "string") continue;
44
+
45
+ const manifest = {
46
+ kind: "Telo.Import",
47
+ metadata: { name: alias },
48
+ source: entry.source,
49
+ ...(entry.variables !== undefined ? { variables: entry.variables } : {}),
50
+ ...(entry.secrets !== undefined ? { secrets: entry.secrets } : {}),
51
+ ...(entry.runtime !== undefined ? { runtime: entry.runtime } : {}),
52
+ } as unknown as ResourceManifest;
53
+
54
+ out.push({ manifest, position: synthPosition(modulePosition, alias, scalar) });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** Returns a copy of `file` with synthetic `Telo.Import` manifests (from the
60
+ * module document's inline `imports:` map) appended to `manifests` and
61
+ * `positions`. `documents` is intentionally left untouched: it is the raw
62
+ * YAML-AST array round-trip consumers pair by index, and a synthetic import
63
+ * has no backing node. Every flatten/discovery loop iterates `manifests` and
64
+ * indexes `positions[i]` — never `documents[i]` in lockstep — so the trailing
65
+ * synthetics are visible to resolution while the AST round-trip stays intact.
66
+ * Returns `file` unchanged when there is no module doc or no inline imports. */
67
+ export function desugarLoadedFile(file: LoadedFile): LoadedFile {
68
+ let moduleIndex = -1;
69
+ for (let i = 0; i < file.manifests.length; i++) {
70
+ const m = file.manifests[i];
71
+ if (m && isModuleKind(m.kind)) {
72
+ moduleIndex = i;
73
+ break;
74
+ }
75
+ }
76
+ if (moduleIndex < 0) return file;
77
+
78
+ const synthetic = inlineImportManifests(file.manifests[moduleIndex]!, file.positions[moduleIndex]);
79
+ if (synthetic.length === 0) return file;
80
+
81
+ return {
82
+ ...file,
83
+ manifests: [...file.manifests, ...synthetic.map((s) => s.manifest)],
84
+ positions: [...file.positions, ...synthetic.map((s) => s.position)],
85
+ };
86
+ }
87
+
88
+ /** Build a `DocumentPosition` for a synthetic import by re-rooting the module
89
+ * document's `imports.<Alias>` position subtree at the import manifest's own
90
+ * paths (`source`, `variables.*`, `metadata.name`, …). This makes a
91
+ * diagnostic on the synthetic's `source` land on the `imports:` entry's
92
+ * authoring line rather than a phantom document. */
93
+ function synthPosition(
94
+ modulePosition: DocumentPosition | undefined,
95
+ alias: string,
96
+ scalar: boolean,
97
+ ): DocumentPosition {
98
+ if (!modulePosition) return { sourceLine: 0, positionIndex: new Map() };
99
+
100
+ const base = modulePosition.positionIndex;
101
+ const index: PositionIndex = new Map();
102
+
103
+ const keyRange = base.get(`@key:imports.${alias}`);
104
+ const valueRange = base.get(`imports.${alias}`);
105
+
106
+ if (keyRange) {
107
+ index.set("metadata.name", keyRange);
108
+ index.set("@key:metadata.name", keyRange);
109
+ }
110
+
111
+ if (scalar) {
112
+ // `Console: std/console@1.2.3` — the entry value IS the source scalar.
113
+ if (valueRange) index.set("source", valueRange);
114
+ } else {
115
+ const valuePrefix = `imports.${alias}.`;
116
+ const keyPrefix = `@key:imports.${alias}.`;
117
+ for (const [path, range] of base) {
118
+ if (path.startsWith(valuePrefix)) {
119
+ index.set(path.slice(valuePrefix.length), range);
120
+ } else if (path.startsWith(keyPrefix)) {
121
+ index.set(`@key:${path.slice(keyPrefix.length)}`, range);
122
+ }
123
+ }
124
+ }
125
+
126
+ if (valueRange) index.set("", valueRange);
127
+
128
+ const sourceLine = (keyRange ?? valueRange)?.start.line ?? modulePosition.sourceLine;
129
+ return { sourceLine, positionIndex: index };
130
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  LoadedGraph,
11
11
  LoadedModule,
12
12
  } from "./loaded-types.js";
13
+ import { desugarLoadedFile } from "./inline-imports.js";
13
14
  import { isModuleKind } from "./module-kinds.js";
14
15
  import { parseLoadedFile } from "./parse-loaded-file.js";
15
16
  import {
@@ -26,6 +27,14 @@ const SYSTEM_KINDS = new Set([
26
27
  "Telo.Definition",
27
28
  ]);
28
29
 
30
+ /** File cache variant tags: compile (c/r) × desugarImports (d/n). A desugared
31
+ * and a raw load of the same file are distinct entries so neither sees the
32
+ * wrong manifest tree. */
33
+ const CACHE_VARIANTS = ["rn", "rd", "cn", "cd"] as const;
34
+ function variantKey(options?: LoadOptions): string {
35
+ return `${options?.compile ? "c" : "r"}${options?.desugarImports ? "d" : "n"}`;
36
+ }
37
+
29
38
  export class Loader {
30
39
  /** LoadedFile cache keyed by `${compile ? "compiled" : "raw"}:${source}`.
31
40
  * Same dual-keying as the legacy ResourceManifest[] cache: a compile-mode
@@ -100,29 +109,25 @@ export class Loader {
100
109
  * private mutable copy must call `parseLoadedFile` directly with the
101
110
  * LoadedFile's `text`. */
102
111
  async loadFile(url: string, options?: LoadOptions): Promise<LoadedFile> {
103
- const compileKey = options?.compile ? "compiled" : "raw";
112
+ const variant = variantKey(options);
104
113
  const knownSource = this.urlToSource.get(url);
105
114
  if (knownSource) {
106
- const cached = this.fileCache.get(`${compileKey}:${knownSource}`);
115
+ const cached = this.fileCache.get(`${variant}:${knownSource}`);
107
116
  if (cached) return cached;
108
- // The other compile-mode entry is cached — reparse from its text
117
+ // Another variant of this source is cached — reparse from its text
109
118
  // instead of re-reading the source.
110
119
  //
111
120
  // NOTE for watch-mode reactivation (cli/nodejs/src/commands/run.ts
112
121
  // currently has `setupWatchMode` commented out): this branch
113
122
  // assumes file contents don't change underneath a single Loader.
114
123
  // Reviving watch mode will need a public `invalidate(url)` (or
115
- // similar) that drops both `urlToSource[url]` and the cached
116
- // entries for its canonical source before the loader serves the
117
- // file again.
118
- const altKey = `${compileKey === "compiled" ? "raw" : "compiled"}:${knownSource}`;
119
- const alt = this.fileCache.get(altKey);
120
- if (alt) {
121
- const reparsed = parseLoadedFile(knownSource, url, alt.text, {
122
- compile: options?.compile,
123
- celEnv: this.celEnv,
124
- });
125
- this.fileCache.set(`${compileKey}:${knownSource}`, reparsed);
124
+ // similar) that drops both `urlToSource[url]` and every cached
125
+ // variant entry for its canonical source before the loader serves
126
+ // the file again.
127
+ const altText = this.findCachedText(knownSource);
128
+ if (altText !== undefined) {
129
+ const reparsed = this.parseAndMaybeDesugar(knownSource, url, altText, options);
130
+ this.fileCache.set(`${variant}:${knownSource}`, reparsed);
126
131
  return reparsed;
127
132
  }
128
133
  }
@@ -135,16 +140,41 @@ export class Loader {
135
140
  // for that exact URL — hit the urlToSource fast path instead of
136
141
  // falling through to a redundant `pick(url).read(url)`.
137
142
  this.urlToSource.set(source, source);
138
- const cacheKey = `${compileKey}:${source}`;
143
+ const cacheKey = `${variant}:${source}`;
139
144
  const cached = this.fileCache.get(cacheKey);
140
145
  if (cached && cached.text === text) return cached;
141
146
 
142
- const loaded = parseLoadedFile(source, url, text, {
147
+ const loaded = this.parseAndMaybeDesugar(source, url, text, options);
148
+ this.fileCache.set(cacheKey, loaded);
149
+ return loaded;
150
+ }
151
+
152
+ /** Parse `text` into a LoadedFile, then desugar inline `imports:` when the
153
+ * caller opted in. Desugaring lives here, not in the pure `parseLoadedFile`,
154
+ * so round-trip consumers (the editor) keep a raw manifest/AST/position
155
+ * triple they can pair by index; only resolved consumers that pass
156
+ * `desugarImports` see synthetic Telo.Import manifests. */
157
+ private parseAndMaybeDesugar(
158
+ source: string,
159
+ requestedUrl: string,
160
+ text: string,
161
+ options?: LoadOptions,
162
+ ): LoadedFile {
163
+ const loaded = parseLoadedFile(source, requestedUrl, text, {
143
164
  compile: options?.compile,
144
165
  celEnv: this.celEnv,
145
166
  });
146
- this.fileCache.set(cacheKey, loaded);
147
- return loaded;
167
+ return options?.desugarImports ? desugarLoadedFile(loaded) : loaded;
168
+ }
169
+
170
+ /** Raw text of any already-cached variant for `source`, so a cache miss on
171
+ * one (compile, desugar) variant reparses without a second source read. */
172
+ private findCachedText(source: string): string | undefined {
173
+ for (const v of CACHE_VARIANTS) {
174
+ const cached = this.fileCache.get(`${v}:${source}`);
175
+ if (cached) return cached.text;
176
+ }
177
+ return undefined;
148
178
  }
149
179
 
150
180
  /** Load an owner file plus every partial reachable through its `include:`
@@ -382,12 +412,13 @@ export class Loader {
382
412
  * via parent-directory traversal. */
383
413
  async loadGraphForFile(
384
414
  fileUrl: string,
415
+ options?: LoadOptions,
385
416
  ): Promise<{ graph: LoadedGraph; ownerUrl: string } | null> {
386
417
  try {
387
- const owner = await this.loadFile(fileUrl);
418
+ const owner = await this.loadFile(fileUrl, options);
388
419
  const isOwner = owner.manifests.some((m) => m && isModuleKind(m.kind));
389
420
  if (isOwner) {
390
- const graph = await this.loadGraph(fileUrl);
421
+ const graph = await this.loadGraph(fileUrl, options);
391
422
  return { graph, ownerUrl: graph.rootSource };
392
423
  }
393
424
  } catch (err) {
@@ -404,7 +435,7 @@ export class Loader {
404
435
  if (!source.resolveOwnerOf) return null;
405
436
  const ownerUrl = await source.resolveOwnerOf(fileUrl);
406
437
  if (!ownerUrl) return null;
407
- const graph = await this.loadGraph(ownerUrl);
438
+ const graph = await this.loadGraph(ownerUrl, options);
408
439
  return { graph, ownerUrl: graph.rootSource };
409
440
  }
410
441