@telorun/analyzer 0.13.0 → 0.15.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.
@@ -3,36 +3,89 @@ import { isRefEntry } from "./reference-field-map.js";
3
3
  import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
4
4
  /**
5
5
  * Walks every `x-telo-ref` slot in every non-system resource and rewrites
6
- * `!ref <name>` sentinels in-place to `{kind: <resolved-kind>, name}`.
6
+ * `!ref <name>` sentinels in-place to `{kind, name}` (local) or
7
+ * `{kind, name, alias}` (cross-module).
7
8
  *
8
- * The downstream pipeline (inline normalization, dependency graph, kernel
9
- * controllers) expects every ref-slot value to be either a `{kind, name}`
10
- * object, an inline-definition object, or a legacy bare string — resolving
11
- * sentinels here keeps that contract intact so each consumer doesn't need
12
- * its own sentinel branch.
9
+ * Reference grammar the tag's source string is split on the FIRST dot:
10
+ * - `!ref writeLine` → local resource `writeLine`
11
+ * - `!ref Self.writeLine` → local resource `writeLine` (explicit self-qualifier)
12
+ * - `!ref Console.writeLine` → instance `writeLine` exported by the import aliased
13
+ * `Console`, resolved against the forwarded foreign set
13
14
  *
14
- * The walker assigns `kind` by name lookup (resource names are unique
15
- * within a manifest scope). When the name doesn't resolve in the local
16
- * `byName` map, the sentinel is left in place so `validateReferences`
17
- * can emit the `UNRESOLVED_REFERENCE` diagnostic with full context.
15
+ * Aliases are PascalCase identifiers without dots and resource names carry no dots
16
+ * (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.
18
19
  *
19
- * Mutation strategy: the field-path walker descends the resource tree
20
- * directly and replaces the sentinel on its parent container. Re-parsing
21
- * a string-encoded concrete path (the earlier shape) coupled the writer
22
- * to the path-encoding rules of `resolveFieldEntries` — any new path
23
- * marker would silently break this writer. Descending directly avoids
24
- * that coupling.
20
+ * Forwarded foreign resources (an imported library's exported instances, carrying a
21
+ * `metadata.module` that isn't a root module) are resolution TARGETS only — they are not
22
+ * re-walked as sources here, since their own ref slots belong to their own module scope.
25
23
  */
26
- export function resolveRefSentinels(resources, registry, aliases, aliasesByModule) {
24
+ export function resolveRefSentinels(resources, registry, aliases, aliasesByModule,
25
+ // Extra foreign resources used only as cross-module resolution TARGETS (not mutated, not
26
+ // walked as sources). The kernel passes the analyzer-flattened set here so the runtime
27
+ // pass — which loads the entry module only — can still resolve `!ref Alias.name` against
28
+ // imported libraries' exported instances.
29
+ crossModuleTargets = []) {
30
+ const moduleOf = (r) => r.metadata?.module;
31
+ // Forwarded exports are flagged by flattenForAnalyzer (`metadata.forwardedExport`); they're
32
+ // cross-module resolution targets only — never walked as local ref sources here.
33
+ const isForeign = (r) => r.metadata?.forwardedExport === true;
34
+ // Local resources resolve a bare / `Self.`-qualified name; forwarded foreign exports
35
+ // resolve an `Alias.`-qualified name keyed by (module, name).
27
36
  const byName = new Map();
37
+ const byModuleName = new Map();
28
38
  for (const r of resources) {
29
- if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) {
30
- byName.set(r.metadata.name, r);
39
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind))
40
+ continue;
41
+ const name = r.metadata.name;
42
+ if (isForeign(r)) {
43
+ byModuleName.set(`${moduleOf(r)}\0${name}`, r);
44
+ }
45
+ else {
46
+ byName.set(name, r);
31
47
  }
32
48
  }
