@telorun/analyzer 0.21.0 → 0.22.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/README.md CHANGED
@@ -58,8 +58,8 @@ imports:
58
58
  Http: std/http-server@0.9.0
59
59
  Sql: std/sql@0.8.0
60
60
  targets:
61
- - Migrations
62
- - Server
61
+ - !ref Migrations
62
+ - !ref Server
63
63
  ---
64
64
  # SQLite database — swap driver/host/database for PostgreSQL with zero YAML changes
65
65
  kind: Sql.Connection
@@ -72,9 +72,7 @@ file: ./tmp/feedback.db
72
72
  kind: Sql.Migrations
73
73
  metadata:
74
74
  name: Migrations
75
- connection:
76
- kind: Sql.Connection
77
- name: Db
75
+ connection: !ref Db
78
76
  ---
79
77
  kind: Sql.Migration
80
78
  metadata:
@@ -101,7 +99,7 @@ openapi:
101
99
  version: 1.0.0
102
100
  mounts:
103
101
  - path: /v1
104
- type: Http.Api.FeedbackRoutes
102
+ mount: !ref FeedbackRoutes
105
103
  ---
106
104
  kind: Http.Api
107
105
  metadata:
@@ -123,9 +121,7 @@ routes:
123
121
  required: [ text ]
124
122
  handler:
125
123
  kind: Sql.Exec
126
- connection:
127
- kind: Sql.Connection
128
- name: Db
124
+ connection: !ref Db
129
125
  inputs:
130
126
  sql: "INSERT INTO feedback (text, source, score) VALUES (?, ?, ?)"
131
127
  bindings:
@@ -146,9 +142,7 @@ routes:
146
142
  method: GET
147
143
  handler:
148
144
  kind: Sql.Select
149
- connection:
150
- kind: Sql.Connection
151
- name: Db
145
+ connection: !ref Db
152
146
  from: feedback
153
147
  columns: [ id, text, source, score, created_at ]
154
148
  orderBy:
@@ -172,9 +166,7 @@ routes:
172
166
  required: [ id ]
173
167
  handler:
174
168
  kind: Sql.Select
175
- connection:
176
- kind: Sql.Connection
177
- name: Db
169
+ connection: !ref Db
178
170
  from: feedback
179
171
  columns: [ id, text, source, score, created_at ]
180
172
  where:
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAiB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA2hB/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAypBvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CACP,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,EAI1B,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,GACtC,gBAAgB,EAAE;IAqBrB,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAiB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4hB/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAkqBvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CACP,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,EAI1B,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,GACtC,gBAAgB,EAAE;IAerB,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
package/dist/analyzer.js CHANGED
@@ -18,6 +18,7 @@ import { validateExtends } from "./validate-extends.js";
18
18
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
19
19
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
20
20
  import { validateReferences } from "./validate-references.js";
21
+ import { validateReferenceForms } from "./validate-reference-forms.js";
21
22
  import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
22
23
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
23
24
  const SELF_PREFIX = "Self.";
@@ -646,13 +647,21 @@ export class StaticAnalyzer {
646
647
  : def;
647
648
  defs.register(normalized);
648
649
  }
650
+ // Reference-form validation — enforce `!ref` as the only reference shape.
651
+ // Runs on the RAW manifests, BEFORE inline extraction and sentinel
652
+ // resolution, while an author-written `{kind, name}` is still
653
+ // distinguishable from the resolver's own substitution (after Phase 2/2.5
654
+ // they are the same object).
655
+ if (!options?.skipValidation) {
656
+ diagnostics.push(...validateReferenceForms(manifests, defs, aliases, aliasesByModule));
657
+ }
649
658
  // Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
650
659
  const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
651
660
  // Phase 2.5: resolve `!ref <name>` sentinels at every ref slot to canonical
652
661
  // {kind, name} objects so downstream phases (validation, dependency graph,
653
662
  // kernel controllers) see a uniform shape. Runs after normalize so both
654
663
  // original and inline-extracted manifests have their sentinels resolved.
655
- resolveRefSentinels(allManifests, defs, aliases, aliasesByModule);
664
+ resolveRefSentinels(allManifests, aliases, aliasesByModule);
656
665
  // Trusted-input fast path: when the caller has already attested that
657
666
  // this exact manifest set passes analysis (e.g. via the kernel's
658
667
  // hash-stamped `.validated.json` cache), skip the validation walk.
@@ -1058,7 +1067,7 @@ export class StaticAnalyzer {
1058
1067
  // Resolve !ref sentinels after normalize so both the original and
1059
1068
  // inline-extracted manifests get their refs canonicalized to
1060
1069
  // {kind, name} for the kernel that consumes this output.
1061
- resolveRefSentinels(normalized, ctx.definitions, ctx.aliases, ctx.aliasesByModule, crossModuleTargets ?? []);
1070
+ resolveRefSentinels(normalized, ctx.aliases, ctx.aliasesByModule, crossModuleTargets ?? []);
1062
1071
  return normalized;
1063
1072
  }
