@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.
- package/dist/alias-resolver.d.ts +6 -0
- package/dist/alias-resolver.d.ts.map +1 -1
- package/dist/alias-resolver.js +8 -0
- package/dist/analysis-registry.d.ts +1 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analyzer.d.ts +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +111 -5
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +66 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +17 -0
- package/dist/manifest-visitor.d.ts +15 -0
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +73 -2
- package/dist/reference-field-map.js +16 -0
- package/dist/resolve-ref-sentinels.d.ts +15 -17
- package/dist/resolve-ref-sentinels.d.ts.map +1 -1
- package/dist/resolve-ref-sentinels.js +86 -40
- package/dist/resolve-throws-union.d.ts +10 -0
- package/dist/resolve-throws-union.d.ts.map +1 -1
- package/dist/resolve-throws-union.js +35 -7
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +108 -7
- package/package.json +5 -4
- package/src/alias-resolver.ts +9 -0
- package/src/analysis-registry.ts +1 -1
- package/src/analyzer.ts +130 -5
- package/src/builtins.ts +66 -0
- package/src/flatten-for-analyzer.ts +20 -0
- package/src/manifest-visitor.ts +91 -2
- package/src/reference-field-map.ts +14 -0
- package/src/resolve-ref-sentinels.ts +89 -44
- package/src/resolve-throws-union.ts +36 -8
- package/src/validate-references.ts +110 -8
|
@@ -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
|
|
6
|
+
* `!ref <name>` sentinels in-place to `{kind, name}` (local) or
|
|
7
|
+
* `{kind, name, alias}` (cross-module).
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
97
|
+
descend(r, fieldPath.split("."), resolveTarget);
|
|
45
98
|
}
|
|
46
99
|
}
|
|
47
100
|
}
|
|
48
|
-
/** Walks `obj` along `fieldPath` (dot notation with `[]` for arrays and
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
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 =
|
|
114
|
+
const target = resolve(child.source);
|
|
69
115
|
if (target)
|
|
70
|
-
container[key] =
|
|
116
|
+
container[key] = target;
|
|
71
117
|
}
|
|
72
118
|
}
|
|
73
119
|
else {
|
|
74
|
-
descend(child, rest,
|
|
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 =
|
|
138
|
+
const target = resolve(elem.source);
|
|
93
139
|
if (target)
|
|
94
|
-
val[i] =
|
|
140
|
+
val[i] = target;
|
|
95
141
|
}
|
|
96
142
|
}
|
|
97
143
|
else {
|
|
98
|
-
descend(val[i], rest,
|
|
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 =
|
|
151
|
+
const target = resolve(val.source);
|
|
106
152
|
if (target)
|
|
107
|
-
container[key] =
|
|
153
|
+
container[key] = target;
|
|
108
154
|
}
|
|
109
155
|
}
|
|
110
156
|
else {
|
|
111
|
-
descend(val, rest,
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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 '${
|
|
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
|
-
|
|
259
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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": "
|
|
54
|
+
"@telorun/sdk": "*"
|
|
54
55
|
},
|
|
55
56
|
"scripts": {
|
|
56
57
|
"build": "tsc -p tsconfig.lib.json",
|
package/src/alias-resolver.ts
CHANGED
|
@@ -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) {
|
package/src/analysis-registry.ts
CHANGED
|
@@ -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,
|