@telorun/analyzer 1.2.0 → 1.4.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.
@@ -1,13 +1,9 @@
1
+ import { isRefSentinel } from "@telorun/templating";
1
2
  import { isRefEntry, isScopeEntry, isSchemaFromEntry, isInlineResource, resolveFieldEntries, resolveFieldValues } from "./reference-field-map.js";
2
3
  import { navigateJsonPointer } from "./schema-compat.js";
4
+ import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
3
5
  import { DiagnosticSeverity } from "./types.js";
4
6
  const SOURCE = "telo-analyzer";
5
- /** Kinds skipped by reference validation. Telo.Application and Telo.Library
6
- * are intentionally not here: Application has `targets` with x-telo-ref that
7
- * must be validated, and Library has no ref-bearing fields so flows through
8
- * harmlessly. Telo.Import is also not here for the same reason — its
9
- * `source` field isn't x-telo-ref, so nothing gets checked. */
10
- const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Abstract"]);
11
7
  /**
12
8
  * Checks whether `kind` satisfies the ref constraint in `entry`.
13
9
  * Returns an empty array when valid, or mismatch error strings when not.
@@ -64,14 +60,57 @@ export function validateReferences(resources, context) {
64
60
  const aliasesByModule = context.aliasesByModule;
65
61
  if (!aliases || !registry)
66
62
  return diagnostics;
67
- // Build outer resource lookup by name for resolution check.
68
- // Exclude system kinds (Telo.Definition) they are type blueprints, not instances,
69
- // and their names (e.g. "Server", "Job") would shadow user-defined resource instances.
70
- const byName = new Map();
63
+ // Build outer resource lookup by name for resolution check, collecting
64
+ // every entry per name so we can surface name collisions as diagnostics
65
+ // (the kernel's resource registry shares one namespace across all
66
+ // non-system kinds e.g. `Telo.Application HelloApi` and `Http.Api
67
+ // HelloApi` collide at boot with `ERR_DUPLICATE_RESOURCE`. Catching it
68
+ // statically removes a class of "everything analyzes clean, then the
69
+ // kernel refuses to start" surprises.)
70
+ //
71
+ // Telo.Import is excluded from the duplicate check on top of the
72
+ // SYSTEM_KINDS skip: its `metadata.name` is an alias, not a resource
73
+ // identity (aliases live in a separate namespace from resources, and
74
+ // colliding aliases vs. resource names is benign — the alias is only
75
+ // ever read as a kind prefix).
76
+ const byNameAll = new Map();
71
77
  for (const r of resources) {
72
- if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind))
73
- byName.set(r.metadata.name, r);
78
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import")
79
+ continue;
80
+ const name = r.metadata.name;
81
+ const existing = byNameAll.get(name);
82
+ if (existing)
83
+ existing.push(r);
84
+ else
85
+ byNameAll.set(name, [r]);
74
86
  }
87
+ for (const [name, list] of byNameAll) {
88
+ if (list.length <= 1)
89
+ continue;
90
+ const [first, ...rest] = list;
91
+ const firstLabel = `${first.kind}/${name}`;
92
+ for (const dup of rest) {
93
+ diagnostics.push({
94
+ severity: DiagnosticSeverity.Error,
95
+ code: "DUPLICATE_RESOURCE_NAME",
96
+ source: SOURCE,
97
+ message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
98
+ data: {
99
+ resource: { kind: dup.kind, name },
100
+ filePath: dup.metadata?.source,
101
+ path: "metadata.name",
102
+ },
103
+ });
104
+ }
105
+ }
106
+ // Single-resource map for the resolution / scope lookups below — when a
107
+ // collision exists, falling back to the first occurrence keeps the rest
108
+ // of the pass behaving the same as before the duplicate diagnostic was
109
+ // added (resolution still finds *something*; the duplicate diagnostic
110
+ // is what surfaces the underlying problem to the user).
111
+ const byName = new Map();
112
+ for (const [name, list] of byNameAll)
113
+ byName.set(name, list[0]);
75
114
  for (const r of resources) {
76
115
  if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
77
116
  continue;
@@ -122,6 +161,36 @@ export function validateReferences(resources, context) {
122
161
  for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
123
162
  if (!val)
124
163
  continue;
164
+ // `!ref <name>` sentinel — bare resource name marked at parse time as a
165
+ // reference. Look it up against the slot's x-telo-ref constraint exactly
166
+ // like the legacy bare-string path; the only difference is the value's
167
+ // shape (a TaggedSentinel rather than a raw string), which removed the
168
+ // string/inline ambiguity at the source.
169
+ if (isRefSentinel(val)) {
170
+ const refName = val.source;
171
+ const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
172
+ if (!target) {
173
+ diagnostics.push({
174
+ severity: DiagnosticSeverity.Error,
175
+ code: "UNRESOLVED_REFERENCE",
176
+ source: SOURCE,
177
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
178
+ data: { resource: resourceData, filePath, path: concretePath },
179
+ });
180
+ continue;
181
+ }
182
+ const kindErrors = checkKind(target.kind, entry, registry, aliases);
183
+ if (kindErrors.length > 0) {
184
+ diagnostics.push({
185
+ severity: DiagnosticSeverity.Error,
186
+ code: "REFERENCE_KIND_MISMATCH",
187
+ source: SOURCE,
188
+ message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
189
+ data: { resource: resourceData, filePath, path: concretePath },
190
+ });
191
+ }
192
+ continue;
193
+ }
125
194
  // Name-only reference (plain string) — look up by name to validate.
126
195
  // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
127
196
  // extract the resource name from the last dot segment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -42,7 +42,7 @@
42
42
  "ajv-formats": "^3.0.1",
43
43
  "jsonpath-plus": "^10.3.0",
44
44
  "yaml": "^2.8.3",
45
- "@telorun/templating": "1.0.0"
45
+ "@telorun/templating": "1.1.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
package/src/analyzer.ts CHANGED
@@ -14,6 +14,7 @@ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kerne
14
14
  import { computeSuggestKind } from "./kind-suggest.js";
15
15
  import { isModuleKind } from "./module-kinds.js";
16
16
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
17
+ import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
17
18
  import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
18
19
  import {
19
20
  celTypeSatisfiesJsonSchema,
@@ -623,6 +624,12 @@ export class StaticAnalyzer {
623
624
  // Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
624
625
  const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
625
626
 
627
+ // Phase 2.5: resolve `!ref <name>` sentinels at every ref slot to canonical
628
+ // {kind, name} objects so downstream phases (validation, dependency graph,
629
+ // kernel controllers) see a uniform shape. Runs after normalize so both
630
+ // original and inline-extracted manifests have their sentinels resolved.
631
+ resolveRefSentinels(allManifests, defs, aliases, aliasesByModule);
632
+
626
633
  // Trusted-input fast path: when the caller has already attested that
627
634
  // this exact manifest set passes analysis (e.g. via the kernel's
628
635
  // hash-stamped `.validated.json` cache), skip the validation walk.
@@ -982,12 +989,17 @@ export class StaticAnalyzer {
982
989
 
983
990
  normalize(manifests: ResourceManifest[], registry: AnalysisRegistry): ResourceManifest[] {
984
991
  const ctx = registry._context();
985
- return normalizeInlineResources(
992
+ const normalized = normalizeInlineResources(
986
993
  manifests,
987
994
  ctx.definitions!,
988
995
  ctx.aliases,
989
996
  ctx.aliasesByModule,
990
997
  );
998
+ // Resolve !ref sentinels after normalize so both the original and
999
+ // inline-extracted manifests get their refs canonicalized to
1000
+ // {kind, name} for the kernel that consumes this output.
1001
+ resolveRefSentinels(normalized, ctx.definitions!, ctx.aliases, ctx.aliasesByModule);
1002
+ return normalized;
991
1003
  }
992
1004
 
993
1005
  prepare(
package/src/builtins.ts CHANGED
@@ -220,6 +220,20 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
220
220
  anyOf: [
221
221
  { type: "string", "x-telo-ref": "telo#Runnable" },
222
222
  { type: "string", "x-telo-ref": "telo#Service" },
223
+ // Post-resolution shape that `resolveRefSentinels`
224
+ // substitutes a `!ref <name>` sentinel into. The
225
+ // adjacent `x-telo-ref` constraints govern the kind
226
+ // check; this branch only admits the structural form so
227
+ // AJV doesn't reject a resolved ref.
228
+ {
229
+ type: "object",
230
+ required: ["kind", "name"],
231
+ properties: {
232
+ kind: { type: "string" },
233
+ name: { type: "string" },
234
+ },
235
+ additionalProperties: true,
236
+ },
223
237
  ],
224
238
  },
225
239
  },
@@ -80,6 +80,21 @@ export class DefinitionRegistry {
80
80
  * @param namespace The module's metadata.namespace (e.g. "std"), or null for telo built-ins.
81
81
  * @param moduleName The module's metadata.name (e.g. "pipeline", "http-server"). */