49
+ for (const r of crossModuleTargets) {
50
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForeign(r))
51
+ continue;
52
+ byModuleName.set(`${moduleOf(r)}\0${r.metadata.name}`, r);
53
+ }
54
+ const resolveTarget = (source) => {
55
+ const dot = source.indexOf(".");
56
+ if (dot === -1) {
57
+ const t = byName.get(source);
58
+ return t ? { kind: t.kind, name: source } : undefined;
59
+ }
60
+ const alias = source.slice(0, dot);
61
+ const name = source.slice(dot + 1);
62
+ if (alias === "Self") {
63
+ const t = byName.get(name);
64
+ return t ? { kind: t.kind, name } : undefined;
65
+ }
66
+ const module = aliases?.moduleForAlias(alias);
67
+ if (module) {
68
+ const t = byModuleName.get(`${module}\0${name}`);
69
+ if (t) {
70
+ // The foreign instance's `kind` is authored in ITS module's scope (e.g.
71
+ // `Self.WriteLine`); canonicalize to a scope-independent `<module>.<Kind>` for the
72
+ // consumer's kind check. `Self.` maps to the owning module directly — the forwarded
73
+ // library's Library doc (hence its `Self` alias) isn't in the consumer's manifest
74
+ // set — while other alias prefixes resolve via that module's forwarded import scope.
75
+ const rawKind = t.kind;
76
+ const foreignKind = rawKind.startsWith("Self.")
77
+ ? `${module}.${rawKind.slice("Self.".length)}`
78
+ : aliasesByModule?.get(module)?.resolveKind(rawKind) ?? rawKind;
79
+ return { kind: foreignKind, name, alias };
80
+ }
81
+ }
82
+ return undefined;
83
+ };
33
84
  for (const r of resources) {
34
85
  if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
35
86
  continue;
87
+ if (isForeign(r))
88
+ continue;
36
89
  const fieldMap = aliases && aliasesByModule
37
90
  ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
38
91
  : registry.getFieldMapForKind(r.kind, aliases);
@@ -41,37 +94,30 @@ export function resolveRefSentinels(resources, registry, aliases, aliasesByModul
41
94
  for (const [fieldPath, entry] of fieldMap) {
42
95
  if (!isRefEntry(entry))
43
96
  continue;
44
- replaceSentinelsAtPath(r, fieldPath, byName);
97
+ descend(r, fieldPath.split("."), resolveTarget);
45
98
  }
46
99
  }
47
100
  }
