@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.
Files changed (92) hide show
  1. package/LICENSE +2 -2
  2. package/README.md +3 -3
  3. package/dist/adapters/http-adapter.d.ts +10 -0
  4. package/dist/adapters/http-adapter.d.ts.map +1 -0
  5. package/dist/adapters/http-adapter.js +18 -0
  6. package/dist/adapters/node-adapter.d.ts +17 -0
  7. package/dist/adapters/node-adapter.d.ts.map +1 -0
  8. package/dist/adapters/node-adapter.js +71 -0
  9. package/dist/adapters/registry-adapter.d.ts +15 -0
  10. package/dist/adapters/registry-adapter.d.ts.map +1 -0
  11. package/dist/adapters/registry-adapter.js +53 -0
  12. package/dist/analysis-registry.d.ts +7 -0
  13. package/dist/analysis-registry.d.ts.map +1 -1
  14. package/dist/analysis-registry.js +38 -0
  15. package/dist/analyzer.d.ts +15 -0
  16. package/dist/analyzer.d.ts.map +1 -1
  17. package/dist/analyzer.js +268 -7
  18. package/dist/builtins.d.ts.map +1 -1
  19. package/dist/builtins.js +172 -1
  20. package/dist/definition-registry.d.ts.map +1 -1
  21. package/dist/definition-registry.js +16 -0
  22. package/dist/dependency-graph.d.ts.map +1 -1
  23. package/dist/dependency-graph.js +27 -13
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/kernel-globals.d.ts.map +1 -1
  28. package/dist/kernel-globals.js +9 -11
  29. package/dist/manifest-loader.d.ts +23 -1
  30. package/dist/manifest-loader.d.ts.map +1 -1
  31. package/dist/manifest-loader.js +66 -3
  32. package/dist/normalize-inline-resources.d.ts.map +1 -1
  33. package/dist/normalize-inline-resources.js +26 -14
  34. package/dist/position-metadata.d.ts +11 -2
  35. package/dist/position-metadata.d.ts.map +1 -1
  36. package/dist/position-metadata.js +18 -3
  37. package/dist/precompile.d.ts.map +1 -1
  38. package/dist/precompile.js +9 -1
  39. package/dist/reference-field-map.d.ts +21 -4
  40. package/dist/reference-field-map.d.ts.map +1 -1
  41. package/dist/reference-field-map.js +93 -25
  42. package/dist/residual-schema.d.ts +23 -0
  43. package/dist/residual-schema.d.ts.map +1 -0
  44. package/dist/residual-schema.js +45 -0
  45. package/dist/resolve-ref-sentinels.d.ts +27 -0
  46. package/dist/resolve-ref-sentinels.d.ts.map +1 -0
  47. package/dist/resolve-ref-sentinels.js +114 -0
  48. package/dist/rewrite-synthetic-origins.d.ts +10 -0
  49. package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
  50. package/dist/rewrite-synthetic-origins.js +55 -0
  51. package/dist/schema-compat.d.ts +7 -1
  52. package/dist/schema-compat.d.ts.map +1 -1
  53. package/dist/schema-compat.js +19 -2
  54. package/dist/system-kinds.d.ts +25 -0
  55. package/dist/system-kinds.d.ts.map +1 -0
  56. package/dist/system-kinds.js +34 -0
  57. package/dist/types.d.ts +12 -0
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/validate-cel-context.d.ts +61 -7
  60. package/dist/validate-cel-context.d.ts.map +1 -1
  61. package/dist/validate-cel-context.js +90 -8
  62. package/dist/validate-provider-coherence.d.ts +23 -0
  63. package/dist/validate-provider-coherence.d.ts.map +1 -0
  64. package/dist/validate-provider-coherence.js +148 -0
  65. package/dist/validate-references.d.ts.map +1 -1
  66. package/dist/validate-references.js +141 -36
  67. package/dist/with-synthetic-positions.d.ts +28 -0
  68. package/dist/with-synthetic-positions.d.ts.map +1 -0
  69. package/dist/with-synthetic-positions.js +45 -0
  70. package/package.json +7 -4
  71. package/src/analysis-registry.ts +37 -0
  72. package/src/analyzer.ts +313 -9
  73. package/src/builtins.ts +172 -1
  74. package/src/definition-registry.ts +15 -0
  75. package/src/dependency-graph.ts +27 -14
  76. package/src/index.ts +2 -0
  77. package/src/kernel-globals.ts +9 -11
  78. package/src/manifest-loader.ts +69 -4
  79. package/src/normalize-inline-resources.ts +48 -13
  80. package/src/position-metadata.ts +18 -3
  81. package/src/precompile.ts +8 -1
  82. package/src/reference-field-map.ts +129 -24
  83. package/src/residual-schema.ts +49 -0
  84. package/src/resolve-ref-sentinels.ts +127 -0
  85. package/src/rewrite-synthetic-origins.ts +75 -0
  86. package/src/schema-compat.ts +19 -2
  87. package/src/system-kinds.ts +37 -0
  88. package/src/types.ts +12 -0
  89. package/src/validate-cel-context.ts +111 -8
  90. package/src/validate-provider-coherence.ts +166 -0
  91. package/src/validate-references.ts +138 -35
  92. package/src/with-synthetic-positions.ts +48 -0
@@ -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 key = nodeKey(r.kind, r.metadata.name as string);
55
- nodes.set(key, { kind: r.kind, name: r.metadata.name as string });
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 || typeof val !== "object") continue;
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,
@@ -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. Falls back to
76
- * an open map when the module declares no variables/secrets. */
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
- if (!schemaMap || typeof schemaMap !== "object" || Array.isArray(schemaMap)) {
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
  }
@@ -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
- const { source } = await this.pick(url).read(url);
71
- return source;
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
- const cacheKey = `${options?.compile ? "compiled" : "raw"}:${source}`;
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
- private resolveImportUrl(fromSource: string, importSource: string): string {
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(resource, fieldPath, parentName, parentModule, invocationContext);
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 traverse(obj: unknown, partsLeft: string[], nameParts: string[]): void {
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 = sanitizeName([parentName, ...nameParts, sanitizedKey].join("_"));
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 = sanitizeName([parentName, ...nameParts, key, elemId].join("_"));
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 = sanitizeName([parentName, ...nameParts, key].join("_"));
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
  }
@@ -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 `---` directive. */
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("--- ")) offsets.push(i + 1);
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
- /** Resolves all values at a field map path in a resource config.
67
- * Path-segment markers:
68
- * - `[]` iterate array values at this key
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). The path is `<key>.{}.<rest>`. */
72
- export function resolveFieldValues(obj: unknown, path: string): unknown[] {
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: unknown[] = [obj];
89
+ let current: ResolvedFieldEntry[] = [{ value: obj, path: "" }];
75
90
  for (const part of parts) {
76
91
  if (part === "{}") {
77
- // Iterate the values of every map currently in `current`.
78
- const next: unknown[] = [];
79
- for (const item of current) {
80
- if (!item || typeof item !== "object") continue;
81
- for (const v of Object.values(item as Record<string, unknown>)) {
82
- if (v != null) next.push(v);
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: unknown[] = [];
91
- for (const item of current) {
92
- if (!item || typeof item !== "object") continue;
93
- const val = (item as Record<string, unknown>)[key];
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
- if (isArray && Array.isArray(val)) next.push(...val);
96
- else if (!isArray) next.push(val);
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(node: Record<string, any>, path: string, map: ReferenceFieldMap): void {
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(node.additionalProperties as Record<string, any>, `${path}.{}`, map);
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
  }