82
82
  registerModuleIdentity(namespace: string | null, moduleName: string): void {
83
+ // The "telo" identity is reserved for the Telo built-in module and gets
84
+ // populated automatically when a Telo.Abstract definition registers (see
85
+ // `register` below). A user app / library without a namespace must NOT
86
+ // claim it — silently overwriting the built-in entry breaks every
87
+ // x-telo-ref that resolves through "telo#…". Concretely, the
88
+ // `Http.Api.routes[].handler` slot in the http-server schema carries
89
+ // `x-telo-ref: "telo#Invocable"`. If the entry application is, say,
90
+ // `Telo.Application/HelloApi` (no namespace), this method previously
91
+ // overwrote `"telo" → "Telo"` with `"telo" → "HelloApi"`. The handler's
92
+ // ref then resolved to a nonexistent `HelloApi.Invocable`, the
93
+ // kind-mismatch check inside `validate-references.ts` short-circuited
94
+ // on partial context, and the analyzer reported zero issues for a
95
+ // manifest that explodes at runtime. Skip non-Telo no-namespace modules;
96
+ // they have no x-telo-ref identity to declare anyway.
97
+ if (!namespace && moduleName !== "Telo") return;
83
98
  const identity = namespace ? `${namespace}/${moduleName}` : "telo";
84
99
  this.identityMap.set(identity, moduleName);
85
100
  this.reverseIdentityMap.set(moduleName, identity);
@@ -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
@@ -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
  }
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) {
@@ -142,7 +142,7 @@ export function buildReferenceFieldMap(schema: Record<string, any>): ReferenceFi
142
142
  const map: ReferenceFieldMap = new Map();
143
143
  if (schema.properties) {
144
144
  for (const [key, propSchema] of Object.entries(schema.properties)) {
145
- traverseNode(propSchema as Record<string, any>, key, map);
145
+ traverseNode(propSchema as Record<string, any>, key, map, schema);
146
146
  }
147
147
  }
148
148
  return map;
@@ -172,11 +172,29 @@ export function buildFieldMapAtPath(
172
172
  pathPrefix: string,
173
173
  ): ReferenceFieldMap {
174
174
  const map: ReferenceFieldMap = new Map();
175
- traverseNode(schema, pathPrefix, map);
175
+ traverseNode(schema, pathPrefix, map, schema);
176
176
  return map;
177
177
  }
178
178
 
179
- 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;
180
198
  // Scope slot — record and stop; do not recurse into scope contents
181
199
  if ("x-telo-scope" in node) {
182
200
  map.set(path, { scope: node["x-telo-scope"] });
@@ -200,13 +218,32 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
200
218
 
201
219
  // Array — recurse into items
202
220
  if (node.type === "array" && node.items) {
203
- traverseNode(node.items as Record<string, any>, path + "[]", map);
221
+ traverseNode(node.items as Record<string, any>, path + "[]", map, root, visitedRefs);
204
222
  }
205
223
 
206
224
  // Object — recurse into properties
207
225
  if (node.properties) {
208
226
  for (const [key, propSchema] of Object.entries(node.properties)) {
209
- 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);
210
247
  }
211
248
  }
212
249
 
@@ -218,6 +255,46 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
218
255
  typeof node.additionalProperties === "object" &&
219
256
  !Array.isArray(node.additionalProperties)
220
257
  ) {
221
- 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
+ );
222
299
  }