1064
1073
  prepare(manifests, registry) {
@@ -1 +1 @@
1
- {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EAsd/C,CAAC"}
1
+ {"version":3,"file":"builtins.d.ts","sourceRoot":"","sources":["../src/builtins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEvD,eAAO,MAAM,eAAe,EAAE,kBAAkB,EAqd/C,CAAC"}
package/dist/builtins.js CHANGED
@@ -234,7 +234,7 @@ export const KERNEL_BUILTINS = [
234
234
  },
235
235
  // Gated reference: run() a Runnable/Service only when the
236
236
  // `when` CEL guard holds. Discriminated by the `ref` key. `ref`
237
- // is a bare name or a resolved `!ref` (`{ kind, name }`).
237
+ // is a `!ref` that resolves to the `{ kind, name }` shape below.
238
238
  {
239
239
  type: "object",
240
240
  required: ["ref"],
@@ -262,12 +262,11 @@ export const KERNEL_BUILTINS = [
262
262
  // with an optional `name` (for steps.<name>.result plumbing),
263
263
  // `when` guard, and `inputs`. Discriminated by the `invoke` key.
264
264
  // Control flow (if/while/switch/try) is not available here —
265
- // reach for Run.Sequence. `invoke` is ref-only and must resolve
266
- // to a `{ kind, name }` reference (a `!ref` / `{kind,name}`):
267
- // requiring `name` rejects an inline `{ kind }` definition (no
268
- // name) at analysis instead of failing at boot with an undefined
269
- // resource name. The Invocable/Runnable kind set mirrors
270
- // Run.Sequence invoke steps.
265
+ // reach for Run.Sequence. `invoke` is ref-only: a `!ref` that
266
+ // resolves to the `{ kind, name }` shape below. Requiring `name`
267
+ // rejects an inline `{ kind }` definition (no name) at analysis
268
+ // instead of failing at boot with an undefined resource name. The
269
+ // Invocable/Runnable kind set mirrors Run.Sequence invoke steps.
271
270
  {
272
271
  type: "object",
273
272
  required: ["invoke"],
@@ -1,10 +1,23 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
- import type { DefinitionRegistry } from "./definition-registry.js";
4
3
  /**
5
- * Walks every `x-telo-ref` slot in every non-system resource and rewrites
6
- * `!ref <name>` sentinels in-place to `{kind, name}` (local) or
7
- * `{kind, name, alias}` (cross-module).
4
+ * Rewrites every `!ref <name>` sentinel in each non-system resource's value tree
5
+ * to `{kind, name}` (local) or `{kind, name, alias}` (cross-module), in place.
6
+ *
7
+ * The walk is value-tree-driven, not field-map-driven: a `!ref` tag is an
8
+ * *explicit* reference marker, so any sentinel found anywhere is unambiguously a
9
+ * reference and is resolved. This reaches sites the field map intentionally does
10
+ * not descend — notably `Run.Sequence` step `invoke`s (behind a local `$ref`)
11
+ * and references nested inside inline definitions — so every downstream consumer
12
+ * (Phase-5 injection, the runtime controllers, the analyzer's step-context and
13
+ * dependency passes) sees the uniform `{kind, name}` shape regardless of where
14
+ * the reference was written.
15
+ *
16
+ * Resolving a sentinel here does NOT cause Phase-5 injection: that pass is
17
+ * driven by the field map, which still excludes step `invoke`s, so a resolved
18
+ * step invoke stays `{kind, name}` and is dispatched through
19
+ * `executeInvokeStep` (preserving `<Kind>.<Name>.Invoked` events) rather than
20
+ * being replaced with a live instance.
8
21
  *
9
22
  * Reference grammar — the tag's source string is split on the FIRST dot:
10
23
  * - `!ref writeLine` → local resource `writeLine`
@@ -14,12 +27,14 @@ import type { DefinitionRegistry } from "./definition-registry.js";
14
27
  *
15
28
  * Aliases are PascalCase identifiers without dots and resource names carry no dots
16
29
  * (enforced as a hard diagnostic), so the first-dot split is unambiguous. When the
17
- * name doesn't resolve, the sentinel is left in place so `validateReferences` emits the
18
- * `UNRESOLVED_REFERENCE` diagnostic with full context.
30
+ * name doesn't resolve (e.g. a scope-local target, or a cross-module reference in
31
+ * partial single-file analysis), the sentinel is left in place — the runtime
32
+ * resolves scope-local names on demand, and `validateReferences` emits the
33
+ * `UNRESOLVED_REFERENCE` diagnostic for genuine misses.
19
34
  *
20
35
  * Forwarded foreign resources (an imported library's exported instances, carrying a
21
36
  * `metadata.module` that isn't a root module) are resolution TARGETS only — they are not
22
37
  * re-walked as sources here, since their own ref slots belong to their own module scope.
23
38
  */
24
- export declare function resolveRefSentinels(resources: ResourceManifest[], registry: DefinitionRegistry, aliases?: AliasResolver, aliasesByModule?: Map<string, AliasResolver>, crossModuleTargets?: ResourceManifest[]): void;
39
+ export declare function resolveRefSentinels(resources: ResourceManifest[], aliases?: AliasResolver, aliasesByModule?: Map<string, AliasResolver>, crossModuleTargets?: ResourceManifest[]): void;
25
40
  //# sourceMappingURL=resolve-ref-sentinels.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-ref-sentinels.d.ts","sourceRoot":"","sources":["../src/resolve-ref-sentinels.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAQnE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,CAAC,EAAE,aAAa,EACvB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,EAK5C,kBAAkB,GAAE,gBAAgB,EAAO,GAC1C,IAAI,CAmFN"}
1
+ {"version":3,"file":"resolve-ref-sentinels.d.ts","sourceRoot":"","sources":["../src/resolve-ref-sentinels.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAOzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,aAAa,EACvB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,EAK5C,kBAAkB,GAAE,gBAAgB,EAAO,GAC1C,IAAI,CAgFN"}
@@ -1,10 +1,23 @@
1
- import { isRefSentinel } from "@telorun/templating";
2
- import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
1
+ import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
3
2
  import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
4
3
  /**
5
- * Walks every `x-telo-ref` slot in every non-system resource and rewrites
6
- * `!ref <name>` sentinels in-place to `{kind, name}` (local) or
7
- * `{kind, name, alias}` (cross-module).
4
+ * Rewrites every `!ref <name>` sentinel in each non-system resource's value tree
5
+ * to `{kind, name}` (local) or `{kind, name, alias}` (cross-module), in place.
6
+ *
7
+ * The walk is value-tree-driven, not field-map-driven: a `!ref` tag is an
8
+ * *explicit* reference marker, so any sentinel found anywhere is unambiguously a
9
+ * reference and is resolved. This reaches sites the field map intentionally does
10
+ * not descend — notably `Run.Sequence` step `invoke`s (behind a local `$ref`)
11
+ * and references nested inside inline definitions — so every downstream consumer
12
+ * (Phase-5 injection, the runtime controllers, the analyzer's step-context and
13
+ * dependency passes) sees the uniform `{kind, name}` shape regardless of where
14
+ * the reference was written.
15
+ *
16
+ * Resolving a sentinel here does NOT cause Phase-5 injection: that pass is
17
+ * driven by the field map, which still excludes step `invoke`s, so a resolved
18
+ * step invoke stays `{kind, name}` and is dispatched through
19
+ * `executeInvokeStep` (preserving `<Kind>.<Name>.Invoked` events) rather than
20
+ * being replaced with a live instance.
8
21
  *
9
22
  * Reference grammar — the tag's source string is split on the FIRST dot:
10
23
  * - `!ref writeLine` → local resource `writeLine`
@@ -14,14 +27,16 @@ import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
14
27
  *
15
28
  * Aliases are PascalCase identifiers without dots and resource names carry no dots
16
29
  * (enforced as a hard diagnostic), so the first-dot split is unambiguous. When the
17
- * name doesn't resolve, the sentinel is left in place so `validateReferences` emits the
18
- * `UNRESOLVED_REFERENCE` diagnostic with full context.
30
+ * name doesn't resolve (e.g. a scope-local target, or a cross-module reference in
31
+ * partial single-file analysis), the sentinel is left in place — the runtime
32
+ * resolves scope-local names on demand, and `validateReferences` emits the
33
+ * `UNRESOLVED_REFERENCE` diagnostic for genuine misses.
19
34
  *
20
35
  * Forwarded foreign resources (an imported library's exported instances, carrying a
21
36
  * `metadata.module` that isn't a root module) are resolution TARGETS only — they are not
22
37
  * re-walked as sources here, since their own ref slots belong to their own module scope.
23
38
  */
24
- export function resolveRefSentinels(resources, registry, aliases, aliasesByModule,
39
+ export function resolveRefSentinels(resources, aliases, aliasesByModule,
25
40
  // Extra foreign resources used only as cross-module resolution TARGETS (not mutated, not
26
41
  // walked as sources). The kernel passes the analyzer-flattened set here so the runtime
27
42
  // pass — which loads the entry module only — can still resolve `!ref Alias.name` against
@@ -81,138 +96,33 @@ crossModuleTargets = []) {
81
96
  }
82
97
  return undefined;
83
98
  };
84
- const processResource = (r) => {
85
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
86
- return;
87
- const fieldMap = aliases && aliasesByModule
88
- ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
89
- : registry.getFieldMapForKind(r.kind, aliases);
90
- if (!fieldMap)
91
- return;
92
- for (const [fieldPath, entry] of fieldMap) {
93
- const parts = fieldPath.split(".");
94
- if (isRefEntry(entry)) {
95
- descend(r, parts, resolveTarget);
96
- }
97
- else if (isScopeEntry(entry)) {
98
- // x-telo-scope resources (e.g. a Run.Sequence `with` server) carry their own ref
99
- // slots. The top-level walk skips scope contents, so recurse so a scoped resource's
100
- // `!ref` (e.g. an Http.Server mount) is canonicalized to {kind, name} like any other.
101
- forEachScopeResource(r, parts, processResource);
102
- }
99
+ // Resolve every `!ref` sentinel in the tree; leave opaque tagged / precompiled
100
+ // nodes (e.g. `!cel`) untouched and don't descend into them.
101
+ const walk = (value) => {
102
+ if (isRefSentinel(value)) {
103
+ return resolveTarget(value.source) ?? value;
103
104
  }
105
+ if (value === null || typeof value !== "object")
106
+ return value;
107
+ if (isTaggedSentinel(value))
108
+ return value;
109
+ if (value.__compiled)
110
+ return value;
111
+ if (Array.isArray(value)) {
112
+ for (let i = 0; i < value.length; i++)
113
+ value[i] = walk(value[i]);
114
+ return value;
115
+ }
116
+ const obj = value;
117
+ for (const key of Object.keys(obj))
118
+ obj[key] = walk(obj[key]);
119
+ return value;
104
120
  };
105
121
  for (const r of resources) {
106
122
  if (isForeign(r))
107
123
  continue;
108
- processResource(r);
109
- }
110
- }
111
- /**
112
- * Navigates `obj` along the scope field path (dot notation, `[]` = array items) and
113
- * invokes `cb` on every resource-like object found — any value carrying a `kind` string.
114
- *
115
- * Two-phase design:
116
- *
117
- * Phase 1 — path-walk: steps through each `parts` segment. `[]`-suffixed parts spread
118
- * the array into individual elements so `current` always ends up holding scalars or
119
- * plain objects, never intermediate arrays. Non-`[]` parts push the value as-is.
120
- *
121
- * Phase 2 — terminal visit: after the walk, `current` contains the values at the end
122
- * of the path. These are always scalars or plain objects because of the `[]` spreading
123
- * above, EXCEPT when a scope field is typed as an array in the schema but the path
124
- * was authored WITHOUT a `[]` suffix. The `visit` function handles that case by
125
- * recursing one level into arrays so `cb` is always called on resource objects, not
126
- * on their container.
127
- */
128
- function forEachScopeResource(obj, parts, cb) {
129
- let current = [obj];
130
- for (const part of parts) {
131
- const isArr = part.endsWith("[]");
132
- const key = isArr ? part.slice(0, -2) : part;
133
- const next = [];
134
- for (const node of current) {
135
- if (!node || typeof node !== "object")
136
- continue;
137
- const val = node[key];
138
- if (val == null)
139
- continue;
140
- if (isArr && Array.isArray(val))
141
- next.push(...val);
142
- else if (!isArr)
143
- next.push(val);
144
- }
145
- current = next;
146
- }
147
- const visit = (node) => {
148
- if (Array.isArray(node)) {
149
- for (const elem of node)
150
- visit(elem);
151
- }
152
- else if (node && typeof node === "object" && typeof node.kind === "string") {
153
- cb(node);
154
- }
155
- };
156
- for (const node of current)
157
- visit(node);
158
- }
159
- /** Walks `obj` along `fieldPath` parts (dot notation with `[]` for arrays and `{}` for
160
- * additionalProperties-typed maps) and replaces any `!ref` sentinel at the terminal slot
161
- * with its resolved `{kind, name, alias?}`. Mutates the parent container in place. */
162
- function descend(obj, parts, resolve) {
163
- if (obj == null || typeof obj !== "object" || parts.length === 0)
164
- return;
165
- const [head, ...rest] = parts;
166
- if (head === "{}") {
167
- const container = obj;
168
- for (const key of Object.keys(container)) {
169
- const child = container[key];
170
- if (rest.length === 0) {
171
- if (isRefSentinel(child)) {
172
- const target = resolve(child.source);
173
- if (target)
174
- container[key] = target;
175
- }
176
- }
177
- else {
178
- descend(child, rest, resolve);
179
- }
180
- }
181
- return;
182
- }
183
- const isArr = head.endsWith("[]");
184
- const key = isArr ? head.slice(0, -2) : head;
185
- const container = obj;
186
- const val = container[key];
187
- if (val == null)
188
- return;
189
- if (isArr) {
190
- if (!Array.isArray(val))
191
- return;
192
- for (let i = 0; i < val.length; i++) {
193
- if (rest.length === 0) {
194
- const elem = val[i];
195
- if (isRefSentinel(elem)) {
196
- const target = resolve(elem.source);
197
- if (target)
198
- val[i] = target;
199
- }
200
- }
201
- else {
202
- descend(val[i], rest, resolve);
203
- }
204
- }
205
- }
206
- else {
207
- if (rest.length === 0) {
208
- if (isRefSentinel(val)) {
209
- const target = resolve(val.source);
210
- if (target)
211
- container[key] = target;
212
- }
213
- }
214
- else {
215
- descend(val, rest, resolve);
216
- }
124
+ if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
125
+ continue;
126
+ walk(r);
217
127
  }
218
128
  }
@@ -1 +1 @@
1
- {"version":3,"file":"schema-compat.d.ts","sourceRoot":"","sources":["../src/schema-compat.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,GAAG,KAA0C,CAAC;AAEpD;;;;;;;mCAOmC;AACnC,wBAAgB,SAAS,IAAI,YAAY,CAAC,OAAO,GAAG,CAAC,CAOpD;AAKD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;oEAEoE;AACpE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,mBAAmB,CAIrB;AAiDD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAelD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAGxE;AAuBD,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;CACd;AAED,0GAA0G;AAC1G,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,WAAW,EAAE,CAmB/F;AAED;qFACqF;AACrF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAQ7E;AAED;;;;6DAI6D;AAC7D,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAsBjC;AAED;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAGnD,CAAC;AAEF,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGzF;AAED,8DAA8D;AAC9D,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,CAyBnF;AAED,wFAAwF;AACxF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiChG;AAED,6EAA6E;AAC7E,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiB5E;AAID,0EAA0E;AAC1E,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAOtG;AAED,gGAAgG;AAChG,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAUlF;AAED;iGACiG;AACjG,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC/B,OAAO,CAqCT"}
1
+ {"version":3,"file":"schema-compat.d.ts","sourceRoot":"","sources":["../src/schema-compat.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,GAAG,KAA0C,CAAC;AAEpD;;;;;;;mCAOmC;AACnC,wBAAgB,SAAS,IAAI,YAAY,CAAC,OAAO,GAAG,CAAC,CAOpD;AAKD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,OAAO,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;oEAEoE;AACpE,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,mBAAmB,CAIrB;AAiDD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAelD;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAGxE;AAuBD,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;CACd;AAaD,0GAA0G;AAC1G,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,WAAW,EAAE,CA2B/F;AAED;qFACqF;AACrF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAQ7E;AAED;;;;6DAI6D;AAC7D,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAsBjC;AAED;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAGnD,CAAC;AAEF,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGzF;AAED,8DAA8D;AAC9D,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,GAAG,MAAM,CAyBnF;AAED,wFAAwF;AACxF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiChG;AAED,6EAA6E;AAC7E,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,OAAO,CAiB5E;AAID,0EAA0E;AAC1E,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAOtG;AAED,gGAAgG;AAChG,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAUlF;AAED;iGACiG;AACjG,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC/B,OAAO,CAqCT"}
@@ -1,6 +1,6 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
3
+ import { isRefSentinel, isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
4
4
  const Ajv = AjvModule.default ?? AjvModule;
5
5
  /** Creates a configured AJV instance (allErrors, strict: false, with formats).
6
6
  * Also registers the kernel manifest root schema under `telo://manifest` so
@@ -100,21 +100,41 @@ function ajvErrorToPath(err) {
100
100
  }
101
101
  return result;
102
102
  }
103
+ /** Does `schema` compile as-authored? Used to tell a malformed module schema
104
+ * (the author's problem) apart from a fault we introduced while normalizing it. */
105
+ function schemaCompiles(schema) {
106
+ try {
107
+ ajv.compile(schema);
108
+ return true;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
103
114
  /** Validate actual data against a JSON Schema. Returns issues with path info, or empty array if valid. */
104
115
  export function validateAgainstSchema(data, schema) {
105
116
  let validate = compiledSchemaValidators.get(schema);
106
117
  if (!validate) {
118
+ // Normalize outside the try: a fault in our own ref-slot normalization must
119
+ // surface, never be mistaken for the module author's schema being malformed.
120
+ // Drop the legacy scalar `type` an older published module may still pin on
121
+ // its `x-telo-ref` slots so a resolved reference object validates.
122
+ const normalized = normalizeRefSlots(schema);
107
123
  try {
108
- validate = ajv.compile(schema);
109
- compiledSchemaValidators.set(schema, validate);
124
+ validate = ajv.compile(normalized);
110
125
  }
111
- catch {
112
- // A schema that won't compile is reported loudly (once, on the owning
113
- // definition) by the analyzer's definition-schema compile check
114
- // (`DefinitionRegistry.schemaCompileError`), so returning `[]` here does
115
- // not silently accept resources of that kind.
126
+ catch (err) {
127
+ // The normalized schema didn't compile. If the original schema is itself
128
+ // malformed, that is the module author's error already surfaced once,
129
+ // anchored on the definition, by the analyzer's `SCHEMA_COMPILE_ERROR`
130
+ // pre-check (`DefinitionRegistry.schemaCompileError`); re-reporting it per
131
+ // resource would be noise, so skip. If the original compiles and only the
132
+ // normalized form fails, the fault is ours — let it throw.
133
+ if (schemaCompiles(schema))
134
+ throw err;
116
135
  return [];
117
136
  }
137
+ compiledSchemaValidators.set(schema, validate);
118
138
  }
119
139
  if (validate(data))
120
140
  return [];
@@ -1 +1 @@
1
- {"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtF,MAAM,WAAW,kBAAkB;IACjC;mEAC+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACnC;;kDAE8C;IAC9C,IAAI,CAAC,EAAE;QACL,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAAC;KACxD,CAAC;IACF,OAAO,CAAC,EAAE;QACR,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;KAC/C,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC;CACtC;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA6BjC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,IAAI,CAAC,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAChD,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqGrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB;AAWD;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,SAAM,GACT,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAAE,CAAC,CAGvD"}
1
+ {"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEtF,MAAM,WAAW,kBAAkB;IACjC;mEAC+D;IAC/D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACnC;;kDAE8C;IAC9C,IAAI,CAAC,EAAE;QACL,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAAC;KACxD,CAAC;IACF,OAAO,CAAC,EAAE;QACR,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;KAC/C,CAAC;IACF,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC;CACtC;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAkCjC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,IAAI,CAAC,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAChD,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqGrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB;AAWD;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,IAAI,SAAM,GACT,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CAAE,CAAC,CAGvD"}
@@ -27,6 +27,11 @@ export function resolveTypeFieldToSchema(value, allManifests) {
27
27
  if (obj.type || obj.properties) {
28
28
  return obj;
29
29
  }
30
+ // Named type reference resolved from a `!ref` → { kind, name } — resolve the
31
+ // named Telo.Type the same way as the bare-string form.
32
+ if (typeof obj.name === "string") {
33
+ return resolveTypeFieldToSchema(obj.name, allManifests);
34
+ }
30
35
  }
31
36
  return undefined;
32
37
  }
@@ -0,0 +1,28 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import { type AnalysisDiagnostic } from "./types.js";
5
+ /**
6
+ * Reference-form validation — the single enforcement point for "a reference is
7
+ * written `!ref <name>` (or `!ref <Alias>.<name>`), nothing else".
8
+ *
9
+ * Runs on the RAW manifest set, BEFORE inline-resource extraction and `!ref`
10
+ * sentinel resolution. That ordering is load-bearing: only at this point is an
11
+ * author-written value still distinguishable from the resolver's own
12
+ * substitution. After normalization both an author's `{kind, name}` and a
13
+ * resolved `!ref` are the same `{kind, name}` object, so no later pass — and no
14
+ * JSON Schema — can tell them apart.
15
+ *
16
+ * At every `x-telo-ref` slot the only accepted value is:
17
+ * - a `!ref` sentinel (or any tagged sentinel — e.g. a `${{ }}` ref passed
18
+ * through a template), or
19
+ * - an inline definition: a plain object with a `kind` and NO `name` (the
20
+ * extractor assigns the name), or
21
+ * - a `${{ }}` CEL expression string (a reference flowed through CEL).
22
+ *
23
+ * Rejected, each with an actionable diagnostic pointing at `!ref`:
24
+ * - the object form `{ kind, name }` (the old reference object), and
25
+ * - a bare string (the old name / dotted-FQN reference).
26
+ */
27
+ export declare function validateReferenceForms(resources: ResourceManifest[], registry: DefinitionRegistry, aliases?: AliasResolver, aliasesByModule?: Map<string, AliasResolver>): AnalysisDiagnostic[];
28
+ //# sourceMappingURL=validate-reference-forms.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-reference-forms.d.ts","sourceRoot":"","sources":["../src/validate-reference-forms.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAGnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIzE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,CAAC,EAAE,aAAa,EACvB,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GAC3C,kBAAkB,EAAE,CA+DtB"}
@@ -0,0 +1,91 @@
1
+ import { isTaggedSentinel } from "@telorun/templating";
2
+ import { visitManifest } from "./manifest-visitor.js";
3
+ import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
4
+ import { DiagnosticSeverity } from "./types.js";
5
+ const SOURCE = "telo-analyzer";
6
+ /**
7
+ * Reference-form validation — the single enforcement point for "a reference is
8
+ * written `!ref <name>` (or `!ref <Alias>.<name>`), nothing else".
9
+ *
10
+ * Runs on the RAW manifest set, BEFORE inline-resource extraction and `!ref`
11
+ * sentinel resolution. That ordering is load-bearing: only at this point is an
12
+ * author-written value still distinguishable from the resolver's own
13
+ * substitution. After normalization both an author's `{kind, name}` and a
14
+ * resolved `!ref` are the same `{kind, name}` object, so no later pass — and no
15
+ * JSON Schema — can tell them apart.
16
+ *
17
+ * At every `x-telo-ref` slot the only accepted value is:
18
+ * - a `!ref` sentinel (or any tagged sentinel — e.g. a `${{ }}` ref passed
19
+ * through a template), or
20
+ * - an inline definition: a plain object with a `kind` and NO `name` (the
21
+ * extractor assigns the name), or
22
+ * - a `${{ }}` CEL expression string (a reference flowed through CEL).
23
+ *
24
+ * Rejected, each with an actionable diagnostic pointing at `!ref`:
25
+ * - the object form `{ kind, name }` (the old reference object), and
26
+ * - a bare string (the old name / dotted-FQN reference).
27
+ */
28
+ export function validateReferenceForms(resources, registry, aliases, aliasesByModule) {
29
+ if (!aliases)
30
+ return [];
31
+ const diagnostics = [];
32
+ const isForeign = (r) => r.metadata?.forwardedExport === true;
33
+ const localResources = resources.filter((r) => !isForeign(r));
34
+ visitManifest(localResources, registry, {
35
+ onRef: (e) => {
36
+ const value = e.value;
37
+ // `!ref` and `!cel`/`${{ }}` sentinels are the supported shapes.
38
+ if (isTaggedSentinel(value))
39
+ return;
40
+ const r = e.source;
41
+ const resourceLabel = `${r.kind}/${r.metadata.name}`;
42
+ const resourceData = { kind: r.kind, name: r.metadata.name };
43
+ const filePath = r.metadata?.source;
44
+ const path = e.concretePath;
45
+ if (typeof value === "string") {
46
+ // A `${{ }}` reference flowed through CEL is fine; any other bare
47
+ // string at a ref slot is the removed string / dotted-FQN form.
48
+ if (value.includes("${{"))
49
+ return;
50
+ diagnostics.push({
51
+ severity: DiagnosticSeverity.Error,
52
+ code: "INVALID_REFERENCE_FORM",
53
+ source: SOURCE,
54
+ message: `${resourceLabel}: string reference at '${path}' → '${value}' is not supported; write it as '!ref ${refHint(value)}'`,
55
+ data: { resource: resourceData, filePath, path },
56
+ });
57
+ return;
58
+ }
59
+ if (value && typeof value === "object" && !Array.isArray(value)) {
60
+ const obj = value;
61
+ // A plain object is an inline definition unless it names a resource —
62
+ // a `name` makes it the removed `{ kind, name }` reference object.
63
+ if (typeof obj.name === "string" && typeof obj.kind === "string") {
64
+ diagnostics.push({
65
+ severity: DiagnosticSeverity.Error,
66
+ code: "INVALID_REFERENCE_FORM",
67
+ source: SOURCE,
68
+ message: `${resourceLabel}: object reference '{ kind, name }' at '${path}' is not supported; write it as '!ref ${obj.name}'`,
69
+ data: { resource: resourceData, filePath, path },
70
+ });
71
+ }
72
+ }
73
+ },
74
+ }, {
75
+ aliases,
76
+ aliasesByModule,
77
+ skipKinds: SYSTEM_KINDS,
78
+ expand: true,
79
+ discoverNestedRefs: true,
80
+ });
81
+ return diagnostics;
82
+ }
83
+ /** Best-effort name for the `!ref` suggestion in a string-ref diagnostic: a
84
+ * dotted-FQN (`Http.Api.UsersApi`) keeps its last segment, an alias-qualified
85
+ * name (`Console.writeLine`) is left intact, a bare name passes through. */
86
+ function refHint(value) {
87
+ const dotCount = (value.match(/\./g) ?? []).length;
88
+ if (dotCount >= 2)
89
+ return value.slice(value.lastIndexOf(".") + 1);
90
+ return value;
91
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAMrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAgftB"}
1
+ {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAMrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CA6ctB"}
@@ -272,43 +272,10 @@ export function validateReferences(resources, context) {
272
272
  }
273
273
  return;
274
274
  }
275
- // Name-only reference (plain string) look up by name to validate.
276
- // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
277
- // extract the resource name from the last dot segment.
278
- if (typeof val === "string") {
279
- const lastDot = val.lastIndexOf(".");
280
- const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
281
- const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
282
- const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
283
- if (!target) {
284
- // Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
285
- // The resource lives in the imported module's scope and can't be validated here.
286
- // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
287
- // kinds — those must be validated.
288
- if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
289
- return;
290
- }
291
- diagnostics.push({
292
- severity: DiagnosticSeverity.Error,
293
- code: "UNRESOLVED_REFERENCE",
294
- source: SOURCE,
295
- message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
296
- data: { resource: resourceData, filePath, path: concretePath },
297
- });
298
- return;
299
- }
300
- const kindErrors = checkKind(target.kind, entry, registry, aliases);
301
- if (kindErrors.length > 0) {
302
- diagnostics.push({
303
- severity: DiagnosticSeverity.Error,
304
- code: "REFERENCE_KIND_MISMATCH",
305
- source: SOURCE,
306
- message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
307
- data: { resource: resourceData, filePath, path: concretePath },
308
- });
309
- }
310
- return;
311
- }
275
+ // Bare strings are no longer a reference shape `validateReferenceForms`
276
+ // rejects an author-written string at a ref slot before this pass runs,
277
+ // and a `${{ }}` reference flowed through CEL is resolved/typed
278
+ // elsewhere. Anything still a string here is not a reference to resolve.
312
279
  if (typeof val !== "object")
313
280
  return;
314
281
  const refVal = val;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.21.0",
3
+ "version": "0.22.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": "0.7.0"
45
+ "@telorun/templating": "0.8.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
package/src/analyzer.ts CHANGED
@@ -36,6 +36,7 @@ import { validateExtends } from "./validate-extends.js";
36
36
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
37
37
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
38
38
  import { validateReferences } from "./validate-references.js";
39
+ import { validateReferenceForms } from "./validate-reference-forms.js";
39
40
  import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
40
41
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
41
42
 
@@ -774,6 +775,15 @@ export class StaticAnalyzer {
774
775
  defs.register(normalized);
775
776
  }
776
777
 
778
+ // Reference-form validation — enforce `!ref` as the only reference shape.
779
+ // Runs on the RAW manifests, BEFORE inline extraction and sentinel
780
+ // resolution, while an author-written `{kind, name}` is still
781
+ // distinguishable from the resolver's own substitution (after Phase 2/2.5
782
+ // they are the same object).
783
+ if (!options?.skipValidation) {
784
+ diagnostics.push(...validateReferenceForms(manifests, defs, aliases, aliasesByModule));
785
+ }
786
+
777
787
  // Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
778
788
  const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
779
789
 
@@ -781,7 +791,7 @@ export class StaticAnalyzer {
781
791
  // {kind, name} objects so downstream phases (validation, dependency graph,
782
792
  // kernel controllers) see a uniform shape. Runs after normalize so both
783
793
  // original and inline-extracted manifests have their sentinels resolved.
784
- resolveRefSentinels(allManifests, defs, aliases, aliasesByModule);
794
+ resolveRefSentinels(allManifests, aliases, aliasesByModule);
785
795
 
786
796
  // Trusted-input fast path: when the caller has already attested that
787
797
  // this exact manifest set passes analysis (e.g. via the kernel's
@@ -1286,13 +1296,7 @@ export class StaticAnalyzer {
1286
1296
  // Resolve !ref sentinels after normalize so both the original and
1287
1297
  // inline-extracted manifests get their refs canonicalized to
1288
1298
  // {kind, name} for the kernel that consumes this output.
1289
- resolveRefSentinels(
1290
- normalized,
1291
- ctx.definitions!,
1292
- ctx.aliases,
1293
- ctx.aliasesByModule,
1294
- crossModuleTargets ?? [],
1295
- );
1299
+ resolveRefSentinels(normalized, ctx.aliases, ctx.aliasesByModule, crossModuleTargets ?? []);
1296
1300
  return normalized;
1297
1301
  }
1298
1302
 
package/src/builtins.ts CHANGED
@@ -236,7 +236,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
236
236
  },
237
237
  // Gated reference: run() a Runnable/Service only when the
238
238
  // `when` CEL guard holds. Discriminated by the `ref` key. `ref`
239
- // is a bare name or a resolved `!ref` (`{ kind, name }`).
239
+ // is a `!ref` that resolves to the `{ kind, name }` shape below.
240
240
  {
241
241
  type: "object",
242
242
  required: ["ref"],
@@ -264,12 +264,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
264
264
  // with an optional `name` (for steps.<name>.result plumbing),
265
265
  // `when` guard, and `inputs`. Discriminated by the `invoke` key.
266
266
  // Control flow (if/while/switch/try) is not available here —
267
- // reach for Run.Sequence. `invoke` is ref-only and must resolve
268
- // to a `{ kind, name }` reference (a `!ref` / `{kind,name}`):
269
- // requiring `name` rejects an inline `{ kind }` definition (no
270
- // name) at analysis instead of failing at boot with an undefined
271
- // resource name. The Invocable/Runnable kind set mirrors
272
- // Run.Sequence invoke steps.
267
+ // reach for Run.Sequence. `invoke` is ref-only: a `!ref` that
268
+ // resolves to the `{ kind, name }` shape below. Requiring `name`
269
+ // rejects an inline `{ kind }` definition (no name) at analysis
270
+ // instead of failing at boot with an undefined resource name. The
271
+ // Invocable/Runnable kind set mirrors Run.Sequence invoke steps.
273
272
  {
274
273
  type: "object",
275
274
  required: ["invoke"],
@@ -1,8 +1,6 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
- import { isRefSentinel } from "@telorun/templating";
2
+ import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
- import type { DefinitionRegistry } from "./definition-registry.js";
5
- import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
6
4
  import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
5
 
8
6
  /** Resolved ref shape written in place of a `!ref` sentinel. `alias` is set only for
@@ -10,9 +8,23 @@ import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
10
8
  type ResolvedRef = { kind: string; name: string; alias?: string };
11
9
 
12
10
  /**
13
- * Walks every `x-telo-ref` slot in every non-system resource and rewrites
14
- * `!ref <name>` sentinels in-place to `{kind, name}` (local) or
15
- * `{kind, name, alias}` (cross-module).
11
+ * Rewrites every `!ref <name>` sentinel in each non-system resource's value tree
12
+ * to `{kind, name}` (local) or `{kind, name, alias}` (cross-module), in place.
13
+ *
14
+ * The walk is value-tree-driven, not field-map-driven: a `!ref` tag is an
15
+ * *explicit* reference marker, so any sentinel found anywhere is unambiguously a
16
+ * reference and is resolved. This reaches sites the field map intentionally does
17
+ * not descend — notably `Run.Sequence` step `invoke`s (behind a local `$ref`)
18
+ * and references nested inside inline definitions — so every downstream consumer
19
+ * (Phase-5 injection, the runtime controllers, the analyzer's step-context and
20
+ * dependency passes) sees the uniform `{kind, name}` shape regardless of where
21
+ * the reference was written.
22
+ *
23
+ * Resolving a sentinel here does NOT cause Phase-5 injection: that pass is
24
+ * driven by the field map, which still excludes step `invoke`s, so a resolved
25
+ * step invoke stays `{kind, name}` and is dispatched through
26
+ * `executeInvokeStep` (preserving `<Kind>.<Name>.Invoked` events) rather than
27
+ * being replaced with a live instance.
16
28
  *
17
29
  * Reference grammar — the tag's source string is split on the FIRST dot:
18
30
  * - `!ref writeLine` → local resource `writeLine`
@@ -22,8 +34,10 @@ type ResolvedRef = { kind: string; name: string; alias?: string };
22
34
  *
23
35
  * Aliases are PascalCase identifiers without dots and resource names carry no dots
24
36
  * (enforced as a hard diagnostic), so the first-dot split is unambiguous. When the
25
- * name doesn't resolve, the sentinel is left in place so `validateReferences` emits the
26
- * `UNRESOLVED_REFERENCE` diagnostic with full context.
37
+ * name doesn't resolve (e.g. a scope-local target, or a cross-module reference in
38
+ * partial single-file analysis), the sentinel is left in place — the runtime
39
+ * resolves scope-local names on demand, and `validateReferences` emits the
40
+ * `UNRESOLVED_REFERENCE` diagnostic for genuine misses.
27
41
  *
28
42
  * Forwarded foreign resources (an imported library's exported instances, carrying a
29
43
  * `metadata.module` that isn't a root module) are resolution TARGETS only — they are not
@@ -31,7 +45,6 @@ type ResolvedRef = { kind: string; name: string; alias?: string };
31
45
  */
32
46
  export function resolveRefSentinels(
33
47
  resources: ResourceManifest[],
34
- registry: DefinitionRegistry,
35
48
  aliases?: AliasResolver,
36
49
  aliasesByModule?: Map<string, AliasResolver>,
37
50
  // Extra foreign resources used only as cross-module resolution TARGETS (not mutated, not
@@ -96,134 +109,27 @@ export function resolveRefSentinels(
96
109
  return undefined;
97
110
  };
98
111
 
99
- const processResource = (r: ResourceManifest): void => {
100
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) return;
101
-
102
- const fieldMap =
103
- aliases && aliasesByModule
104
- ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
105
- : registry.getFieldMapForKind(r.kind, aliases);
106
- if (!fieldMap) return;
107
-
108
- for (const [fieldPath, entry] of fieldMap) {
109
- const parts = fieldPath.split(".");
110
- if (isRefEntry(entry)) {
111
- descend(r as Record<string, unknown>, parts, resolveTarget);
112
- } else if (isScopeEntry(entry)) {
113
- // x-telo-scope resources (e.g. a Run.Sequence `with` server) carry their own ref
114
- // slots. The top-level walk skips scope contents, so recurse so a scoped resource's
115
- // `!ref` (e.g. an Http.Server mount) is canonicalized to {kind, name} like any other.
116
- forEachScopeResource(r as Record<string, unknown>, parts, processResource);
117
- }
112
+ // Resolve every `!ref` sentinel in the tree; leave opaque tagged / precompiled
113
+ // nodes (e.g. `!cel`) untouched and don't descend into them.
114
+ const walk = (value: unknown): unknown => {
115
+ if (isRefSentinel(value)) {
116
+ return resolveTarget(value.source) ?? value;
118
117
  }
118
+ if (value === null || typeof value !== "object") return value;
119
+ if (isTaggedSentinel(value)) return value;
120
+ if ((value as { __compiled?: unknown }).__compiled) return value;
121
+ if (Array.isArray(value)) {
122
+ for (let i = 0; i < value.length; i++) value[i] = walk(value[i]);
123
+ return value;
124
+ }
125
+ const obj = value as Record<string, unknown>;
126
+ for (const key of Object.keys(obj)) obj[key] = walk(obj[key]);
127
+ return value;
119
128
  };
120
129
 
121
130
  for (const r of resources) {
122
131
  if (isForeign(r)) continue;
123
- processResource(r);
124
- }
125
- }
126
-
127
- /**
128
- * Navigates `obj` along the scope field path (dot notation, `[]` = array items) and
129
- * invokes `cb` on every resource-like object found — any value carrying a `kind` string.
130
- *
131
- * Two-phase design:
132
- *
133
- * Phase 1 — path-walk: steps through each `parts` segment. `[]`-suffixed parts spread
134
- * the array into individual elements so `current` always ends up holding scalars or
135
- * plain objects, never intermediate arrays. Non-`[]` parts push the value as-is.
136
- *
137
- * Phase 2 — terminal visit: after the walk, `current` contains the values at the end
138
- * of the path. These are always scalars or plain objects because of the `[]` spreading
139
- * above, EXCEPT when a scope field is typed as an array in the schema but the path
140
- * was authored WITHOUT a `[]` suffix. The `visit` function handles that case by
141
- * recursing one level into arrays so `cb` is always called on resource objects, not
142
- * on their container.
143
- */
144
- function forEachScopeResource(
145
- obj: Record<string, unknown>,
146
- parts: string[],
147
- cb: (resource: ResourceManifest) => void,
148
- ): void {
149
- let current: unknown[] = [obj];
150
- for (const part of parts) {
151
- const isArr = part.endsWith("[]");
152
- const key = isArr ? part.slice(0, -2) : part;
153
- const next: unknown[] = [];
154
- for (const node of current) {
155
- if (!node || typeof node !== "object") continue;
156
- const val = (node as Record<string, unknown>)[key];
157
- if (val == null) continue;
158
- if (isArr && Array.isArray(val)) next.push(...val);
159
- else if (!isArr) next.push(val);
160
- }
161
- current = next;
162
- }
163
- const visit = (node: unknown): void => {
164
- if (Array.isArray(node)) {
165
- for (const elem of node) visit(elem);
166
- } else if (node && typeof node === "object" && typeof (node as { kind?: unknown }).kind === "string") {
167
- cb(node as ResourceManifest);
168
- }
169
- };
170
- for (const node of current) visit(node);
171
- }
172
-
173
- /** Walks `obj` along `fieldPath` parts (dot notation with `[]` for arrays and `{}` for
174
- * additionalProperties-typed maps) and replaces any `!ref` sentinel at the terminal slot
175
- * with its resolved `{kind, name, alias?}`. Mutates the parent container in place. */
176
- function descend(
177
- obj: unknown,
178
- parts: string[],
179
- resolve: (source: string) => ResolvedRef | undefined,
180
- ): void {
181
- if (obj == null || typeof obj !== "object" || parts.length === 0) return;
182
- const [head, ...rest] = parts;
183
-
184
- if (head === "{}") {
185
- const container = obj as Record<string, unknown>;
186
- for (const key of Object.keys(container)) {
187
- const child = container[key];
188
- if (rest.length === 0) {
189
- if (isRefSentinel(child)) {
190
- const target = resolve(child.source);
191
- if (target) container[key] = target;
192
- }
193
- } else {
194
- descend(child, rest, resolve);
195
- }
196
- }
197
- return;
198
- }
199
-
200
- const isArr = head.endsWith("[]");
201
- const key = isArr ? head.slice(0, -2) : head;
202
- const container = obj as Record<string, unknown>;
203
- const val = container[key];
204
- if (val == null) return;
205
-
206
- if (isArr) {
207
- if (!Array.isArray(val)) return;
208
- for (let i = 0; i < val.length; i++) {
209
- if (rest.length === 0) {
210
- const elem = val[i];
211
- if (isRefSentinel(elem)) {
212
- const target = resolve(elem.source);
213
- if (target) val[i] = target;
214
- }
215
- } else {
216
- descend(val[i], rest, resolve);
217
- }
218
- }
219
- } else {
220
- if (rest.length === 0) {
221
- if (isRefSentinel(val)) {
222
- const target = resolve(val.source);
223
- if (target) container[key] = target;
224
- }
225
- } else {
226
- descend(val, rest, resolve);
227
- }
132
+ if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
133
+ walk(r as Record<string, unknown>);
228
134
  }
229
135
  }
@@ -1,6 +1,6 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { isRefSentinel, isTaggedSentinel, ManifestRootSchema } from "@telorun/templating";
3
+ import { isRefSentinel, isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
4
4
 
5
5
  const Ajv = (AjvModule as any).default ?? AjvModule;
6
6
 
@@ -138,20 +138,39 @@ export interface SchemaIssue {
138
138
  path: string;
139
139
  }
140
140
 
141
+ /** Does `schema` compile as-authored? Used to tell a malformed module schema
142
+ * (the author's problem) apart from a fault we introduced while normalizing it. */
143
+ function schemaCompiles(schema: Record<string, any>): boolean {
144
+ try {
145
+ ajv.compile(schema);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
141
152
  /** Validate actual data against a JSON Schema. Returns issues with path info, or empty array if valid. */
142
153
  export function validateAgainstSchema(data: unknown, schema: Record<string, any>): SchemaIssue[] {
143
154
  let validate = compiledSchemaValidators.get(schema);
144
155
  if (!validate) {
156
+ // Normalize outside the try: a fault in our own ref-slot normalization must
157
+ // surface, never be mistaken for the module author's schema being malformed.
158
+ // Drop the legacy scalar `type` an older published module may still pin on
159
+ // its `x-telo-ref` slots so a resolved reference object validates.
160
+ const normalized = normalizeRefSlots(schema) as Record<string, any>;
145
161
  try {
146
- validate = ajv.compile(schema);
147
- compiledSchemaValidators.set(schema, validate);
148
- } catch {
149
- // A schema that won't compile is reported loudly (once, on the owning
150
- // definition) by the analyzer's definition-schema compile check
151
- // (`DefinitionRegistry.schemaCompileError`), so returning `[]` here does
152
- // not silently accept resources of that kind.
162
+ validate = ajv.compile(normalized);
163
+ } catch (err) {
164
+ // The normalized schema didn't compile. If the original schema is itself
165
+ // malformed, that is the module author's error already surfaced once,
166
+ // anchored on the definition, by the analyzer's `SCHEMA_COMPILE_ERROR`
167
+ // pre-check (`DefinitionRegistry.schemaCompileError`); re-reporting it per
168
+ // resource would be noise, so skip. If the original compiles and only the
169
+ // normalized form fails, the fault is ours — let it throw.
170
+ if (schemaCompiles(schema)) throw err;
153
171
  return [];
154
172
  }
173
+ compiledSchemaValidators.set(schema, validate);
155
174
  }
156
175
  if (validate(data)) return [];
157
176
  return (validate.errors ?? []).map((err: any) => ({
@@ -51,6 +51,11 @@ export function resolveTypeFieldToSchema(
51
51
  if (obj.type || obj.properties) {
52
52
  return obj;
53
53
  }
54
+ // Named type reference resolved from a `!ref` → { kind, name } — resolve the
55
+ // named Telo.Type the same way as the bare-string form.
56
+ if (typeof obj.name === "string") {
57
+ return resolveTypeFieldToSchema(obj.name, allManifests);
58
+ }
54
59
  }
55
60
 
56
61
  return undefined;
@@ -0,0 +1,110 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isTaggedSentinel } from "@telorun/templating";
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { visitManifest } from "./manifest-visitor.js";
6
+ import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
8
+
9
+ const SOURCE = "telo-analyzer";
10
+
11
+ /**
12
+ * Reference-form validation — the single enforcement point for "a reference is
13
+ * written `!ref <name>` (or `!ref <Alias>.<name>`), nothing else".
14
+ *
15
+ * Runs on the RAW manifest set, BEFORE inline-resource extraction and `!ref`
16
+ * sentinel resolution. That ordering is load-bearing: only at this point is an
17
+ * author-written value still distinguishable from the resolver's own
18
+ * substitution. After normalization both an author's `{kind, name}` and a
19
+ * resolved `!ref` are the same `{kind, name}` object, so no later pass — and no
20
+ * JSON Schema — can tell them apart.
21
+ *
22
+ * At every `x-telo-ref` slot the only accepted value is:
23
+ * - a `!ref` sentinel (or any tagged sentinel — e.g. a `${{ }}` ref passed
24
+ * through a template), or
25
+ * - an inline definition: a plain object with a `kind` and NO `name` (the
26
+ * extractor assigns the name), or
27
+ * - a `${{ }}` CEL expression string (a reference flowed through CEL).
28
+ *
29
+ * Rejected, each with an actionable diagnostic pointing at `!ref`:
30
+ * - the object form `{ kind, name }` (the old reference object), and
31
+ * - a bare string (the old name / dotted-FQN reference).
32
+ */
33
+ export function validateReferenceForms(
34
+ resources: ResourceManifest[],
35
+ registry: DefinitionRegistry,
36
+ aliases?: AliasResolver,
37
+ aliasesByModule?: Map<string, AliasResolver>,
38
+ ): AnalysisDiagnostic[] {
39
+ if (!aliases) return [];
40
+ const diagnostics: AnalysisDiagnostic[] = [];
41
+
42
+ const isForeign = (r: ResourceManifest): boolean =>
43
+ (r.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true;
44
+ const localResources = resources.filter((r) => !isForeign(r));
45
+
46
+ visitManifest(
47
+ localResources,
48
+ registry,
49
+ {
50
+ onRef: (e) => {
51
+ const value = e.value;
52
+ // `!ref` and `!cel`/`${{ }}` sentinels are the supported shapes.
53
+ if (isTaggedSentinel(value)) return;
54
+
55
+ const r = e.source;
56
+ const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
57
+ const resourceData = { kind: r.kind, name: r.metadata!.name as string };
58
+ const filePath = (r.metadata as { source?: string } | undefined)?.source;
59
+ const path = e.concretePath;
60
+
61
+ if (typeof value === "string") {
62
+ // A `${{ }}` reference flowed through CEL is fine; any other bare
63
+ // string at a ref slot is the removed string / dotted-FQN form.
64
+ if (value.includes("${{")) return;
65
+ diagnostics.push({
66
+ severity: DiagnosticSeverity.Error,
67
+ code: "INVALID_REFERENCE_FORM",
68
+ source: SOURCE,
69
+ message: `${resourceLabel}: string reference at '${path}' → '${value}' is not supported; write it as '!ref ${refHint(value)}'`,
70
+ data: { resource: resourceData, filePath, path },
71
+ });
72
+ return;
73
+ }
74
+
75
+ if (value && typeof value === "object" && !Array.isArray(value)) {
76
+ const obj = value as Record<string, unknown>;
77
+ // A plain object is an inline definition unless it names a resource —
78
+ // a `name` makes it the removed `{ kind, name }` reference object.
79
+ if (typeof obj.name === "string" && typeof obj.kind === "string") {
80
+ diagnostics.push({
81
+ severity: DiagnosticSeverity.Error,
82
+ code: "INVALID_REFERENCE_FORM",
83
+ source: SOURCE,
84
+ message: `${resourceLabel}: object reference '{ kind, name }' at '${path}' is not supported; write it as '!ref ${obj.name}'`,
85
+ data: { resource: resourceData, filePath, path },
86
+ });
87
+ }
88
+ }
89
+ },
90
+ },
91
+ {
92
+ aliases,
93
+ aliasesByModule,
94
+ skipKinds: SYSTEM_KINDS,
95
+ expand: true,
96
+ discoverNestedRefs: true,
97
+ },
98
+ );
99
+
100
+ return diagnostics;
101
+ }
102
+
103
+ /** Best-effort name for the `!ref` suggestion in a string-ref diagnostic: a
104
+ * dotted-FQN (`Http.Api.UsersApi`) keeps its last segment, an alias-qualified
105
+ * name (`Console.writeLine`) is left intact, a bare name passes through. */
106
+ function refHint(value: string): string {
107
+ const dotCount = (value.match(/\./g) ?? []).length;
108
+ if (dotCount >= 2) return value.slice(value.lastIndexOf(".") + 1);
109
+ return value;
110
+ }
@@ -289,45 +289,10 @@ export function validateReferences(
289
289
  return;
290
290
  }
291
291
 
292
- // Name-only reference (plain string) look up by name to validate.
293
- // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
294
- // extract the resource name from the last dot segment.
295
- if (typeof val === "string") {
296
- const lastDot = val.lastIndexOf(".");
297
- const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
298
- const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
299
- const target =
300
- byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
301
- if (!target) {
302
- // Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
303
- // The resource lives in the imported module's scope and can't be validated here.
304
- // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
305
- // kinds — those must be validated.
306
- if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
307
- return;
308
- }
309
- diagnostics.push({
310
- severity: DiagnosticSeverity.Error,
311
- code: "UNRESOLVED_REFERENCE",
312
- source: SOURCE,
313
- message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
314
- data: { resource: resourceData, filePath, path: concretePath },
315
- });
316
- return;
317
- }
318
- const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
319
- if (kindErrors.length > 0) {
320
- diagnostics.push({
321
- severity: DiagnosticSeverity.Error,
322
- code: "REFERENCE_KIND_MISMATCH",
323
- source: SOURCE,
324
- message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
325
- data: { resource: resourceData, filePath, path: concretePath },
326
- });
327
- }
328
- return;
329
- }
330
-
292
+ // Bare strings are no longer a reference shape `validateReferenceForms`
293
+ // rejects an author-written string at a ref slot before this pass runs,
294
+ // and a `${{ }}` reference flowed through CEL is resolved/typed
295
+ // elsewhere. Anything still a string here is not a reference to resolve.
331
296
  if (typeof val !== "object") return;
332
297
  const refVal = val as Record<string, unknown>;
333
298