@telorun/analyzer 0.10.1 → 0.12.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/LICENSE +2 -2
- package/README.md +3 -3
- package/dist/adapters/http-adapter.d.ts +10 -0
- package/dist/adapters/http-adapter.d.ts.map +1 -0
- package/dist/adapters/http-adapter.js +18 -0
- package/dist/adapters/node-adapter.d.ts +17 -0
- package/dist/adapters/node-adapter.d.ts.map +1 -0
- package/dist/adapters/node-adapter.js +71 -0
- package/dist/adapters/registry-adapter.d.ts +15 -0
- package/dist/adapters/registry-adapter.d.ts.map +1 -0
- package/dist/adapters/registry-adapter.js +53 -0
- package/dist/analysis-registry.d.ts +7 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +38 -0
- package/dist/analyzer.d.ts +15 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +268 -7
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +172 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +16 -0
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +27 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +9 -11
- package/dist/manifest-loader.d.ts +23 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +66 -3
- package/dist/normalize-inline-resources.d.ts.map +1 -1
- package/dist/normalize-inline-resources.js +26 -14
- package/dist/position-metadata.d.ts +11 -2
- package/dist/position-metadata.d.ts.map +1 -1
- package/dist/position-metadata.js +18 -3
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +9 -1
- package/dist/reference-field-map.d.ts +21 -4
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +93 -25
- package/dist/residual-schema.d.ts +23 -0
- package/dist/residual-schema.d.ts.map +1 -0
- package/dist/residual-schema.js +45 -0
- package/dist/resolve-ref-sentinels.d.ts +27 -0
- package/dist/resolve-ref-sentinels.d.ts.map +1 -0
- package/dist/resolve-ref-sentinels.js +114 -0
- package/dist/rewrite-synthetic-origins.d.ts +10 -0
- package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
- package/dist/rewrite-synthetic-origins.js +55 -0
- package/dist/schema-compat.d.ts +7 -1
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +19 -2
- package/dist/system-kinds.d.ts +25 -0
- package/dist/system-kinds.d.ts.map +1 -0
- package/dist/system-kinds.js +34 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-cel-context.d.ts +61 -7
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +90 -8
- package/dist/validate-provider-coherence.d.ts +23 -0
- package/dist/validate-provider-coherence.d.ts.map +1 -0
- package/dist/validate-provider-coherence.js +148 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +141 -36
- package/dist/with-synthetic-positions.d.ts +28 -0
- package/dist/with-synthetic-positions.d.ts.map +1 -0
- package/dist/with-synthetic-positions.js +45 -0
- package/package.json +7 -4
- package/src/analysis-registry.ts +37 -0
- package/src/analyzer.ts +313 -9
- package/src/builtins.ts +172 -1
- package/src/definition-registry.ts +15 -0
- package/src/dependency-graph.ts +27 -14
- package/src/index.ts +2 -0
- package/src/kernel-globals.ts +9 -11
- package/src/manifest-loader.ts +69 -4
- package/src/normalize-inline-resources.ts +48 -13
- package/src/position-metadata.ts +18 -3
- package/src/precompile.ts +8 -1
- package/src/reference-field-map.ts +129 -24
- package/src/residual-schema.ts +49 -0
- package/src/resolve-ref-sentinels.ts +127 -0
- package/src/rewrite-synthetic-origins.ts +75 -0
- package/src/schema-compat.ts +19 -2
- package/src/system-kinds.ts +37 -0
- package/src/types.ts +12 -0
- package/src/validate-cel-context.ts +111 -8
- package/src/validate-provider-coherence.ts +166 -0
- package/src/validate-references.ts +138 -35
- package/src/with-synthetic-positions.ts +48 -0
package/src/dependency-graph.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
2
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
4
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
5
|
import { isRefEntry, isScopeEntry, resolveFieldValues } from "./reference-field-map.js";
|
|
6
|
+
import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
5
7
|
|
|
6
8
|
export interface ResourceNode {
|
|
7
9
|
kind: string;
|
|
@@ -17,16 +19,6 @@ export interface DependencyGraph {
|
|
|
17
19
|
cycle?: ReadonlyArray<ResourceNode>;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
/** System resource kinds that are not runtime nodes in the dependency graph.
|
|
21
|
-
* Module-identity docs (Telo.Application, Telo.Library) are intentionally
|
|
22
|
-
* not in this set: an Application's `targets` use `x-telo-ref` to real
|
|
23
|
-
* Runnable/Service resources, so the Application legitimately depends on
|
|
24
|
-
* them in boot order — modeling that as a graph edge is correct. A Library
|
|
25
|
-
* has no `targets`, so it becomes a zero-edge node, which is harmless.
|
|
26
|
-
* If the graph is ever consumed as "things to init", skip these kinds at
|
|
27
|
-
* the consumer site; the controller already runs them separately. */
|
|
28
|
-
const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Import"]);
|
|
29
|
-
|
|
30
22
|
const nodeKey = (kind: string, name: string) => `${kind}\0${name}`;
|
|
31
23
|
|
|
32
24
|
/**
|
|
@@ -47,12 +39,18 @@ export function buildDependencyGraph(
|
|
|
47
39
|
aliases?: AliasResolver,
|
|
48
40
|
aliasesByModule?: Map<string, AliasResolver>,
|
|
49
41
|
): DependencyGraph {
|
|
50
|
-
// --- Build node set ---
|
|
42
|
+
// --- Build node set + name index ---
|
|
51
43
|
const nodes = new Map<string, ResourceNode>();
|
|
44
|
+
// Sentinel lookup (`!ref <name>`) needs to resolve a bare name to its
|
|
45
|
+
// declared kind. Names are unique within a manifest scope, so a flat
|
|
46
|
+
// map suffices and lets the sentinel branch below avoid a full
|
|
47
|
+
// O(N) scan of the node set on every reference.
|
|
48
|
+
const nodesByName = new Map<string, ResourceNode>();
|
|
52
49
|
for (const r of resources) {
|
|
53
50
|
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
54
|
-
const
|
|
55
|
-
nodes.set(
|
|
51
|
+
const node = { kind: r.kind, name: r.metadata.name as string };
|
|
52
|
+
nodes.set(nodeKey(node.kind, node.name), node);
|
|
53
|
+
nodesByName.set(node.name, node);
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
// --- Build adjacency: from → deps (from depends on dep) ---
|
|
@@ -90,7 +88,22 @@ export function buildDependencyGraph(
|
|
|
90
88
|
if (!isRefEntry(entry)) continue;
|
|
91
89
|
|
|
92
90
|
for (const val of resolveFieldValues(r, fieldPath)) {
|
|
93
|
-
if (!val
|
|
91
|
+
if (!val) continue;
|
|
92
|
+
|
|
93
|
+
// `!ref <name>` sentinel — look up the target's kind from the
|
|
94
|
+
// name (resources are unique by name) so the edge carries the
|
|
95
|
+
// concrete kind, matching the {kind, name} edge shape below.
|
|
96
|
+
if (isRefSentinel(val)) {
|
|
97
|
+
const refName = val.source;
|
|
98
|
+
if (scopedNames.has(refName)) continue;
|
|
99
|
+
const node = nodesByName.get(refName);
|
|
100
|
+
if (node) {
|
|
101
|
+
deps.get(sourceKey)!.add(nodeKey(node.kind, node.name));
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof val !== "object") continue;
|
|
94
107
|
const ref = val as Record<string, unknown>;
|
|
95
108
|
if (!ref.kind || !ref.name) continue;
|
|
96
109
|
// Edges to scoped resources are runtime deps, not boot-time deps — exclude from DAG
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
|
|
|
14
14
|
export type { ModuleKind } from "./module-kinds.js";
|
|
15
15
|
export { parseLoadedFile } from "./parse-loaded-file.js";
|
|
16
16
|
export type { ParseOptions } from "./parse-loaded-file.js";
|
|
17
|
+
export { residualEntrySchema, residualEntrySchemaMap } from "./residual-schema.js";
|
|
17
18
|
export {
|
|
18
19
|
buildDocumentPositions,
|
|
19
20
|
buildLineOffsets,
|
|
@@ -23,6 +24,7 @@ export {
|
|
|
23
24
|
export type { DocumentPosition } from "./position-metadata.js";
|
|
24
25
|
export { HttpSource } from "./sources/http-source.js";
|
|
25
26
|
export { RegistrySource } from "./sources/registry-source.js";
|
|
27
|
+
export { withSyntheticPositions } from "./with-synthetic-positions.js";
|
|
26
28
|
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
|
|
27
29
|
export type {
|
|
28
30
|
AnalysisDiagnostic,
|
package/src/kernel-globals.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { residualEntrySchemaMap } from "./residual-schema.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Kernel global names available in every CEL evaluation context at runtime.
|
|
@@ -72,20 +73,17 @@ export function buildKernelGlobalsSchema(
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** Wrap a JSON Schema property map (like `Telo.Application.variables`) into a
|
|
75
|
-
* closed object schema suitable for chain-access validation.
|
|
76
|
-
*
|
|
76
|
+
* closed object schema suitable for chain-access validation. For Application
|
|
77
|
+
* entries the per-entry shape carries kernel-specific keys (`env`, `default`)
|
|
78
|
+
* on top of an otherwise-standard JSON Schema property schema; those keys are
|
|
79
|
+
* stripped via `residualEntrySchemaMap` so CEL sees the coerced shape, not
|
|
80
|
+
* the env-binding wrapper. Library entries are pure JSON Schema property
|
|
81
|
+
* schemas and pass through the same call unchanged. Falls back to an open map
|
|
82
|
+
* when the module declares no variables/secrets. */
|
|
77
83
|
function buildSchemaMapSchema(
|
|
78
84
|
schemaMap: Record<string, any> | null | undefined,
|
|
79
85
|
): Record<string, any> {
|
|
80
|
-
|
|
81
|
-
return { type: "object", additionalProperties: true };
|
|
82
|
-
}
|
|
83
|
-
const props: Record<string, any> = {};
|
|
84
|
-
for (const [key, value] of Object.entries(schemaMap)) {
|
|
85
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
86
|
-
props[key] = value;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
86
|
+
const props = residualEntrySchemaMap(schemaMap);
|
|
89
87
|
if (Object.keys(props).length === 0) {
|
|
90
88
|
return { type: "object", additionalProperties: true };
|
|
91
89
|
}
|
package/src/manifest-loader.ts
CHANGED
|
@@ -33,6 +33,14 @@ export class Loader {
|
|
|
33
33
|
* get distinct entries, so neither sees the wrong manifest tree. */
|
|
34
34
|
private readonly fileCache = new Map<string, LoadedFile>();
|
|
35
35
|
|
|
36
|
+
/** requestUrl → canonical `source`. Lets `loadFile` skip the source read
|
|
37
|
+
* when a URL it has already canonicalised is requested again — kernel
|
|
38
|
+
* load → boot and the import-controller each ask the loader for the same
|
|
39
|
+
* modules. Without this fast path every duplicate request re-runs the
|
|
40
|
+
* source's `read()` (a `fetch` for `RegistrySource`, a disk read for
|
|
41
|
+
* `LocalFileSource`). */
|
|
42
|
+
private readonly urlToSource = new Map<string, string>();
|
|
43
|
+
|
|
36
44
|
protected sources: ManifestSource[];
|
|
37
45
|
private readonly celEnv: Environment;
|
|
38
46
|
|
|
@@ -67,8 +75,22 @@ export class Loader {
|
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
async resolveEntryPoint(url: string): Promise<string> {
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
// Route through `loadFile` so the resolved source URL and parsed
|
|
79
|
+
// entry are populated in `urlToSource` + `fileCache` in one read.
|
|
80
|
+
// Callers (kernel.load) immediately call `loadGraph(entryUrl)`
|
|
81
|
+
// afterwards — without this priming, the entry file would be read
|
|
82
|
+
// twice (twice over the network for `RegistrySource`).
|
|
83
|
+
const file = await this.loadFile(url);
|
|
84
|
+
return file.source;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Returns the canonical source URL the loader has already mapped `url`
|
|
88
|
+
* to during a prior `loadFile`/`loadModule`/`loadGraph` call, or
|
|
89
|
+
* `undefined` when the URL has not been seen. Callers use it to test
|
|
90
|
+
* set-membership against a previous graph walk's modules without
|
|
91
|
+
* triggering an extra source read. */
|
|
92
|
+
canonicalize(url: string): string | undefined {
|
|
93
|
+
return this.urlToSource.get(url);
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
// --- New API: returns LoadedFile / LoadedModule / LoadedGraph ----------
|
|
@@ -78,8 +100,42 @@ export class Loader {
|
|
|
78
100
|
* private mutable copy must call `parseLoadedFile` directly with the
|
|
79
101
|
* LoadedFile's `text`. */
|
|
80
102
|
async loadFile(url: string, options?: LoadOptions): Promise<LoadedFile> {
|
|
103
|
+
const compileKey = options?.compile ? "compiled" : "raw";
|
|
104
|
+
const knownSource = this.urlToSource.get(url);
|
|
105
|
+
if (knownSource) {
|
|
106
|
+
const cached = this.fileCache.get(`${compileKey}:${knownSource}`);
|
|
107
|
+
if (cached) return cached;
|
|
108
|
+
// The other compile-mode entry is cached — reparse from its text
|
|
109
|
+
// instead of re-reading the source.
|
|
110
|
+
//
|
|
111
|
+
// NOTE for watch-mode reactivation (cli/nodejs/src/commands/run.ts
|
|
112
|
+
// currently has `setupWatchMode` commented out): this branch
|
|
113
|
+
// assumes file contents don't change underneath a single Loader.
|
|
114
|
+
// 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);
|
|
126
|
+
return reparsed;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
81
130
|
const { text, source } = await this.pick(url).read(url);
|
|
82
|
-
|
|
131
|
+
this.urlToSource.set(url, source);
|
|
132
|
+
// Also map the canonical source to itself so subsequent `loadFile`
|
|
133
|
+
// calls that already received a canonical URL — `kernel.load` passes
|
|
134
|
+
// the result of `resolveEntryPoint` to `loadGraph`, which then asks
|
|
135
|
+
// for that exact URL — hit the urlToSource fast path instead of
|
|
136
|
+
// falling through to a redundant `pick(url).read(url)`.
|
|
137
|
+
this.urlToSource.set(source, source);
|
|
138
|
+
const cacheKey = `${compileKey}:${source}`;
|
|
83
139
|
const cached = this.fileCache.get(cacheKey);
|
|
84
140
|
if (cached && cached.text === text) return cached;
|
|
85
141
|
|
|
@@ -224,7 +280,16 @@ export class Loader {
|
|
|
224
280
|
return { rootSource, entry, modules, importEdges, errors };
|
|
225
281
|
}
|
|
226
282
|
|
|
227
|
-
|
|
283
|
+
/** Resolve an `import` URL against the file it appears in. Relative /
|
|
284
|
+
* absolute-path forms run through the owning `ManifestSource`'s
|
|
285
|
+
* `resolveRelative`; registry refs and full URLs pass through
|
|
286
|
+
* unchanged. Exposed so the import-controller (and any other
|
|
287
|
+
* caller-side resolver) lands on the *exact same* canonical URL the
|
|
288
|
+
* loader used when walking the entry graph — divergent resolution
|
|
289
|
+
* would silently break optimizations like `canonicalize()`-keyed
|
|
290
|
+
* cache hits whenever a non-trivial `ManifestSource.resolveRelative`
|
|
291
|
+
* is in play. */
|
|
292
|
+
resolveImportUrl(fromSource: string, importSource: string): string {
|
|
228
293
|
if (importSource.startsWith(".") || importSource.startsWith("/")) {
|
|
229
294
|
return this.pick(fromSource).resolveRelative(fromSource, importSource);
|
|
230
295
|
}
|
|
@@ -82,7 +82,14 @@ export function normalizeInlineResources(
|
|
|
82
82
|
);
|
|
83
83
|
|
|
84
84
|
const invocationContext = isRefEntry(entry) ? entry.context : undefined;
|
|
85
|
-
const extracted = extractInlinesAtPath(
|
|
85
|
+
const extracted = extractInlinesAtPath(
|
|
86
|
+
resource,
|
|
87
|
+
fieldPath,
|
|
88
|
+
parentName,
|
|
89
|
+
resource.kind,
|
|
90
|
+
parentModule,
|
|
91
|
+
invocationContext,
|
|
92
|
+
);
|
|
86
93
|
for (const manifest of extracted) {
|
|
87
94
|
result.push(manifest);
|
|
88
95
|
queue.push(manifest as ResourceManifest & { metadata: { name: string } });
|
|
@@ -99,18 +106,42 @@ export function normalizeInlineResources(
|
|
|
99
106
|
* Walks `resource` following `fieldPath` (dot notation, `[]` = array traversal).
|
|
100
107
|
* Mutates the resource in-place: replaces each inline value with `{kind, name}`.
|
|
101
108
|
* Returns the extracted manifests.
|
|
109
|
+
*
|
|
110
|
+
* Each extracted manifest carries `metadata.xTeloOrigin` so downstream
|
|
111
|
+
* diagnostics can be rerouted back to the parent doc's YAML position:
|
|
112
|
+
* - `parentKind` / `parentName` — the resource that owned the inline slot
|
|
113
|
+
* - `pathFromParent` — concrete dotted path with `[N]` indices, matching
|
|
114
|
+
* `buildPositionIndex` keys (e.g. `routes[0].handler`)
|
|
102
115
|
*/
|
|
103
116
|
function extractInlinesAtPath(
|
|
104
117
|
resource: ResourceManifest,
|
|
105
118
|
fieldPath: string,
|
|
106
119
|
parentName: string,
|
|
120
|
+
parentKind: string,
|
|
107
121
|
parentModule: string | undefined,
|
|
108
122
|
invocationContext?: Record<string, any>,
|
|
109
123
|
): ResourceManifest[] {
|
|
110
124
|
const extracted: ResourceManifest[] = [];
|
|
111
125
|
const parts = fieldPath.split(".");
|
|
112
126
|
|
|
113
|
-
function
|
|
127
|
+
function emit(
|
|
128
|
+
inline: Record<string, unknown>,
|
|
129
|
+
nameSegments: string[],
|
|
130
|
+
concretePath: string,
|
|
131
|
+
): string {
|
|
132
|
+
const name = sanitizeName([parentName, ...nameSegments].join("_"));
|
|
133
|
+
extracted.push(
|
|
134
|
+
buildManifest(inline, name, parentKind, parentName, concretePath, parentModule, invocationContext),
|
|
135
|
+
);
|
|
136
|
+
return name;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function traverse(
|
|
140
|
+
obj: unknown,
|
|
141
|
+
partsLeft: string[],
|
|
142
|
+
nameParts: string[],
|
|
143
|
+
pathSoFar: string,
|
|
144
|
+
): void {
|
|
114
145
|
if (!obj || typeof obj !== "object" || partsLeft.length === 0) return;
|
|
115
146
|
|
|
116
147
|
const [head, ...rest] = partsLeft;
|
|
@@ -123,15 +154,15 @@ function extractInlinesAtPath(
|
|
|
123
154
|
const elem = container[mapKey];
|
|
124
155
|
if (!elem || typeof elem !== "object") continue;
|
|
125
156
|
const sanitizedKey = sanitizeName(mapKey);
|
|
157
|
+
const childPath = pathSoFar ? `${pathSoFar}.${mapKey}` : mapKey;
|
|
126
158
|
|
|
127
159
|
if (rest.length === 0) {
|
|
128
160
|
if (isInlineResource(elem as Record<string, unknown>)) {
|
|
129
|
-
const name =
|
|
130
|
-
extracted.push(buildManifest(elem as Record<string, unknown>, name, parentModule, invocationContext));
|
|
161
|
+
const name = emit(elem as Record<string, unknown>, [...nameParts, sanitizedKey], childPath);
|
|
131
162
|
container[mapKey] = { kind: (elem as Record<string, unknown>).kind, name };
|
|
132
163
|
}
|
|
133
164
|
} else {
|
|
134
|
-
traverse(elem, rest, [...nameParts, sanitizedKey]);
|
|
165
|
+
traverse(elem, rest, [...nameParts, sanitizedKey], childPath);
|
|
135
166
|
}
|
|
136
167
|
}
|
|
137
168
|
return;
|
|
@@ -142,6 +173,7 @@ function extractInlinesAtPath(
|
|
|
142
173
|
const container = obj as Record<string, unknown>;
|
|
143
174
|
const val = container[key];
|
|
144
175
|
if (val == null) return;
|
|
176
|
+
const keyPath = pathSoFar ? `${pathSoFar}.${key}` : key;
|
|
145
177
|
|
|
146
178
|
if (isArr) {
|
|
147
179
|
if (!Array.isArray(val)) return;
|
|
@@ -152,39 +184,41 @@ function extractInlinesAtPath(
|
|
|
152
184
|
typeof (elem as Record<string, unknown>).name === "string"
|
|
153
185
|
? ((elem as Record<string, unknown>).name as string)
|
|
154
186
|
: String(idx);
|
|
187
|
+
const childPath = `${keyPath}[${idx}]`;
|
|
155
188
|
|
|
156
189
|
if (rest.length === 0) {
|
|
157
190
|
// Array element itself is the ref slot
|
|
158
191
|
if (isInlineResource(elem as Record<string, unknown>)) {
|
|
159
|
-
const name =
|
|
160
|
-
extracted.push(buildManifest(elem as Record<string, unknown>, name, parentModule, invocationContext));
|
|
192
|
+
const name = emit(elem as Record<string, unknown>, [...nameParts, key, elemId], childPath);
|
|
161
193
|
val[idx] = { kind: (elem as Record<string, unknown>).kind, name };
|
|
162
194
|
}
|
|
163
195
|
} else {
|
|
164
|
-
traverse(elem, rest, [...nameParts, key, elemId]);
|
|
196
|
+
traverse(elem, rest, [...nameParts, key, elemId], childPath);
|
|
165
197
|
}
|
|
166
198
|
}
|
|
167
199
|
} else {
|
|
168
200
|
if (rest.length === 0) {
|
|
169
201
|
// val is the ref slot
|
|
170
202
|
if (val && typeof val === "object" && !Array.isArray(val) && isInlineResource(val as Record<string, unknown>)) {
|
|
171
|
-
const name =
|
|
172
|
-
extracted.push(buildManifest(val as Record<string, unknown>, name, parentModule, invocationContext));
|
|
203
|
+
const name = emit(val as Record<string, unknown>, [...nameParts, key], keyPath);
|
|
173
204
|
container[key] = { kind: (val as Record<string, unknown>).kind, name };
|
|
174
205
|
}
|
|
175
206
|
} else {
|
|
176
|
-
traverse(val, rest, [...nameParts, key]);
|
|
207
|
+
traverse(val, rest, [...nameParts, key], keyPath);
|
|
177
208
|
}
|
|
178
209
|
}
|
|
179
210
|
}
|
|
180
211
|
|
|
181
|
-
traverse(resource, parts, []);
|
|
212
|
+
traverse(resource, parts, [], "");
|
|
182
213
|
return extracted;
|
|
183
214
|
}
|
|
184
215
|
|
|
185
216
|
function buildManifest(
|
|
186
217
|
inline: Record<string, unknown>,
|
|
187
218
|
name: string,
|
|
219
|
+
parentKind: string,
|
|
220
|
+
parentName: string,
|
|
221
|
+
pathFromParent: string,
|
|
188
222
|
parentModule: string | undefined,
|
|
189
223
|
invocationContext?: Record<string, any>,
|
|
190
224
|
): ResourceManifest {
|
|
@@ -200,6 +234,7 @@ function buildManifest(
|
|
|
200
234
|
// Inherit parent module only if the inline doesn't already declare one
|
|
201
235
|
...(parentModule && !existingMeta.module ? { module: parentModule } : {}),
|
|
202
236
|
...(invocationContext ? { xTeloInvocationContext: invocationContext } : {}),
|
|
237
|
+
xTeloOrigin: { parentKind, parentName, pathFromParent },
|
|
203
238
|
},
|
|
204
|
-
} as ResourceManifest;
|
|
239
|
+
} as unknown as ResourceManifest;
|
|
205
240
|
}
|
package/src/position-metadata.ts
CHANGED
|
@@ -29,13 +29,21 @@ export function buildDocumentPositions(
|
|
|
29
29
|
|
|
30
30
|
/** Line numbers (0-indexed) where each YAML document in a multi-doc file
|
|
31
31
|
* starts. The first document is always at line 0; subsequent entries point
|
|
32
|
-
* to the line after each `---`
|
|
32
|
+
* to the line after each `---` separator.
|
|
33
|
+
*
|
|
34
|
+
* A `---` at line 0 is the doc-start marker for doc 0 (the parser still
|
|
35
|
+
* emits a single document), not a separator before an empty doc — skipping
|
|
36
|
+
* it keeps `offsets.length === parsedDocs.length` so diagnostics for doc N
|
|
37
|
+
* don't land inside doc N-1's text. */
|
|
33
38
|
export function documentLineOffsets(text: string): number[] {
|
|
34
39
|
const offsets = [0];
|
|
35
40
|
const lines = text.split("\n");
|
|
36
41
|
for (let i = 0; i < lines.length; i++) {
|
|
37
42
|
const t = lines[i].trimEnd();
|
|
38
|
-
if (t === "---" || t.startsWith("--- "))
|
|
43
|
+
if (t === "---" || t.startsWith("--- ")) {
|
|
44
|
+
if (i === 0) continue;
|
|
45
|
+
offsets.push(i + 1);
|
|
46
|
+
}
|
|
39
47
|
}
|
|
40
48
|
return offsets;
|
|
41
49
|
}
|
|
@@ -63,7 +71,11 @@ function offsetToPosition(offset: number, lineOffsets: number[]): Position {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/** Walks the YAML AST and records source ranges for every field value, keyed
|
|
66
|
-
* by dotted path (e.g. "kind", "config.handler", "config.routes[0].path").
|
|
74
|
+
* by dotted path (e.g. "kind", "config.handler", "config.routes[0].path").
|
|
75
|
+
* Map keys are also recorded under the `@key:<path>` namespace so diagnostic
|
|
76
|
+
* resolvers can squiggle just the key identifier instead of the full value
|
|
77
|
+
* block — used when a diagnostic targets a missing child property and the
|
|
78
|
+
* resolver has to fall back to the parent. */
|
|
67
79
|
export function buildPositionIndex(doc: Document, lineOffsets: number[]): PositionIndex {
|
|
68
80
|
const index: PositionIndex = new Map();
|
|
69
81
|
|
|
@@ -83,6 +95,9 @@ export function buildPositionIndex(doc: Document, lineOffsets: number[]): Positi
|
|
|
83
95
|
const key = isScalar(pair.key) ? String(pair.key.value) : null;
|
|
84
96
|
if (key == null) continue;
|
|
85
97
|
const childPath = path ? `${path}.${key}` : key;
|
|
98
|
+
if (pair.key && (pair.key as any).range) {
|
|
99
|
+
recordNode(pair.key, `@key:${childPath}`);
|
|
100
|
+
}
|
|
86
101
|
if (pair.value != null) {
|
|
87
102
|
recordNode(pair.value, childPath);
|
|
88
103
|
walk(pair.value, childPath);
|
package/src/precompile.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
2
2
|
import { isCompiledValue } from "@telorun/sdk";
|
|
3
|
-
import { compileString, defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
3
|
+
import { compileString, defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Walks a raw YAML document and replaces all `${{ expr }}` strings (and
|
|
@@ -21,6 +21,13 @@ export function precompileDoc(doc: unknown, env: Environment): unknown {
|
|
|
21
21
|
// analyzer's diagnostic walk can identify it on compiled trees too;
|
|
22
22
|
// engines returning plain values (e.g. `literal` → a string) pass through
|
|
23
23
|
// verbatim — the runtime contract is "any scalar value is fine."
|
|
24
|
+
// `!ref` sentinels are identity markers, not templating values. They must
|
|
25
|
+
// survive precompile intact so the analyzer's `resolveRefSentinels` pass
|
|
26
|
+
// can substitute them with `{kind, name}` objects against the resolved
|
|
27
|
+
// resource manifest. Running the engine's `compile` here would prematurely
|
|
28
|
+
// collapse the sentinel into its source string and the ref slot would
|
|
29
|
+
// arrive at the controller as a bare name with no kind.
|
|
30
|
+
if (isRefSentinel(doc)) return doc;
|
|
24
31
|
if (isTaggedSentinel(doc)) {
|
|
25
32
|
const engine = defaultRegistry().get(doc.engine);
|
|
26
33
|
if (!engine) {
|
|
@@ -63,23 +63,39 @@ export function isInlineResource(val: Record<string, unknown>): boolean {
|
|
|
63
63
|
return true;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
66
|
+
/** A value found at a field-map path, paired with the concrete path that
|
|
67
|
+
* produced it. `path` has every `[]` substituted with `[N]` and every `{}`
|
|
68
|
+
* substituted with the actual map key, matching the format produced by
|
|
69
|
+
* `buildPositionIndex`. Used so diagnostics emitted against a specific
|
|
70
|
+
* array element / map entry can be resolved back to a YAML range. */
|
|
71
|
+
export interface ResolvedFieldEntry {
|
|
72
|
+
value: unknown;
|
|
73
|
+
path: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resolves all `{value, path}` entries at a field map path in a resource
|
|
77
|
+
* config. The returned `path` is the concrete dotted path produced by the
|
|
78
|
+
* substitutions below, matching the format `buildPositionIndex` keys on.
|
|
79
|
+
* Path-segment markers accepted in the input `path`:
|
|
80
|
+
* - `[]` iterate array values at this key, substituting `[N]` per item
|
|
81
|
+
* (e.g. `routes[]` → `routes[0]`, `routes[1]`, …).
|
|
69
82
|
* - `{}` iterate map values (every value in an `additionalProperties`-typed
|
|
70
83
|
* object — used for fields like `content[mime]` whose schema declares
|
|
71
|
-
* a key-as-MIME map).
|
|
72
|
-
|
|
84
|
+
* a key-as-MIME map). Substituted with the literal map key joined by
|
|
85
|
+
* a dot, so the input `content.{}.encoder` yields concrete paths
|
|
86
|
+
* like `content.application/json.encoder`. */
|
|
87
|
+
export function resolveFieldEntries(obj: unknown, path: string): ResolvedFieldEntry[] {
|
|
73
88
|
const parts = path.split(".");
|
|
74
|
-
let current:
|
|
89
|
+
let current: ResolvedFieldEntry[] = [{ value: obj, path: "" }];
|
|
75
90
|
for (const part of parts) {
|
|
76
91
|
if (part === "{}") {
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
const next: ResolvedFieldEntry[] = [];
|
|
93
|
+
for (const entry of current) {
|
|
94
|
+
if (!entry.value || typeof entry.value !== "object") continue;
|
|
95
|
+
for (const [k, v] of Object.entries(entry.value as Record<string, unknown>)) {
|
|
96
|
+
if (v != null) {
|
|
97
|
+
next.push({ value: v, path: entry.path ? `${entry.path}.${k}` : k });
|
|
98
|
+
}
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
current = next;
|
|
@@ -87,19 +103,31 @@ export function resolveFieldValues(obj: unknown, path: string): unknown[] {
|
|
|
87
103
|
}
|
|
88
104
|
const isArray = part.endsWith("[]");
|
|
89
105
|
const key = isArray ? part.slice(0, -2) : part;
|
|
90
|
-
const next:
|
|
91
|
-
for (const
|
|
92
|
-
if (!
|
|
93
|
-
const val = (
|
|
106
|
+
const next: ResolvedFieldEntry[] = [];
|
|
107
|
+
for (const entry of current) {
|
|
108
|
+
if (!entry.value || typeof entry.value !== "object") continue;
|
|
109
|
+
const val = (entry.value as Record<string, unknown>)[key];
|
|
94
110
|
if (val == null) continue;
|
|
95
|
-
|
|
96
|
-
|
|
111
|
+
const basePath = entry.path ? `${entry.path}.${key}` : key;
|
|
112
|
+
if (isArray && Array.isArray(val)) {
|
|
113
|
+
for (let i = 0; i < val.length; i++) {
|
|
114
|
+
if (val[i] != null) next.push({ value: val[i], path: `${basePath}[${i}]` });
|
|
115
|
+
}
|
|
116
|
+
} else if (!isArray) {
|
|
117
|
+
next.push({ value: val, path: basePath });
|
|
118
|
+
}
|
|
97
119
|
}
|
|
98
120
|
current = next;
|
|
99
121
|
}
|
|
100
122
|
return current;
|
|
101
123
|
}
|
|
102
124
|
|
|
125
|
+
/** Backwards-compat wrapper that drops the concrete path. Prefer
|
|
126
|
+
* `resolveFieldEntries` for new code that wants positions. */
|
|
127
|
+
export function resolveFieldValues(obj: unknown, path: string): unknown[] {
|
|
128
|
+
return resolveFieldEntries(obj, path).map((e) => e.value);
|
|
129
|
+
}
|
|
130
|
+
|
|
103
131
|
/**
|
|
104
132
|
* Traverses a definition's JSON Schema once and returns a field map recording every
|
|
105
133
|
* x-telo-ref slot and every x-telo-scope slot.
|
|
@@ -114,7 +142,7 @@ export function buildReferenceFieldMap(schema: Record<string, any>): ReferenceFi
|
|
|
114
142
|
const map: ReferenceFieldMap = new Map();
|
|
115
143
|
if (schema.properties) {
|
|
116
144
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
117
|
-
traverseNode(propSchema as Record<string, any>, key, map);
|
|
145
|
+
traverseNode(propSchema as Record<string, any>, key, map, schema);
|
|
118
146
|
}
|
|
119
147
|
}
|
|
120
148
|
return map;
|
|
@@ -144,11 +172,29 @@ export function buildFieldMapAtPath(
|
|
|
144
172
|
pathPrefix: string,
|
|
145
173
|
): ReferenceFieldMap {
|
|
146
174
|
const map: ReferenceFieldMap = new Map();
|
|
147
|
-
traverseNode(schema, pathPrefix, map);
|
|
175
|
+
traverseNode(schema, pathPrefix, map, schema);
|
|
148
176
|
return map;
|
|
149
177
|
}
|
|
150
178
|
|
|
151
|
-
function traverseNode(
|
|
179
|
+
function traverseNode(
|
|
180
|
+
node: Record<string, any>,
|
|
181
|
+
path: string,
|
|
182
|
+
map: ReferenceFieldMap,
|
|
183
|
+
root?: Record<string, any>,
|
|
184
|
+
visitedRefs: Set<string> = new Set(),
|
|
185
|
+
): void {
|
|
186
|
+
// Local `$ref` is intentionally NOT followed. Descending into shared
|
|
187
|
+
// `$defs` (notably `Run.Sequence`'s `step` definition) would surface
|
|
188
|
+
// ref slots like `steps[].invoke` that Phase 5 then injects live
|
|
189
|
+
// instances into; today's `Run.Sequence` controller calls
|
|
190
|
+
// `instance.invoke()` directly when handed an instance, bypassing
|
|
191
|
+
// the kernel's `runInvoke` emit-Invoked path. The walker fix and the
|
|
192
|
+
// dispatcher fix need to land together — see the follow-up in
|
|
193
|
+
// [kernel/nodejs/plans/reference-syntax-unification.md] and the
|
|
194
|
+
// stopgap in `resource-context.ts:resolveChildren`. `visitedRefs`
|
|
195
|
+
// stays as a parameter so the recursive calls below thread the right
|
|
196
|
+
// signature; turning the descent back on is a single-branch change.
|
|
197
|
+
if (typeof node?.$ref === "string") return;
|
|
152
198
|
// Scope slot — record and stop; do not recurse into scope contents
|
|
153
199
|
if ("x-telo-scope" in node) {
|
|
154
200
|
map.set(path, { scope: node["x-telo-scope"] });
|
|
@@ -172,13 +218,32 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
|
|
|
172
218
|
|
|
173
219
|
// Array — recurse into items
|
|
174
220
|
if (node.type === "array" && node.items) {
|
|
175
|
-
traverseNode(node.items as Record<string, any>, path + "[]", map);
|
|
221
|
+
traverseNode(node.items as Record<string, any>, path + "[]", map, root, visitedRefs);
|
|
176
222
|
}
|
|
177
223
|
|
|
178
224
|
// Object — recurse into properties
|
|
179
225
|
if (node.properties) {
|
|
180
226
|
for (const [key, propSchema] of Object.entries(node.properties)) {
|
|
181
|
-
traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map);
|
|
227
|
+
traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map, root, visitedRefs);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Variant branches — descend into every alternative's properties / items.
|
|
232
|
+
// Schemas that discriminate on shape (Run.Sequence's step kinds:
|
|
233
|
+
// `oneOf: [{properties: {invoke}}, {properties: {try}}, ...]`) hide ref
|
|
234
|
+
// slots inside the branch. Walking each branch surfaces those slots into
|
|
235
|
+
// the field map so downstream passes (ref validation, sentinel
|
|
236
|
+
// resolution, dependency graph) cover them without a runtime fallback.
|
|
237
|
+
// The same field path may be added by multiple branches; the later
|
|
238
|
+
// assignment wins, which is fine — branches with the same field path
|
|
239
|
+
// share the same ref/context configuration (any divergence is already
|
|
240
|
+
// a schema bug).
|
|
241
|
+
for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
|
|
242
|
+
const variants = node[variantKey];
|
|
243
|
+
if (!Array.isArray(variants)) continue;
|
|
244
|
+
for (const variant of variants) {
|
|
245
|
+
if (!variant || typeof variant !== "object") continue;
|
|
246
|
+
traverseVariant(variant as Record<string, any>, path, map, root, visitedRefs);
|
|
182
247
|
}
|
|
183
248
|
}
|
|
184
249
|
|
|
@@ -190,6 +255,46 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
|
|
|
190
255
|
typeof node.additionalProperties === "object" &&
|
|
191
256
|
!Array.isArray(node.additionalProperties)
|
|
192
257
|
) {
|
|
193
|
-
traverseNode(
|
|
258
|
+
traverseNode(
|
|
259
|
+
node.additionalProperties as Record<string, any>,
|
|
260
|
+
`${path}.{}`,
|
|
261
|
+
map,
|
|
262
|
+
root,
|
|
263
|
+
visitedRefs,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Walk a single variant of a `oneOf` / `anyOf` / `allOf` branch. Only
|
|
269
|
+
* the properties / items / map slots are followed — collectRefs at the
|
|
270
|
+
* variant root is handled by the parent's `collectRefs(node)` already
|
|
271
|
+
* (anyOf of x-telo-ref branches is the canonical multi-ref shape). */
|
|
272
|
+
function traverseVariant(
|
|
273
|
+
variant: Record<string, any>,
|
|
274
|
+
path: string,
|
|
275
|
+
map: ReferenceFieldMap,
|
|
276
|
+
root?: Record<string, any>,
|
|
277
|
+
visitedRefs: Set<string> = new Set(),
|
|
278
|
+
): void {
|
|
279
|
+
if (variant.properties) {
|
|
280
|
+
for (const [key, propSchema] of Object.entries(variant.properties)) {
|
|
281
|
+
traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map, root, visitedRefs);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (variant.type === "array" && variant.items) {
|
|
285
|
+
traverseNode(variant.items as Record<string, any>, path + "[]", map, root, visitedRefs);
|
|
286
|
+
}
|
|
287
|
+
if (
|
|
288
|
+
variant.additionalProperties &&
|
|
289
|
+
typeof variant.additionalProperties === "object" &&
|
|
290
|
+
!Array.isArray(variant.additionalProperties)
|
|
291
|
+
) {
|
|
292
|
+
traverseNode(
|
|
293
|
+
variant.additionalProperties as Record<string, any>,
|
|
294
|
+
`${path}.{}`,
|
|
295
|
+
map,
|
|
296
|
+
root,
|
|
297
|
+
visitedRefs,
|
|
298
|
+
);
|
|
194
299
|
}
|
|
195
300
|
}
|