223
300
  }
@@ -0,0 +1,127 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isRefSentinel } from "@telorun/templating";
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { isRefEntry } from "./reference-field-map.js";
6
+ import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
+
8
+ /**
9
+ * Walks every `x-telo-ref` slot in every non-system resource and rewrites
10
+ * `!ref <name>` sentinels in-place to `{kind: <resolved-kind>, name}`.
11
+ *
12
+ * The downstream pipeline (inline normalization, dependency graph, kernel
13
+ * controllers) expects every ref-slot value to be either a `{kind, name}`
14
+ * object, an inline-definition object, or a legacy bare string — resolving
15
+ * sentinels here keeps that contract intact so each consumer doesn't need
16
+ * its own sentinel branch.
17
+ *
18
+ * The walker assigns `kind` by name lookup (resource names are unique
19
+ * within a manifest scope). When the name doesn't resolve in the local
20
+ * `byName` map, the sentinel is left in place so `validateReferences`
21
+ * can emit the `UNRESOLVED_REFERENCE` diagnostic with full context.
22
+ *
23
+ * Mutation strategy: the field-path walker descends the resource tree
24
+ * directly and replaces the sentinel on its parent container. Re-parsing
25
+ * a string-encoded concrete path (the earlier shape) coupled the writer
26
+ * to the path-encoding rules of `resolveFieldEntries` — any new path
27
+ * marker would silently break this writer. Descending directly avoids
28
+ * that coupling.
29
+ */
30
+ export function resolveRefSentinels(
31
+ resources: ResourceManifest[],
32
+ registry: DefinitionRegistry,
33
+ aliases?: AliasResolver,
34
+ aliasesByModule?: Map<string, AliasResolver>,
35
+ ): void {
36
+ const byName = new Map<string, ResourceManifest>();
37
+ for (const r of resources) {
38
+ if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) {
39
+ byName.set(r.metadata.name as string, r);
40
+ }
41
+ }
42
+
43
+ for (const r of resources) {
44
+ if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
45
+
46
+ const fieldMap =
47
+ aliases && aliasesByModule
48
+ ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
49
+ : registry.getFieldMapForKind(r.kind, aliases);
50
+ if (!fieldMap) continue;
51
+
52
+ for (const [fieldPath, entry] of fieldMap) {
53
+ if (!isRefEntry(entry)) continue;
54
+ replaceSentinelsAtPath(r as Record<string, unknown>, fieldPath, byName);
55
+ }
56
+ }
57
+ }
58
+
59
+ /** Walks `obj` along `fieldPath` (dot notation with `[]` for arrays and
60
+ * `{}` for additionalProperties-typed maps) and replaces any `!ref`
61
+ * sentinel value at the terminal slot with `{kind, name}` looked up
62
+ * via `byName`. Mutates the parent container in place; no string-path
63
+ * round-trip. */
64
+ function replaceSentinelsAtPath(
65
+ obj: Record<string, unknown>,
66
+ fieldPath: string,
67
+ byName: Map<string, ResourceManifest>,
68
+ ): void {
69
+ const parts = fieldPath.split(".");
70
+ descend(obj, parts, byName);
71
+ }
72
+
73
+ function descend(
74
+ obj: unknown,
75
+ parts: string[],
76
+ byName: Map<string, ResourceManifest>,
77
+ ): void {
78
+ if (obj == null || typeof obj !== "object" || parts.length === 0) return;
79
+ const [head, ...rest] = parts;
80
+
81
+ // Map iteration: descend into every value of the current object.
82
+ if (head === "{}") {
83
+ const container = obj as Record<string, unknown>;
84
+ for (const key of Object.keys(container)) {
85
+ const child = container[key];
86
+ if (rest.length === 0) {
87
+ if (isRefSentinel(child)) {
88
+ const target = byName.get(child.source);
89
+ if (target) container[key] = { kind: target.kind as string, name: child.source };
90
+ }
91
+ } else {
92
+ descend(child, rest, byName);
93
+ }
94
+ }
95
+ return;
96
+ }
97
+
98
+ const isArr = head.endsWith("[]");
99
+ const key = isArr ? head.slice(0, -2) : head;
100
+ const container = obj as Record<string, unknown>;
101
+ const val = container[key];
102
+ if (val == null) return;
103
+
104
+ if (isArr) {
105
+ if (!Array.isArray(val)) return;
106
+ for (let i = 0; i < val.length; i++) {
107
+ if (rest.length === 0) {
108
+ const elem = val[i];
109
+ if (isRefSentinel(elem)) {
110
+ const target = byName.get(elem.source);
111
+ if (target) val[i] = { kind: target.kind as string, name: elem.source };
112
+ }
113
+ } else {
114
+ descend(val[i], rest, byName);
115
+ }
116
+ }
117
+ } else {
118
+ if (rest.length === 0) {
119
+ if (isRefSentinel(val)) {
120
+ const target = byName.get(val.source);
121
+ if (target) container[key] = { kind: target.kind as string, name: val.source };
122
+ }
123
+ } else {
124
+ descend(val, rest, byName);
125
+ }
126
+ }
127
+ }
@@ -1,16 +1,23 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { isTaggedSentinel } from "@telorun/templating";
3
+ import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
4
4
 
