@telorun/analyzer 0.66.0 → 0.67.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/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +37 -2
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +67 -16
- package/dist/cel-scope.d.ts +8 -0
- package/dist/cel-scope.d.ts.map +1 -1
- package/dist/cel-scope.js +66 -8
- package/dist/definition-registry.d.ts +17 -0
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +35 -0
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +65 -0
- package/dist/flatten-for-analyzer.d.ts +36 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +103 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/inline-imports.d.ts.map +1 -1
- package/dist/inline-imports.js +1 -0
- package/dist/manifest-visitor.d.ts +4 -0
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +28 -0
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +8 -0
- package/dist/resolve-ref-sentinels.d.ts.map +1 -1
- package/dist/resolve-ref-sentinels.js +14 -1
- package/dist/resource-input.d.ts +75 -0
- package/dist/resource-input.d.ts.map +1 -0
- package/dist/resource-input.js +90 -0
- package/dist/schema-projection.d.ts +13 -0
- package/dist/schema-projection.d.ts.map +1 -1
- package/dist/schema-projection.js +7 -0
- package/dist/system-kinds.d.ts +7 -2
- package/dist/system-kinds.d.ts.map +1 -1
- package/dist/system-kinds.js +7 -2
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/template-body.d.ts +50 -0
- package/dist/template-body.d.ts.map +1 -0
- package/dist/template-body.js +58 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +68 -8
- package/dist/validate-identifier-names.d.ts.map +1 -1
- package/dist/validate-identifier-names.js +17 -2
- package/dist/validate-references.d.ts +17 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +68 -16
- package/dist/validate-resource-inputs.d.ts +35 -0
- package/dist/validate-resource-inputs.d.ts.map +1 -0
- package/dist/validate-resource-inputs.js +319 -0
- package/dist/validate-template-dispatch.d.ts +27 -0
- package/dist/validate-template-dispatch.d.ts.map +1 -0
- package/dist/validate-template-dispatch.js +95 -0
- package/package.json +3 -3
- package/src/analyzer.ts +45 -2
- package/src/builtins.ts +69 -16
- package/src/cel-scope.ts +90 -14
- package/src/definition-registry.ts +36 -0
- package/src/dependency-graph.ts +66 -0
- package/src/flatten-for-analyzer.ts +116 -3
- package/src/index.ts +12 -0
- package/src/inline-imports.ts +1 -0
- package/src/manifest-visitor.ts +33 -0
- package/src/precompile.ts +8 -0
- package/src/resolve-ref-sentinels.ts +12 -1
- package/src/resource-input.ts +132 -0
- package/src/schema-projection.ts +19 -0
- package/src/system-kinds.ts +7 -2
- package/src/telo-version.ts +1 -1
- package/src/template-body.ts +104 -0
- package/src/validate-cel-context.ts +67 -7
- package/src/validate-identifier-names.ts +18 -3
- package/src/validate-references.ts +70 -14
- package/src/validate-resource-inputs.ts +367 -0
- package/src/validate-template-dispatch.ts +99 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
2
|
+
import { findDynamicLeaf } from "./resource-rule.js";
|
|
3
|
+
import { readResourceInputs, readSuppliedResources } from "./resource-input.js";
|
|
4
|
+
import { DiagnosticSeverity } from "./types.js";
|
|
5
|
+
const SOURCE = "telo-analyzer";
|
|
6
|
+
/**
|
|
7
|
+
* The strict half of {@link readResourceInputs} — the `validate-ref-slots.ts`
|
|
8
|
+
* split applied to the resource-input boundary, and not optional for the same
|
|
9
|
+
* reason: the two sides fail in OPPOSITE directions. An unreadable DECLARATION
|
|
10
|
+
* leaves the library referencing a name nothing stands behind; an unchecked
|
|
11
|
+
* INJECTION hands a library an instance of a kind it never asked for, which
|
|
12
|
+
* surfaces as a method-missing failure inside someone else's module.
|
|
13
|
+
*
|
|
14
|
+
* Two surfaces, scoped the same way every other declaration check is — to the
|
|
15
|
+
* entry's own modules, since a published dependency's block is not the
|
|
16
|
+
* consumer's to fix:
|
|
17
|
+
*
|
|
18
|
+
* - **The library's own declaration.** Each entry's alias-qualified `kind:` must
|
|
19
|
+
* resolve in the DECLARING module's scope (`RESOURCE_INPUT_KIND_UNRESOLVED`).
|
|
20
|
+
* A constraint that resolves to nothing accepts anything, which is the same
|
|
21
|
+
* hole `X_TELO_REF_UNRESOLVED` exists to close one level down.
|
|
22
|
+
* - **The injection site.** Every declared entry must be supplied
|
|
23
|
+
* (`RESOURCE_INPUT_MISSING` — the same failure as a missing required
|
|
24
|
+
* variable), nothing beyond them may be (`RESOURCE_INPUT_UNKNOWN`), the value
|
|
25
|
+
* must name a resource that exists (`RESOURCE_INPUT_UNRESOLVED`), and its
|
|
26
|
+
* kind must satisfy the declared constraint transitively
|
|
27
|
+
* (`RESOURCE_INPUT_KIND_MISMATCH`).
|
|
28
|
+
*
|
|
29
|
+
* Browser-safe.
|
|
30
|
+
*/
|
|
31
|
+
export function validateResourceInputs(manifests, registry, aliases, rootModules,
|
|
32
|
+
/** Kind acceptance, transitively — the `checkKind` rule `validate-references`
|
|
33
|
+
* applies at an ordinary ref slot, passed in rather than re-derived so the
|
|
34
|
+
* two cannot disagree about what satisfies a constraint. */
|
|
35
|
+
acceptsKind) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const isOwn = (module) => !module || rootModules.has(module);
|
|
38
|
+
// --- the library's own declaration -------------------------------------
|
|
39
|
+
for (const m of manifests) {
|
|
40
|
+
if (m.kind !== "Telo.Library")
|
|
41
|
+
continue;
|
|
42
|
+
const moduleName = m.metadata?.name;
|
|
43
|
+
// Only the entry's own modules, so the declaring scope is always the root
|
|
44
|
+
// alias table — a library analyzed here IS a root module.
|
|
45
|
+
if (!isOwn(moduleName))
|
|
46
|
+
continue;
|
|
47
|
+
const scope = aliases;
|
|
48
|
+
const exported = new Set((m.exports?.resources ?? []).filter((e) => typeof e === "string"));
|
|
49
|
+
for (const input of readResourceInputs(m)) {
|
|
50
|
+
// An input is the IMPORTER's instance, borrowed. Exporting it back out
|
|
51
|
+
// would forward a kind-only stand-in into the consumer's flattened set as
|
|
52
|
+
// a resource this library declares — a phantom the consumer can `!ref`
|
|
53
|
+
// and whose kind constraint is all there is behind it. Handing an
|
|
54
|
+
// instance straight back to whoever supplied it is also a relation
|
|
55
|
+
// nothing needs; if it ever does, it should be a decision rather than a
|
|
56
|
+
// name collision nobody noticed.
|
|
57
|
+
if (exported.has(input.name)) {
|
|
58
|
+
out.push({
|
|
59
|
+
severity: DiagnosticSeverity.Error,
|
|
60
|
+
code: "RESOURCE_INPUT_EXPORTED",
|
|
61
|
+
source: SOURCE,
|
|
62
|
+
message: `Resource input '${input.name}' is also listed in 'exports.resources'. An input is ` +
|
|
63
|
+
`an instance the importer supplies, not one this library declares, so there is ` +
|
|
64
|
+
`nothing to export — remove it from 'exports.resources', or rename the input.`,
|
|
65
|
+
data: {
|
|
66
|
+
resource: { kind: m.kind, name: moduleName },
|
|
67
|
+
filePath: m.metadata?.source,
|
|
68
|
+
path: `resources.${input.name}`,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (registry.resolve(input.kind) ?? registry.resolve(scope.resolveKind(input.kind) ?? "")) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out.push({
|
|
76
|
+
severity: DiagnosticSeverity.Error,
|
|
77
|
+
code: "RESOURCE_INPUT_KIND_UNRESOLVED",
|
|
78
|
+
source: SOURCE,
|
|
79
|
+
message: `Resource input '${input.name}' is constrained to kind '${input.kind}', which does not ` +
|
|
80
|
+
`resolve in this library's scope. Write it alias-qualified — '<Alias>.<Kind>' for an ` +
|
|
81
|
+
`import declared in this file, 'Self.<Kind>' for a kind this library owns, or ` +
|
|
82
|
+
`'Telo.<Kind>' for a built-in. An unresolvable constraint accepts anything.`,
|
|
83
|
+
data: {
|
|
84
|
+
resource: { kind: m.kind, name: moduleName },
|
|
85
|
+
filePath: m.metadata?.source,
|
|
86
|
+
path: `resources.${input.name}.kind`,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// --- the injection site --------------------------------------------------
|
|
92
|
+
for (const m of manifests) {
|
|
93
|
+
if (m.kind !== "Telo.Import")
|
|
94
|
+
continue;
|
|
95
|
+
const meta = m.metadata;
|
|
96
|
+
const alias = meta?.name;
|
|
97
|
+
if (!alias || !isOwn(meta?.module))
|
|
98
|
+
continue;
|
|
99
|
+
// An import whose target identity was never established registers nothing —
|
|
100
|
+
// reporting a missing input against it would blame the author for a
|
|
101
|
+
// dependency the loader has already said it could not obtain.
|
|
102
|
+
if (!meta?.resolvedModuleName)
|
|
103
|
+
continue;
|
|
104
|
+
// A singleton has no room for a per-import override: `logging:` scopes a
|
|
105
|
+
// subtree that is no longer this import's subtree, and `runtime:` selects a
|
|
106
|
+
// controller backend for a library that is instantiated once. Whichever
|
|
107
|
+
// import happened to be created first would decide, so the field is
|
|
108
|
+
// rejected rather than silently applied to everyone or silently dropped.
|
|
109
|
+
if (meta.sharedLibrary === true) {
|
|
110
|
+
for (const field of ["logging", "runtime"]) {
|
|
111
|
+
if (m[field] === undefined)
|
|
112
|
+
continue;
|
|
113
|
+
out.push({
|
|
114
|
+
severity: DiagnosticSeverity.Error,
|
|
115
|
+
code: "SHARED_LIBRARY_OVERRIDE",
|
|
116
|
+
source: SOURCE,
|
|
117
|
+
message: `Import '${alias}' declares '${field}:', but module ` +
|
|
118
|
+
`'${meta.resolvedModuleName}' is 'lifecycle: shared' — one instantiation for the ` +
|
|
119
|
+
`whole application, so a per-import override cannot apply to it. Remove it, or ` +
|
|
120
|
+
`make the library 'lifecycle: isolated'.`,
|
|
121
|
+
data: {
|
|
122
|
+
resource: { kind: m.kind, name: alias },
|
|
123
|
+
filePath: meta.source,
|
|
124
|
+
path: field,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const required = Object.entries(meta.requiredResources ?? {});
|
|
130
|
+
const supplied = readSuppliedResources(m);
|
|
131
|
+
const declared = new Map(required);
|
|
132
|
+
const resource = { kind: m.kind, name: alias };
|
|
133
|
+
const filePath = meta.source;
|
|
134
|
+
for (const [entryName, entryKind] of required) {
|
|
135
|
+
if (entryName in supplied)
|
|
136
|
+
continue;
|
|
137
|
+
out.push({
|
|
138
|
+
severity: DiagnosticSeverity.Error,
|
|
139
|
+
code: "RESOURCE_INPUT_MISSING",
|
|
140
|
+
source: SOURCE,
|
|
141
|
+
message: `Import '${alias}' does not supply the resource input '${entryName}', which module ` +
|
|
142
|
+
`'${meta.resolvedModuleName}' requires (kind '${entryKind}'). Add ` +
|
|
143
|
+
`\`resources: { ${entryName}: !ref <name> }\` to the import.`,
|
|
144
|
+
data: { resource, filePath, path: "resources" },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
for (const [name, value] of Object.entries(supplied)) {
|
|
148
|
+
const path = `resources.${name}`;
|
|
149
|
+
const entry = declared.get(name);
|
|
150
|
+
if (!entry) {
|
|
151
|
+
const known = required.map(([n]) => n).join(", ") || "(none)";
|
|
152
|
+
out.push({
|
|
153
|
+
severity: DiagnosticSeverity.Error,
|
|
154
|
+
code: "RESOURCE_INPUT_UNKNOWN",
|
|
155
|
+
source: SOURCE,
|
|
156
|
+
message: `Import '${alias}' supplies a resource input '${name}', which module ` +
|
|
157
|
+
`'${meta.resolvedModuleName}' does not declare. Declared inputs: ${known}.`,
|
|
158
|
+
data: { resource, filePath, path },
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Phase 2.5 rewrote a resolvable `!ref` to `{kind, name}`; a sentinel that
|
|
163
|
+
// survived names nothing the importer can reach.
|
|
164
|
+
if (isRefSentinel(value)) {
|
|
165
|
+
out.push({
|
|
166
|
+
severity: DiagnosticSeverity.Error,
|
|
167
|
+
code: "RESOURCE_INPUT_UNRESOLVED",
|
|
168
|
+
source: SOURCE,
|
|
169
|
+
message: `Import '${alias}': resource input '${name}' → resource '${value.source}' not found`,
|
|
170
|
+
data: { resource, filePath, path },
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
175
|
+
out.push({
|
|
176
|
+
severity: DiagnosticSeverity.Error,
|
|
177
|
+
code: "RESOURCE_INPUT_UNRESOLVED",
|
|
178
|
+
source: SOURCE,
|
|
179
|
+
message: `Import '${alias}': resource input '${name}' must be a '!ref' to a resource this ` +
|
|
180
|
+
`module declares.`,
|
|
181
|
+
data: { resource, filePath, path },
|
|
182
|
+
});
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const suppliedKind = value.kind;
|
|
186
|
+
if (typeof suppliedKind !== "string")
|
|
187
|
+
continue;
|
|
188
|
+
const canonical = aliases.resolveKind(suppliedKind) ?? suppliedKind;
|
|
189
|
+
if (acceptsKind(canonical, entry))
|
|
190
|
+
continue;
|
|
191
|
+
out.push({
|
|
192
|
+
severity: DiagnosticSeverity.Error,
|
|
193
|
+
code: "RESOURCE_INPUT_KIND_MISMATCH",
|
|
194
|
+
source: SOURCE,
|
|
195
|
+
message: `Import '${alias}': resource input '${name}' is '${suppliedKind}' (resolved: ` +
|
|
196
|
+
`'${canonical}'), which does not satisfy the declared constraint '${entry}'.`,
|
|
197
|
+
data: { resource, filePath, path },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
out.push(...sharedLibraryConflicts(manifests, isOwn));
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
const SUPPLIED_BLOCKS = ["variables", "secrets", "resources"];
|
|
205
|
+
/**
|
|
206
|
+
* Two imports of ONE `lifecycle: shared` library supplying different values.
|
|
207
|
+
*
|
|
208
|
+
* The runtime half (`ERR_SHARED_LIBRARY_CONFLICT`) is authoritative — it holds
|
|
209
|
+
* resolved values and live instances, and a library reached through a
|
|
210
|
+
* programmatic load never passed `telo check`. But the overwhelmingly common
|
|
211
|
+
* shape is two literal scalars in one flattened manifest set, which is decidable
|
|
212
|
+
* here, and a conflict is a hard BOOT failure: finding it at `telo check` is the
|
|
213
|
+
* difference between a squiggle and a deployment that will not start.
|
|
214
|
+
*
|
|
215
|
+
* Only what is DECIDABLE is compared, and the two ways it is not are skipped
|
|
216
|
+
* rather than guessed:
|
|
217
|
+
*
|
|
218
|
+
* - A value holding a `!cel` (at any depth) is known only at load. Two
|
|
219
|
+
* different expressions may evaluate equal, and identical text in two
|
|
220
|
+
* different modules may not.
|
|
221
|
+
* - A `resources:` entry names a resource by name, which means the same
|
|
222
|
+
* instance only when both imports were DECLARED in the same module — so a
|
|
223
|
+
* cross-module pair is left to the runtime.
|
|
224
|
+
*
|
|
225
|
+
* Grouped by the target's RESOLVED SOURCE, the identity the kernel keys its
|
|
226
|
+
* singleton registry on; `resolvedModuleName` would collapse two versions of one
|
|
227
|
+
* module, which are two libraries.
|
|
228
|
+
*/
|
|
229
|
+
function sharedLibraryConflicts(manifests, isOwn) {
|
|
230
|
+
const out = [];
|
|
231
|
+
const byTarget = new Map();
|
|
232
|
+
for (const m of manifests) {
|
|
233
|
+
if (m.kind !== "Telo.Import")
|
|
234
|
+
continue;
|
|
235
|
+
const meta = m.metadata;
|
|
236
|
+
if (meta?.sharedLibrary !== true || !meta.name || !meta.resolvedSource)
|
|
237
|
+
continue;
|
|
238
|
+
const blocks = new Map();
|
|
239
|
+
for (const block of SUPPLIED_BLOCKS) {
|
|
240
|
+
const value = m[block];
|
|
241
|
+
blocks.set(block, value && typeof value === "object" && !Array.isArray(value)
|
|
242
|
+
? value
|
|
243
|
+
: {});
|
|
244
|
+
}
|
|
245
|
+
const bucket = byTarget.get(meta.resolvedSource);
|
|
246
|
+
const entry = {
|
|
247
|
+
alias: meta.name,
|
|
248
|
+
module: meta.module,
|
|
249
|
+
filePath: meta.source,
|
|
250
|
+
blocks,
|
|
251
|
+
};
|
|
252
|
+
if (bucket)
|
|
253
|
+
bucket.push(entry);
|
|
254
|
+
else
|
|
255
|
+
byTarget.set(meta.resolvedSource, [entry]);
|
|
256
|
+
}
|
|
257
|
+
for (const group of byTarget.values()) {
|
|
258
|
+
if (group.length < 2)
|
|
259
|
+
continue;
|
|
260
|
+
const [first, ...rest] = group;
|
|
261
|
+
for (const later of rest) {
|
|
262
|
+
for (const block of SUPPLIED_BLOCKS) {
|
|
263
|
+
// A reference means the same instance only within one module's scope.
|
|
264
|
+
if (block === "resources" && first.module !== later.module)
|
|
265
|
+
continue;
|
|
266
|
+
const a = first.blocks.get(block);
|
|
267
|
+
const b = later.blocks.get(block);
|
|
268
|
+
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
269
|
+
if (findDynamicLeaf(a[key]) || findDynamicLeaf(b[key]))
|
|
270
|
+
continue;
|
|
271
|
+
if (sameSuppliedValue(a[key], b[key]))
|
|
272
|
+
continue;
|
|
273
|
+
out.push({
|
|
274
|
+
severity: DiagnosticSeverity.Error,
|
|
275
|
+
code: "SHARED_LIBRARY_CONFLICT",
|
|
276
|
+
source: SOURCE,
|
|
277
|
+
message: `Import '${later.alias}' and import '${first.alias}' both reach a ` +
|
|
278
|
+
`'lifecycle: shared' library — one instantiation for the whole application — but ` +
|
|
279
|
+
`they supply different values for ${block}.${key}. Make the two imports agree, or ` +
|
|
280
|
+
`make the library 'lifecycle: isolated'.`,
|
|
281
|
+
data: isOwn(later.module)
|
|
282
|
+
? {
|
|
283
|
+
resource: { kind: "Telo.Import", name: later.alias },
|
|
284
|
+
filePath: later.filePath,
|
|
285
|
+
path: `${block}.${key}`,
|
|
286
|
+
}
|
|
287
|
+
: {
|
|
288
|
+
resource: { kind: "Telo.Import", name: first.alias },
|
|
289
|
+
filePath: first.filePath,
|
|
290
|
+
path: `${block}.${key}`,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
/** Structural equality, key-order insensitive — the analyzer's half of the rule
|
|
300
|
+
* the kernel applies to resolved values. A secret's VALUE is compared but never
|
|
301
|
+
* printed: the key is what the author has to look at. */
|
|
302
|
+
function sameSuppliedValue(a, b) {
|
|
303
|
+
if (a === b)
|
|
304
|
+
return true;
|
|
305
|
+
if (a === undefined || b === undefined || a === null || b === null)
|
|
306
|
+
return false;
|
|
307
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
308
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
309
|
+
return false;
|
|
310
|
+
return a.every((item, i) => sameSuppliedValue(item, b[i]));
|
|
311
|
+
}
|
|
312
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
313
|
+
const left = a;
|
|
314
|
+
const right = b;
|
|
315
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
316
|
+
return [...keys].every((key) => sameSuppliedValue(left[key], right[key]));
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { type AnalysisDiagnostic } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* A `!ref` at a definition's dispatch slot must name a sibling `resources:`
|
|
5
|
+
* entry.
|
|
6
|
+
*
|
|
7
|
+
* `!ref` is the one spelling an author — and the editor's rename, completion and
|
|
8
|
+
* go-to-definition — expects to be RESOLVED. But a `Telo.Definition` is in both
|
|
9
|
+
* `REF_VALIDATION_SKIP_KINDS` and `REF_RESOLUTION_SKIP_KINDS`, and the dispatch
|
|
10
|
+
* slots carry no `x-telo-ref` (the accepted targets are the definition's own
|
|
11
|
+
* template-internal entries, not resources of any module), so no reference pass
|
|
12
|
+
* reaches them. Introducing the tag at a slot nothing resolves would be worse
|
|
13
|
+
* than the string form it replaces: a typo that used to be an obvious runtime
|
|
14
|
+
* miss becomes a typo in a construct that advertises static resolution.
|
|
15
|
+
*
|
|
16
|
+
* Decidable only when every sibling name is LITERAL. A template routinely names
|
|
17
|
+
* its entries with CEL (`name: !cel "self.name + '-query'"`), and an expression
|
|
18
|
+
* could expand to the referenced name — so one dynamic sibling switches the
|
|
19
|
+
* check off for that definition rather than inventing a miss.
|
|
20
|
+
*
|
|
21
|
+
* Entry-module-scoped, like every other declaration check: a published
|
|
22
|
+
* dependency's template body is not the consumer's to fix.
|
|
23
|
+
*
|
|
24
|
+
* Browser-safe.
|
|
25
|
+
*/
|
|
26
|
+
export declare function validateTemplateDispatch(manifests: ResourceManifest[], rootModules: ReadonlySet<string>): AnalysisDiagnostic[];
|
|
27
|
+
//# sourceMappingURL=validate-template-dispatch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-template-dispatch.d.ts","sourceRoot":"","sources":["../src/validate-template-dispatch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AASzE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,wBAAwB,CACtC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,GAC/B,kBAAkB,EAAE,CAgDtB"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
2
|
+
import { distance } from "./levenshtein.js";
|
|
3
|
+
import { DiagnosticSeverity } from "./types.js";
|
|
4
|
+
const SOURCE = "telo-analyzer";
|
|
5
|
+
/** The four slots a `Telo.Definition` names its dispatch target in. Each takes
|
|
6
|
+
* the same grammar; which one is legal for a given capability is
|
|
7
|
+
* `validate-provider-coherence`'s question, not this one's. */
|
|
8
|
+
const DISPATCH_FIELDS = ["invoke", "run", "provide", "mount"];
|
|
9
|
+
/**
|
|
10
|
+
* A `!ref` at a definition's dispatch slot must name a sibling `resources:`
|
|
11
|
+
* entry.
|
|
12
|
+
*
|
|
13
|
+
* `!ref` is the one spelling an author — and the editor's rename, completion and
|
|
14
|
+
* go-to-definition — expects to be RESOLVED. But a `Telo.Definition` is in both
|
|
15
|
+
* `REF_VALIDATION_SKIP_KINDS` and `REF_RESOLUTION_SKIP_KINDS`, and the dispatch
|
|
16
|
+
* slots carry no `x-telo-ref` (the accepted targets are the definition's own
|
|
17
|
+
* template-internal entries, not resources of any module), so no reference pass
|
|
18
|
+
* reaches them. Introducing the tag at a slot nothing resolves would be worse
|
|
19
|
+
* than the string form it replaces: a typo that used to be an obvious runtime
|
|
20
|
+
* miss becomes a typo in a construct that advertises static resolution.
|
|
21
|
+
*
|
|
22
|
+
* Decidable only when every sibling name is LITERAL. A template routinely names
|
|
23
|
+
* its entries with CEL (`name: !cel "self.name + '-query'"`), and an expression
|
|
24
|
+
* could expand to the referenced name — so one dynamic sibling switches the
|
|
25
|
+
* check off for that definition rather than inventing a miss.
|
|
26
|
+
*
|
|
27
|
+
* Entry-module-scoped, like every other declaration check: a published
|
|
28
|
+
* dependency's template body is not the consumer's to fix.
|
|
29
|
+
*
|
|
30
|
+
* Browser-safe.
|
|
31
|
+
*/
|
|
32
|
+
export function validateTemplateDispatch(manifests, rootModules) {
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const m of manifests) {
|
|
35
|
+
if (m.kind !== "Telo.Definition")
|
|
36
|
+
continue;
|
|
37
|
+
const meta = m.metadata;
|
|
38
|
+
const name = meta?.name;
|
|
39
|
+
if (!name)
|
|
40
|
+
continue;
|
|
41
|
+
if (meta?.module && !rootModules.has(meta.module))
|
|
42
|
+
continue;
|
|
43
|
+
const bodies = m.resources;
|
|
44
|
+
if (!Array.isArray(bodies))
|
|
45
|
+
continue;
|
|
46
|
+
const siblings = [];
|
|
47
|
+
let anyDynamic = false;
|
|
48
|
+
for (const entry of bodies) {
|
|
49
|
+
const entryName = entry?.metadata?.name;
|
|
50
|
+
if (typeof entryName === "string" && !entryName.includes("${{"))
|
|
51
|
+
siblings.push(entryName);
|
|
52
|
+
else if (entryName !== undefined)
|
|
53
|
+
anyDynamic = true;
|
|
54
|
+
}
|
|
55
|
+
if (anyDynamic)
|
|
56
|
+
continue;
|
|
57
|
+
for (const field of DISPATCH_FIELDS) {
|
|
58
|
+
const value = m[field];
|
|
59
|
+
if (!isRefSentinel(value))
|
|
60
|
+
continue;
|
|
61
|
+
const source = value.source;
|
|
62
|
+
const target = source.startsWith("Self.") ? source.slice("Self.".length) : source;
|
|
63
|
+
if (siblings.includes(target))
|
|
64
|
+
continue;
|
|
65
|
+
const suggestion = nearest(target, siblings);
|
|
66
|
+
out.push({
|
|
67
|
+
severity: DiagnosticSeverity.Error,
|
|
68
|
+
code: "TEMPLATE_DISPATCH_UNKNOWN",
|
|
69
|
+
source: SOURCE,
|
|
70
|
+
message: `${m.kind}/${name}: '${field}: !ref ${source}' names no entry in 'resources:'. ` +
|
|
71
|
+
`Available: ${siblings.join(", ") || "(none)"}.` +
|
|
72
|
+
(suggestion ? ` Did you mean '${suggestion}'?` : ""),
|
|
73
|
+
data: {
|
|
74
|
+
resource: { kind: m.kind, name },
|
|
75
|
+
filePath: meta?.source,
|
|
76
|
+
path: field,
|
|
77
|
+
...(suggestion ? { fix: { replacement: suggestion } } : {}),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** The closest sibling name, when one is close enough to be a typo rather than a
|
|
85
|
+
* different name. */
|
|
86
|
+
function nearest(target, candidates) {
|
|
87
|
+
let best;
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
const d = distance(target, candidate);
|
|
90
|
+
if (!best || d < best.d)
|
|
91
|
+
best = { name: candidate, d };
|
|
92
|
+
}
|
|
93
|
+
const limit = Math.max(1, Math.floor(target.length / 3));
|
|
94
|
+
return best && best.d <= limit ? best.name : undefined;
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"jsonpath-plus": "^10.3.0",
|
|
44
44
|
"packageurl-js": "^2.0.1",
|
|
45
45
|
"yaml": "^2.8.3",
|
|
46
|
-
"@telorun/templating": "0.
|
|
46
|
+
"@telorun/templating": "0.18.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/node": "^20.0.0",
|
|
50
50
|
"typescript": "^5.0.0",
|
|
51
51
|
"vitest": "^2.1.8",
|
|
52
|
-
"@telorun/sdk": "0.
|
|
52
|
+
"@telorun/sdk": "0.83.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@telorun/sdk": "*"
|
package/src/analyzer.ts
CHANGED
|
@@ -157,8 +157,11 @@ import { validateInvocationContract } from "./validate-invocation-contract.js";
|
|
|
157
157
|
import { collectRefInputIssues, collectStepInputIssues } from "./validate-step-inputs.js";
|
|
158
158
|
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
159
159
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
160
|
-
import { validateReferences } from "./validate-references.js";
|
|
160
|
+
import { kindSatisfies, validateReferences } from "./validate-references.js";
|
|
161
161
|
import { validateReferenceForms } from "./validate-reference-forms.js";
|
|
162
|
+
import { isInjectedDeclaration } from "./resource-input.js";
|
|
163
|
+
import { validateResourceInputs } from "./validate-resource-inputs.js";
|
|
164
|
+
import { validateTemplateDispatch } from "./validate-template-dispatch.js";
|
|
162
165
|
import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
|
|
163
166
|
import { validateThrowsCoverage } from "./validate-throws-coverage.js";
|
|
164
167
|
import { readStepSlot } from "./step-slot.js";
|
|
@@ -1627,6 +1630,16 @@ export class StaticAnalyzer {
|
|
|
1627
1630
|
const runReachable = reportsObservedState
|
|
1628
1631
|
? collectRunReachableNames(getCallGraph())
|
|
1629
1632
|
: new Set<string>();
|
|
1633
|
+
// "Nothing starts this" is DECLARATION-derived, and a library does not
|
|
1634
|
+
// declare its injected inputs — whether the application starts the instance
|
|
1635
|
+
// it hands down is answerable only where that instance is declared. Inside
|
|
1636
|
+
// the library the question has no answer, so the name is treated as
|
|
1637
|
+
// reachable rather than reported on a `targets:` list the author cannot
|
|
1638
|
+
// write. The check still runs at the injection site, against the real
|
|
1639
|
+
// declaration.
|
|
1640
|
+
for (const m of allManifests) {
|
|
1641
|
+
if (isInjectedDeclaration(m)) runReachable.add(m.metadata?.name as string);
|
|
1642
|
+
}
|
|
1630
1643
|
|
|
1631
1644
|
// Build typed kernel globals schema so x-telo-context chain validation
|
|
1632
1645
|
// recognises variables, secrets, resources, env automatically
|
|
@@ -1762,6 +1775,15 @@ export class StaticAnalyzer {
|
|
|
1762
1775
|
continue;
|
|
1763
1776
|
}
|
|
1764
1777
|
|
|
1778
|
+
// A kind-only stand-in for a `resources:` entry is a DECLARATION, not an
|
|
1779
|
+
// instantiation: its kind is routinely an abstract and its configuration
|
|
1780
|
+
// is the importer's to supply, so validating it here would report a
|
|
1781
|
+
// non-instantiable kind and a page of missing required fields against a
|
|
1782
|
+
// block that is correct. Its kind is checked where it was written, by
|
|
1783
|
+
// `validate-resource-inputs`; it participates here only as a resolution
|
|
1784
|
+
// target and as a CEL type.
|
|
1785
|
+
if (isInjectedDeclaration(m)) continue;
|
|
1786
|
+
|
|
1765
1787
|
const resource = { kind: m.kind, name: m.metadata?.name as string };
|
|
1766
1788
|
|
|
1767
1789
|
// Resolve kind through alias if needed; direct lookup takes priority so that
|
|
@@ -1861,6 +1883,11 @@ export class StaticAnalyzer {
|
|
|
1861
1883
|
) as Record<string, any>;
|
|
1862
1884
|
if (!ownModule || rootModules.has(ownModule)) {
|
|
1863
1885
|
for (const failure of projectionFailures) {
|
|
1886
|
+
// A projection through a library's own resource INPUT is
|
|
1887
|
+
// unanswerable here — the entries belong to the declaration the
|
|
1888
|
+
// importer supplies — so it is not a defect in the block that named
|
|
1889
|
+
// it. The check runs at the injection site instead.
|
|
1890
|
+
if (failure.reason === "injected") continue;
|
|
1864
1891
|
diagnostics.push({
|
|
1865
1892
|
severity: DiagnosticSeverity.Error,
|
|
1866
1893
|
code: "SCHEMA_PROJECTION_FROM_UNRESOLVED",
|
|
@@ -2556,7 +2583,7 @@ export class StaticAnalyzer {
|
|
|
2556
2583
|
}
|
|
2557
2584
|
},
|
|
2558
2585
|
},
|
|
2559
|
-
{ aliases },
|
|
2586
|
+
{ aliases, aliasesByModule, rootModules },
|
|
2560
2587
|
);
|
|
2561
2588
|
|
|
2562
2589
|
// The two halves of "does this expression fit the slot it flows into" meet
|
|
@@ -2645,6 +2672,22 @@ export class StaticAnalyzer {
|
|
|
2645
2672
|
// Warn about declared variables / secrets / ports that no CEL references.
|
|
2646
2673
|
diagnostics.push(...validateUnusedDeclarations(allManifests, this.celEnv));
|
|
2647
2674
|
|
|
2675
|
+
// A `!ref` at a definition's dispatch slot must name a sibling `resources:`
|
|
2676
|
+
// entry — the slot no reference pass reaches, so the tag would otherwise
|
|
2677
|
+
// advertise a resolution nothing performs.
|
|
2678
|
+
diagnostics.push(...validateTemplateDispatch(allManifests, rootModules));
|
|
2679
|
+
|
|
2680
|
+
// A library's declared resource inputs, and every import that supplies them.
|
|
2681
|
+
diagnostics.push(
|
|
2682
|
+
...validateResourceInputs(
|
|
2683
|
+
allManifests,
|
|
2684
|
+
defs,
|
|
2685
|
+
aliases,
|
|
2686
|
+
rootModules,
|
|
2687
|
+
(supplied, required) => kindSatisfies(supplied, required, defs),
|
|
2688
|
+
),
|
|
2689
|
+
);
|
|
2690
|
+
|
|
2648
2691
|
// Reroute diagnostics on synthetic (inline-extracted) resources back to
|
|
2649
2692
|
// the chain root so position-index lookups land on the parent doc.
|
|
2650
2693
|
return rewriteSyntheticOrigins(
|