48
- /** Walks `obj` along `fieldPath` (dot notation with `[]` for arrays and
49
- * `{}` for additionalProperties-typed maps) and replaces any `!ref`
50
- * sentinel value at the terminal slot with `{kind, name}` looked up
51
- * via `byName`. Mutates the parent container in place; no string-path
52
- * round-trip. */
53
- function replaceSentinelsAtPath(obj, fieldPath, byName) {
54
- const parts = fieldPath.split(".");
55
- descend(obj, parts, byName);
56
- }
57
- function descend(obj, parts, byName) {
101
+ /** Walks `obj` along `fieldPath` parts (dot notation with `[]` for arrays and `{}` for
102
+ * additionalProperties-typed maps) and replaces any `!ref` sentinel at the terminal slot
103
+ * with its resolved `{kind, name, alias?}`. Mutates the parent container in place. */
104
+ function descend(obj, parts, resolve) {
58
105
  if (obj == null || typeof obj !== "object" || parts.length === 0)
59
106
  return;
60
107
  const [head, ...rest] = parts;
61
- // Map iteration: descend into every value of the current object.
62
108
  if (head === "{}") {
63
109
  const container = obj;
64
110
  for (const key of Object.keys(container)) {
65
111
  const child = container[key];
66
112
  if (rest.length === 0) {
67
113
  if (isRefSentinel(child)) {
68
- const target = byName.get(child.source);
114
+ const target = resolve(child.source);
69
115
  if (target)
70
- container[key] = { kind: target.kind, name: child.source };
116
+ container[key] = target;
71
117
  }
72
118
  }
73
119
  else {
74
- descend(child, rest, byName);
120
+ descend(child, rest, resolve);
75
121
  }
76
122
  }
77
123
  return;
@@ -89,26 +135,26 @@ function descend(obj, parts, byName) {
89
135
  if (rest.length === 0) {
90
136
  const elem = val[i];
91
137
  if (isRefSentinel(elem)) {
92
- const target = byName.get(elem.source);
138
+ const target = resolve(elem.source);
93
139
  if (target)
94
- val[i] = { kind: target.kind, name: elem.source };
140
+ val[i] = target;
95
141
  }
96
142
  }
97
143
  else {
98
- descend(val[i], rest, byName);
144
+ descend(val[i], rest, resolve);
99
145
  }
100
146
  }
101
147
  }
102
148
  else {
103
149
  if (rest.length === 0) {
104
150
  if (isRefSentinel(val)) {
105
- const target = byName.get(val.source);
151
+ const target = resolve(val.source);
106
152
  if (target)
107
- container[key] = { kind: target.kind, name: val.source };
153
+ container[key] = target;
108
154
  }
109
155
  }
110
156
  else {
111
- descend(val, rest, byName);
157
+ descend(val, rest, resolve);
112
158
  }
113
159
  }
114
160
  }
@@ -4,6 +4,10 @@ import type { DefinitionRegistry } from "./definition-registry.js";
4
4
  export interface ThrowsCodeMeta {
5
5
  data?: Record<string, any>;
6
6
  }
7
+ /** Code a non-`InvokeError` failure surfaces as inside a `catch` block. Mirrors
8
+ * `PLAIN_ERROR_CODE` in `@telorun/run`'s `toSequenceError`: any invoke can throw
9
+ * a plain error, which the catch sees as `error.code === "INTERNAL_ERROR"`. */
10
+ export declare const PLAIN_ERROR_CODE = "INTERNAL_ERROR";
7
11
  export interface ThrowsUnion {
8
12
  /** Code → per-code metadata (data schema, etc). Keys are the declared codes. */
9
13
  codes: Map<string, ThrowsCodeMeta>;
@@ -12,6 +16,12 @@ export interface ThrowsUnion {
12
16
  * an unknown kind was encountered, or a cycle short-circuited resolution.
13
17
  * Callers must treat unbounded unions as requiring a catch-all entry. */
14
18
  unbounded: boolean;
19
+ /** True when the block can fail with a non-`InvokeError` (any `invoke:` step).
20
+ * Such a failure surfaces inside an enclosing `catch` as `PLAIN_ERROR_CODE`,
21
+ * so a `throw: { code: "${{ error.code }}" }` rethrow can propagate it. Not
22
+ * injected into `codes` — only seeds `enclosingTryCodes` at a try/catch site,
23
+ * leaving non-rethrow unions untouched. */
24
+ canThrowPlain?: boolean;
15
25
  }
16
26
  export interface ResolveCtx {
17
27
  allManifests: ResourceManifest[];
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-throws-union.d.ts","sourceRoot":"","sources":["../src/resolve-throws-union.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC5B;AAED,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACnC;;;8EAG0E;IAC1E,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,gBAAgB,EAAE,CAAC;IACjC,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,aAAa,CAAC;IACvB,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAED,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,gBAAgB,EAAE,EAChC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,GACrB,UAAU,CAQZ;AA+BD;;;;8CAI8C;AAC9C,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,gBAAgB,EAC1B,GAAG,EAAE,UAAU,GACd,WAAW,CA+Cb"}
1
+ {"version":3,"file":"resolve-throws-union.d.ts","sourceRoot":"","sources":["../src/resolve-throws-union.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC5B;AAED;;gFAEgF;AAChF,eAAO,MAAM,gBAAgB,mBAAmB,CAAC;AAEjD,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACnC;;;8EAG0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB;;;;gDAI4C;IAC5C,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,gBAAgB,EAAE,CAAC;IACjC,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,aAAa,CAAC;IACvB,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAED,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,gBAAgB,EAAE,EAChC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,GACrB,UAAU,CAQZ;AAgCD;;;;8CAI8C;AAC9C,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,gBAAgB,EAC1B,GAAG,EAAE,UAAU,GACd,WAAW,CA+Cb"}
@@ -1,3 +1,8 @@
1
+ import { isTaggedSentinel } from "@telorun/templating";
2
+ /** Code a non-`InvokeError` failure surfaces as inside a `catch` block. Mirrors
3
+ * `PLAIN_ERROR_CODE` in `@telorun/run`'s `toSequenceError`: any invoke can throw
4
+ * a plain error, which the catch sees as `error.code === "INTERNAL_ERROR"`. */
5
+ export const PLAIN_ERROR_CODE = "INTERNAL_ERROR";
1
6
  export function createResolveCtx(allManifests, defs, aliases) {
2
7
  return {
3
8
  allManifests,
@@ -17,6 +22,8 @@ function unionInto(target, src) {
17
22
  }
18
23
  if (src.unbounded)
19
24
  target.unbounded = true;
25
+ if (src.canThrowPlain)
26
+ target.canThrowPlain = true;
20
27
  }
21
28
  function definitionFor(kind, defs, aliases) {
22
29
  const resolved = aliases.resolveKind(kind);
@@ -115,7 +122,11 @@ function collectStepArrayThrows(steps, invokeField, enclosingTryCodes, ctx) {
115
122
  * composers that reuse those shape conventions work without changes here. */
116
123
  function collectStepThrows(step, invokeField, enclosingTryCodes, ctx) {
117
124
  if (step[invokeField]) {
118
- return resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx);
125
+ // Any invoked resource can throw a non-InvokeError at runtime, which an
126
+ // enclosing catch surfaces as PLAIN_ERROR_CODE — record that possibility.
127
+ const u = cloneUnion(resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx));
128
+ u.canThrowPlain = true;
129
+ return u;
119
130
  }
120
131
  if (step.throw && typeof step.throw === "object") {
121
132
  return resolveThrowStepCode(step.throw, enclosingTryCodes);
@@ -128,6 +139,11 @@ function collectStepThrows(step, invokeField, enclosingTryCodes, ctx) {
128
139
  // out instead. Sequence-specific subtraction — the plan explicitly
129
140
  // anchors this to Run.Sequence's try/catch schema shape.
130
141
  const tryCodes = new Set(tryUnion.codes.keys());
142
+ // A plain (non-InvokeError) failure in the try block reaches the catch as
143
+ // `error.code === PLAIN_ERROR_CODE`, so a `throw: { code: error.code }`
144
+ // rethrow can propagate it — seed the set the catch resolves against.
145
+ if (tryUnion.canThrowPlain)
146
+ tryCodes.add(PLAIN_ERROR_CODE);
131
147
  propagated = collectStepArrayThrows(step.catch, invokeField, tryCodes, ctx);
132
148
  // Unbounded in the try block still signals the caller to expect
133
149
  // arbitrary codes to flow through the catch (e.g. via passthrough).
@@ -179,6 +195,8 @@ function cloneUnion(u) {
179
195
  for (const [c, m] of u.codes)
180
196
  out.codes.set(c, m);
181
197
  out.unbounded = u.unbounded;
198
+ if (u.canThrowPlain)
199
+ out.canThrowPlain = true;
182
200
  return out;
183
201
  }
184
202
  function resolveStepInvokeThrows(step, invokeField, enclosingTryCodes, ctx) {
@@ -226,14 +244,24 @@ function resolveThrowStepCode(throwSpec, enclosingTryCodes) {
226
244
  return resolveCodeExpression(throwSpec.code, enclosingTryCodes);
227
245
  }
228
246
  function resolveCodeExpression(codeInput, enclosingTryCodes) {
229
- if (typeof codeInput !== "string" || codeInput.length === 0) {
230
- return { codes: new Map(), unbounded: true };
247
+ // A `!cel`-tagged sentinel and a `${{ … }}` string must resolve identically
248
+ // normalize both to the inner CEL expression (or a bare literal code).
249
+ let expr;
250
+ if (isTaggedSentinel(codeInput)) {
251
+ if (codeInput.engine !== "cel")
252
+ return { codes: new Map(), unbounded: true };
253
+ expr = codeInput.source.trim();
231
254
  }
232
- const match = codeInput.match(/^\s*\$\{\{\s*([\s\S]+?)\s*\}\}\s*$/);
233
- if (!match) {
234
- return { codes: new Map([[codeInput, {}]]), unbounded: false };
255
+ else if (typeof codeInput === "string" && codeInput.length > 0) {
256
+ const match = codeInput.match(/^\s*\$\{\{\s*([\s\S]+?)\s*\}\}\s*$/);
257
+ if (!match) {
258
+ return { codes: new Map([[codeInput, {}]]), unbounded: false };
259
+ }
260
+ expr = match[1].trim();
261
+ }
262
+ else {
263
+ return { codes: new Map(), unbounded: true };
235
264
  }
236
- const expr = match[1].trim();
237
265
  const litMatch = expr.match(/^'([^']+)'$|^"([^"]+)"$/);
238
266
  if (litMatch) {
239
267
  const code = litMatch[1] ?? litMatch[2];
@@ -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,CA0YtB"}
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"}
@@ -95,10 +95,45 @@ export function validateReferences(resources, context) {
95
95
  // source, different sourceLine) keep separate fingerprints and still
96
96
  // trip the diagnostic. `analyze()` enforces that every non-system
97
97
  // manifest carries both positional fields — no defensive guard needed.
98
+ // Forwarded foreign exports (an imported library's exported instances, carrying a
99
+ // metadata.module that isn't a root module) are resolution TARGETS only: excluded from
100
+ // duplicate detection and local name resolution, and never walked as ref sources.
101
+ const moduleOf = (r) => r.metadata?.module;
102
+ // Forwarded exports are flagged by flattenForAnalyzer (`metadata.forwardedExport`); they're
103
+ // cross-module resolution targets only — excluded from duplicate detection and local name
104
+ // resolution, and never walked as ref sources.
105
+ const isForeign = (r) => r.metadata?.forwardedExport === true;
106
+ // Forwarded exported instances keyed `${module}\0${name}` — the lookup that resolves
107
+ // whether a cross-module `!ref Alias.name` names a real exported instance.
108
+ const byModuleName = new Map();
109
+ /** Modules whose import subtree was actually loaded in this analysis. A resolved
110
+ * `Telo.Import` carries `resolvedModuleName` (stamped only once the edge — and thus the
111
+ * imported module — resolved); forwarded exports carry `metadata.module`. Either marks
112
+ * the module loaded independent of how many instances it exports, so a loaded library
113
+ * that exports nothing still reports invalid cross-module refs, while partial single-file
114
+ * analysis (neither present) is skipped to avoid false `UNRESOLVED_REFERENCE`. */
115
+ const loadedModules = new Set();
116
+ for (const r of resources) {
117
+ if (r.kind === "Telo.Import") {
118
+ const m = r.metadata?.resolvedModuleName;
119
+ if (typeof m === "string")
120
+ loadedModules.add(m);
121
+ continue;
122
+ }
123
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForeign(r))
124
+ continue;
125
+ const m = moduleOf(r);
126
+ if (!m)
127
+ continue;
128
+ byModuleName.set(`${m}\0${r.metadata.name}`, r);
129
+ loadedModules.add(m);
130
+ }
131
+ const moduleLoaded = (module) => loadedModules.has(module);
132
+ const localResources = resources.filter((r) => !isForeign(r));
98
133
  const byNameAll = new Map();
99
134
  const seen = new Set();
100
135
  for (const r of resources) {
101
- if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import")
136
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import" || isForeign(r))
102
137
  continue;
103
138
  const name = r.metadata.name;
104
139
  // `analyze()` guarantees both fields are present on non-system manifests.
@@ -140,6 +175,32 @@ export function validateReferences(resources, context) {
140
175
  });
141
176
  }
142
177
  }
178
+ // A resource name must contain no dot. The `!ref` resolver splits the tag's source on
179
+ // the first dot to separate an import alias from the resource name, so a dotted name
180
+ // would mis-resolve into a cross-module lookup. This is the load-bearing invariant of
181
+ // the reference grammar, so it is enforced here rather than left to the (unenforced)
182
+ // casing convention.
183
+ for (const [name, list] of byNameAll) {
184
+ if (!name.includes("."))
185
+ continue;
186
+ for (const r of list) {
187
+ const m = r.metadata;
188
+ const range = typeof m?.sourceLine === "number"
189
+ ? {
190
+ start: { line: m.sourceLine, character: 0 },
191
+ end: { line: m.sourceLine, character: Number.MAX_SAFE_INTEGER },
192
+ }
193
+ : undefined;
194
+ diagnostics.push({
195
+ severity: DiagnosticSeverity.Error,
196
+ code: "INVALID_RESOURCE_NAME",
197
+ source: SOURCE,
198
+ message: `${r.kind}/${name}: resource name must not contain '.' — in a '!ref' the '.' separates an import alias from the resource name`,
199
+ ...(range ? { range } : {}),
200
+ data: { resource: { kind: r.kind, name }, filePath: m?.source, path: "metadata.name" },
201
+ });
202
+ }
203
+ }
143
204
  // Single-resource map for the resolution / scope lookups below — when a
144
205
  // collision exists, falling back to the first occurrence keeps the rest
145
206
  // of the pass behaving the same as before the duplicate diagnostic was
@@ -152,7 +213,7 @@ export function validateReferences(resources, context) {
152
213
  // resolved against the schema-from-expanded field map, with its source
153
214
  // enclosure (`inScope`) and the scope manifests visible to it — so this
154
215
  // handler only validates, it does not re-walk.
155
- visitManifest(resources, registry, {
216
+ visitManifest(localResources, registry, {
156
217
  onRef: (e) => {
157
218
  const r = e.source;
158
219
  const resourceLabel = `${r.kind}/${r.metadata.name}`;
@@ -166,13 +227,35 @@ export function validateReferences(resources, context) {
166
227
  // string/inline ambiguity at the source.
167
228
  if (isRefSentinel(val)) {
168
229
  const refName = val.source;
169
- const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
230
+ const dot = refName.indexOf(".");
231
+ const aliasPrefix = dot > 0 ? refName.slice(0, dot) : undefined;
232
+ // Cross-module sentinel left unresolved by Phase 2.5 — it qualifies an import
233
+ // alias. If that module's exports are loaded in this analysis, the miss is real
234
+ // (name not in exports.resources, or a typo); if not (partial single-file
235
+ // analysis), skip rather than emit a false UNRESOLVED_REFERENCE.
236
+ if (aliasPrefix && aliasPrefix !== "Self" && aliases.hasAlias(aliasPrefix)) {
237
+ const module = aliases.moduleForAlias(aliasPrefix);
238
+ if (module && !moduleLoaded(module))
239
+ return;
240
+ diagnostics.push({
241
+ severity: DiagnosticSeverity.Error,
242
+ code: "UNRESOLVED_REFERENCE",
243
+ source: SOURCE,
244
+ message: `${resourceLabel}: reference at '${concretePath}' → '${refName}' is not exported by module '${module ?? aliasPrefix}' (add it to exports.resources)`,
245
+ data: { resource: resourceData, filePath, path: concretePath },
246
+ });
247
+ return;
248
+ }
249
+ // Local reference (bare name or explicit `Self.`-qualified).
250
+ const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
251
+ const target = byName.get(localName) ??
252
+ visibleScopeManifests.find((m) => m.metadata?.name === localName);
170
253
  if (!target) {
171
254
  diagnostics.push({
172
255
  severity: DiagnosticSeverity.Error,
173
256
  code: "UNRESOLVED_REFERENCE",
174
257
  source: SOURCE,
175
- message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
258
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${localName}' not found`,
176
259
  data: { resource: resourceData, filePath, path: concretePath },
177
260
  });
178
261
  return;
@@ -232,6 +315,13 @@ export function validateReferences(resources, context) {
232
315
  // Skip inline resources — Phase 2 normalization hasn't run yet.
233
316
  if (isInlineResource(refVal))
234
317
  return;
318
+ // Polymorphic ref slots (Application `targets`) accept object forms
319
+ // whose references live in nested slots rather than being a `{kind,
320
+ // name}` ref themselves — inline `{ invoke }` and gated `{ ref }`.
321
+ // Those nested refs are validated via their own field-map entries, so
322
+ // skip the item-level structural check here.
323
+ if (typeof refVal.kind !== "string" && ("invoke" in refVal || "ref" in refVal))
324
+ return;
235
325
  // 1. Structural check
236
326
  if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
237
327
  diagnostics.push({
@@ -255,8 +345,19 @@ export function validateReferences(resources, context) {
255
345
  });
256
346
  }
257
347
  // 3. Resolution check — resource with this name must exist.
258
- const exists = byName.has(refVal.name) ||
259
- visibleScopeManifests.some((m) => m.metadata?.name === refVal.name);
348
+ let exists;
349
+ if (typeof refVal.alias === "string" && refVal.alias !== "Self") {
350
+ // Cross-module ref resolved by Phase 2.5. Validate against the forwarded
351
+ // exports when loaded; in partial context (module not loaded) assume resolvable.
352
+ const module = aliases.moduleForAlias(refVal.alias);
353
+ exists =
354
+ !module || !moduleLoaded(module) || byModuleName.has(`${module}\0${refVal.name}`);
355
+ }
356
+ else {
357
+ exists =
358
+ byName.has(refVal.name) ||
359
+ visibleScopeManifests.some((m) => m.metadata?.name === refVal.name);
360
+ }
260
361
  if (!exists) {
261
362
  diagnostics.push({
262
363
  severity: DiagnosticSeverity.Error,
@@ -273,7 +374,7 @@ export function validateReferences(resources, context) {
273
374
  // concrete kind, navigate the JSON Pointer into that kind's definition schema, and
274
375
  // validate the field value against the resulting sub-schema. Driven off the base map
275
376
  // (un-expanded) so each schema-from slot is seen as its own site.
276
- visitManifest(resources, registry, {
377
+ visitManifest(localResources, registry, {
277
378
  onSchemaFrom: (e) => {
278
379
  const r = e.source;
279
380
  const fieldPath = e.fieldPath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -42,15 +42,16 @@
42
42
  "ajv-formats": "^3.0.1",
43
43
  "jsonpath-plus": "^10.3.0",
44
44
  "yaml": "^2.8.3",
45
- "@telorun/templating": "0.3.1"
45
+ "@telorun/templating": "0.4.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
- "vitest": "^2.1.8"
50
+ "vitest": "^2.1.8",
51
+ "@telorun/sdk": "0.16.0"
51
52
  },
52
53
  "peerDependencies": {
53
- "@telorun/sdk": "0.12.0"
54
+ "@telorun/sdk": "*"
54
55
  },
55
56
  "scripts": {
56
57
  "build": "tsc -p tsconfig.lib.json",
@@ -11,6 +11,15 @@ export class AliasResolver {
11
11
  }
12
12
  }
13
13
 
14
+ /** Real module name an alias points at (e.g. "Console" → "console"), or undefined.
15
+ * Used to resolve an alias-qualified instance reference "Console.writeLine" to the
16
+ * forwarded resource declared in that module. The `exports.resources` gate is enforced
17
+ * upstream by `flattenForAnalyzer` (only exported instances are forwarded), so a name
18
+ * that isn't exported simply won't be found. */
19
+ moduleForAlias(alias: string): string | undefined {
20
+ return this.importAliases.get(alias);
21
+ }
22
+
14
23
  /** Resolves "Http.Api" → "http-server.Api". Returns undefined if alias is unknown. */
15
24
  resolveKind(kind: string): string | undefined {
16
25
  if (!kind) {
@@ -73,7 +73,7 @@ export class AnalysisRegistry {
73
73
  visitManifest(
74
74
  resources: ResourceManifest[],
75
75
  visitor: ManifestVisitor,
76
- opts?: { skipKinds?: ReadonlySet<string>; expand?: boolean },
76
+ opts?: { skipKinds?: ReadonlySet<string>; expand?: boolean; discoverNestedRefs?: boolean },
77
77
  ): void {
78
78
  runVisitManifest(resources, this.defs, visitor, {
79
79
  aliases: this.aliases,