5
5
  const Ajv = (AjvModule as any).default ?? AjvModule;
6
6
 
7
7
  /** Creates a configured AJV instance (allErrors, strict: false, with formats).
8
- * Called once for the module-level instance and once per DefinitionRegistry instance. */
8
+ * Also registers the kernel manifest root schema under `telo://manifest` so
9
+ * module YAMLs can `$ref` into the shared `$defs/ResourceRef` (and any future
10
+ * shared fragments) from this analyzer's AJV without each module having to
11
+ * bundle its own copy.
12
+ *
13
+ * Called once for the module-level instance and once per
14
+ * DefinitionRegistry instance. */
9
15
  export function createAjv(): InstanceType<typeof Ajv> {
10
16
  const instance = new Ajv({ allErrors: true, strict: false });
11
17
  (addFormats as any).default
12
18
  ? (addFormats as any).default(instance)
13
19
  : (addFormats as any)(instance);
20
+ instance.addSchema(ManifestRootSchema);
14
21
  return instance;
15
22
  }
16
23
 
@@ -301,6 +308,16 @@ export function substituteCelFields(
301
308
  if (typeof data === "string" && CEL_PURE_RE.test(data)) {
302
309
  return celPlaceholderForSchema(resolved);
303
310
  }
311
+ // `!ref <name>` sentinels are identity markers, not runtime values —
312
+ // schemas that opt into `$ref: "telo://manifest#/$defs/ResourceRef"`
313
+ // (or `anyOf` it alongside other shapes) need the actual sentinel
314
+ // object so AJV validates it against ResourceRefSchema. Collapsing it
315
+ // to a CEL placeholder would either fail the schema (when the slot
316
+ // expects the ResourceRef shape) or mask validation errors (when the
317
+ // slot expects something else entirely).
318
+ if (isRefSentinel(data)) {
319
+ return data;
320
+ }
304
321
  if (isTaggedSentinel(data)) {
305
322
  return celPlaceholderForSchema(resolved);
306
323
  }