@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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +39 -8
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +64 -1
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +8 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/inline-imports.d.ts +34 -0
- package/dist/inline-imports.d.ts.map +1 -0
- package/dist/inline-imports.js +106 -0
- package/dist/manifest-loader.d.ts +10 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +45 -22
- package/dist/resolve-ref-sentinels.d.ts.map +1 -1
- package/dist/resolve-ref-sentinels.js +3 -11
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +25 -22
- package/package.json +5 -4
- package/src/alias-resolver.ts +9 -0
- package/src/analyzer.ts +64 -5
- package/src/builtins.ts +64 -0
- package/src/flatten-for-analyzer.ts +20 -0
- package/src/index.ts +2 -0
- package/src/inline-imports.ts +130 -0
- package/src/manifest-loader.ts +52 -21
- package/src/resolve-ref-sentinels.ts +89 -44
- package/src/types.ts +9 -0
- package/src/validate-references.ts +103 -8
- package/dist/adapters/http-adapter.d.ts +0 -10
- package/dist/adapters/http-adapter.d.ts.map +0 -1
- package/dist/adapters/http-adapter.js +0 -18
- package/dist/adapters/node-adapter.d.ts +0 -17
- package/dist/adapters/node-adapter.d.ts.map +0 -1
- package/dist/adapters/node-adapter.js +0 -71
- package/dist/adapters/registry-adapter.d.ts +0 -15
- package/dist/adapters/registry-adapter.d.ts.map +0 -1
- 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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": "
|
|
54
|
+
"@telorun/sdk": "*"
|
|
54
55
|
},
|
|
55
56
|
"scripts": {
|
|
56
57
|
"build": "tsc -p tsconfig.lib.json",
|
package/src/alias-resolver.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/manifest-loader.ts
CHANGED
|
@@ -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
|
|
112
|
+
const variant = variantKey(options);
|
|
104
113
|
const knownSource = this.urlToSource.get(url);
|
|
105
114
|
if (knownSource) {
|
|
106
|
-
const cached = this.fileCache.get(`${
|
|
115
|
+
const cached = this.fileCache.get(`${variant}:${knownSource}`);
|
|
107
116
|
if (cached) return cached;
|
|
108
|
-
//
|
|
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
|
|
116
|
-
//
|
|
117
|
-
// file again.
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 = `${
|
|
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 =
|
|
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
|
-
|
|
147
|
-
|
|
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
|
|