@telorun/analyzer 0.51.0 → 0.53.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 +126 -4
- package/dist/cel-bindings.d.ts +57 -0
- package/dist/cel-bindings.d.ts.map +1 -0
- package/dist/cel-bindings.js +207 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/sources/integrity.d.ts +10 -0
- package/dist/sources/integrity.d.ts.map +1 -1
- package/dist/sources/integrity.js +18 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +8 -3
- package/dist/validate-module-metadata.d.ts +38 -0
- package/dist/validate-module-metadata.d.ts.map +1 -0
- package/dist/validate-module-metadata.js +256 -0
- package/package.json +2 -2
- package/src/analyzer.ts +151 -3
- package/src/cel-bindings.ts +222 -0
- package/src/index.ts +13 -0
- package/src/sources/integrity.ts +20 -0
- package/src/validate-cel-context.ts +8 -3
- package/src/validate-module-metadata.ts +335 -0
|
@@ -283,9 +283,14 @@ export function resolveContextAnnotations(schema, manifestItem, opts) {
|
|
|
283
283
|
: [fromRefKindRaw];
|
|
284
284
|
if (fromRoot || fromRefKinds.length > 0) {
|
|
285
285
|
if (fromRoot) {
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
288
|
-
|
|
286
|
+
const navigated = navigatePath(manifestRoot, fromRoot.split("/"));
|
|
287
|
+
if (navigated && typeof navigated === "object" && !Array.isArray(navigated)) {
|
|
288
|
+
// A `telo#Type` slot resolves to the schema it names — the inline
|
|
289
|
+
// `{ kind, schema }` wrapper, a `!ref` to a named type, or a bare name —
|
|
290
|
+
// so the variable is typed by the CONTRACT rather than by the wrapper
|
|
291
|
+
// around it. A raw JSON Schema resolves to itself, and a plain property
|
|
292
|
+
// map (a transport scope) resolves to nothing and is used verbatim.
|
|
293
|
+
return resolveTypeFieldToSchema(navigated, allManifests ?? []) ?? navigated;
|
|
289
294
|
}
|
|
290
295
|
}
|
|
291
296
|
if (defs) {
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
* Static validation of the `metadata:` block on module docs (`Telo.Application` /
|
|
7
|
+
* `Telo.Library`) and of `metadata.deprecated` wherever it appears.
|
|
8
|
+
*
|
|
9
|
+
* These fields are descriptive — nothing in the kernel branches on them — but they
|
|
10
|
+
* are the module's public face: a hub indexes them, and a consumer reads them
|
|
11
|
+
* before deciding to import. That is exactly why they need checking. A field the
|
|
12
|
+
* runtime ignores has no failure mode that would ever surface it, so a mistyped
|
|
13
|
+
* `licence:` or `deprecatd:` is invisible forever, and the module ships claiming
|
|
14
|
+
* nothing while its author believes otherwise.
|
|
15
|
+
*
|
|
16
|
+
* The vocabulary stays **open** — `metadata` accepts any key, because a publisher
|
|
17
|
+
* may carry their own — so an unknown key is only reported when it is a near-miss
|
|
18
|
+
* of a known one. That catches the typo without closing the set.
|
|
19
|
+
*
|
|
20
|
+
* **Everything here is a WARNING, and fatal only at `telo publish`** (see
|
|
21
|
+
* {@link PUBLISH_BLOCKING_CODES}). Refusing to *run* a manifest over a field no
|
|
22
|
+
* runtime reads gets the cost backwards: `version: 1.0` is a YAML float rather
|
|
23
|
+
* than a string, which is a real mistake worth reporting, but stopping the app
|
|
24
|
+
* from starting over it is worse than the mistake. Publication is the moment
|
|
25
|
+
* these fields become consequential — they are projected onto the artifact's
|
|
26
|
+
* annotations and indexed by the hub — so that is where they block.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Codes that must not block running a manifest but MUST block publishing one.
|
|
30
|
+
*
|
|
31
|
+
* Kept as a set rather than a severity because the two audiences differ: a
|
|
32
|
+
* developer running a manifest wants to know, a publisher must be stopped. If a
|
|
33
|
+
* later check earns the same treatment, add its code here rather than inventing
|
|
34
|
+
* a third severity level.
|
|
35
|
+
*/
|
|
36
|
+
export declare const PUBLISH_BLOCKING_CODES: ReadonlySet<string>;
|
|
37
|
+
export declare function validateModuleMetadata(manifests: ResourceManifest[], registry: DefinitionRegistry, aliases: AliasResolver): AnalysisDiagnostic[];
|
|
38
|
+
//# sourceMappingURL=validate-module-metadata.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-module-metadata.d.ts","sourceRoot":"","sources":["../src/validate-module-metadata.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;AAEnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIzE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAKrD,CAAC;AAiDH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,EAAE,aAAa,GACrB,kBAAkB,EAAE,CA8CtB"}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { distance } from "./levenshtein.js";
|
|
2
|
+
import { DiagnosticSeverity } from "./types.js";
|
|
3
|
+
const SOURCE = "telo-analyzer";
|
|
4
|
+
/**
|
|
5
|
+
* Static validation of the `metadata:` block on module docs (`Telo.Application` /
|
|
6
|
+
* `Telo.Library`) and of `metadata.deprecated` wherever it appears.
|
|
7
|
+
*
|
|
8
|
+
* These fields are descriptive — nothing in the kernel branches on them — but they
|
|
9
|
+
* are the module's public face: a hub indexes them, and a consumer reads them
|
|
10
|
+
* before deciding to import. That is exactly why they need checking. A field the
|
|
11
|
+
* runtime ignores has no failure mode that would ever surface it, so a mistyped
|
|
12
|
+
* `licence:` or `deprecatd:` is invisible forever, and the module ships claiming
|
|
13
|
+
* nothing while its author believes otherwise.
|
|
14
|
+
*
|
|
15
|
+
* The vocabulary stays **open** — `metadata` accepts any key, because a publisher
|
|
16
|
+
* may carry their own — so an unknown key is only reported when it is a near-miss
|
|
17
|
+
* of a known one. That catches the typo without closing the set.
|
|
18
|
+
*
|
|
19
|
+
* **Everything here is a WARNING, and fatal only at `telo publish`** (see
|
|
20
|
+
* {@link PUBLISH_BLOCKING_CODES}). Refusing to *run* a manifest over a field no
|
|
21
|
+
* runtime reads gets the cost backwards: `version: 1.0` is a YAML float rather
|
|
22
|
+
* than a string, which is a real mistake worth reporting, but stopping the app
|
|
23
|
+
* from starting over it is worse than the mistake. Publication is the moment
|
|
24
|
+
* these fields become consequential — they are projected onto the artifact's
|
|
25
|
+
* annotations and indexed by the hub — so that is where they block.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Codes that must not block running a manifest but MUST block publishing one.
|
|
29
|
+
*
|
|
30
|
+
* Kept as a set rather than a severity because the two audiences differ: a
|
|
31
|
+
* developer running a manifest wants to know, a publisher must be stopped. If a
|
|
32
|
+
* later check earns the same treatment, add its code here rather than inventing
|
|
33
|
+
* a third severity level.
|
|
34
|
+
*/
|
|
35
|
+
export const PUBLISH_BLOCKING_CODES = new Set([
|
|
36
|
+
"METADATA_INVALID_TYPE",
|
|
37
|
+
"METADATA_UNKNOWN_FIELD",
|
|
38
|
+
"INVALID_DEPRECATION",
|
|
39
|
+
"DEPRECATION_REPLACEMENT_UNRESOLVED",
|
|
40
|
+
]);
|
|
41
|
+
/** Conventional module-doc metadata, and the type each carries. Descriptive only;
|
|
42
|
+
* `name` is the sole field anything resolves against. */
|
|
43
|
+
const MODULE_METADATA_TYPES = {
|
|
44
|
+
name: "string",
|
|
45
|
+
module: "string",
|
|
46
|
+
version: "string",
|
|
47
|
+
description: "string",
|
|
48
|
+
repository: "string",
|
|
49
|
+
homepage: "string",
|
|
50
|
+
documentation: "string",
|
|
51
|
+
license: "string",
|
|
52
|
+
namespace: "string",
|
|
53
|
+
categories: "string[]",
|
|
54
|
+
deprecated: "object",
|
|
55
|
+
};
|
|
56
|
+
/** What a kind doc's `metadata:` may carry.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately narrower than a module's: `version`, `license` and the rest
|
|
59
|
+
* belong to the module, and a kind restating them means nothing. `categories`
|
|
60
|
+
* is legal and *replaces* the module's for that kind; `description` is hub
|
|
61
|
+
* search text. Both have exactly the failure mode this file exists for — a
|
|
62
|
+
* `descriptoin:` on a kind doc is read by nothing and reported by nothing, so
|
|
63
|
+
* it ships silently — which is why kind docs are checked rather than exempt. */
|
|
64
|
+
const KIND_METADATA_TYPES = {
|
|
65
|
+
name: "string",
|
|
66
|
+
module: "string",
|
|
67
|
+
description: "string",
|
|
68
|
+
categories: "string[]",
|
|
69
|
+
deprecated: "object",
|
|
70
|
+
};
|
|
71
|
+
/** Alias-qualified kind — `Self.Migrations`, `Cache.Store`, `Telo.JsonSchema`. */
|
|
72
|
+
const ALIAS_KIND_RE = /^[A-Z][A-Za-z0-9_]*\.[A-Z][A-Za-z0-9_]*$/;
|
|
73
|
+
/** The built-in namespace, resolvable without an import — mirrors `validate-extends`. */
|
|
74
|
+
const TELO_BUILTIN_ALIAS = "Telo";
|
|
75
|
+
function typeOf(value) {
|
|
76
|
+
if (typeof value === "string")
|
|
77
|
+
return "string";
|
|
78
|
+
if (Array.isArray(value))
|
|
79
|
+
return value.every((v) => typeof v === "string") ? "string[]" : "other";
|
|
80
|
+
if (value !== null && typeof value === "object")
|
|
81
|
+
return "object";
|
|
82
|
+
return "other";
|
|
83
|
+
}
|
|
84
|
+
export function validateModuleMetadata(manifests, registry, aliases) {
|
|
85
|
+
const out = [];
|
|
86
|
+
// Docs forwarded from imported libraries carry `metadata.module` set to that
|
|
87
|
+
// library's name. Their `replacedBy` aliases — `Self`, or any alias private to
|
|
88
|
+
// that library — belong to the library's OWN scope, which the consumer's
|
|
89
|
+
// resolver knows nothing about, so re-checking them here reports a false
|
|
90
|
+
// DEPRECATION_REPLACEMENT_UNRESOLVED against a manifest the consumer does not
|
|
91
|
+
// own. They are validated when that library is analyzed as a root, which is
|
|
92
|
+
// its author's concern. Same rule, and the same reason, as `validate-extends`.
|
|
93
|
+
const importedModules = new Set();
|
|
94
|
+
for (const m of manifests) {
|
|
95
|
+
if (m.kind !== "Telo.Import")
|
|
96
|
+
continue;
|
|
97
|
+
const resolved = m.metadata
|
|
98
|
+
?.resolvedModuleName;
|
|
99
|
+
if (resolved)
|
|
100
|
+
importedModules.add(resolved);
|
|
101
|
+
}
|
|
102
|
+
for (const manifest of manifests) {
|
|
103
|
+
const isModuleDoc = manifest.kind === "Telo.Application" || manifest.kind === "Telo.Library";
|
|
104
|
+
const isKindDoc = manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract";
|
|
105
|
+
if (!isModuleDoc && !isKindDoc)
|
|
106
|
+
continue;
|
|
107
|
+
const metadata = manifest.metadata;
|
|
108
|
+
if (!metadata)
|
|
109
|
+
continue;
|
|
110
|
+
const ownModule = metadata.module;
|
|
111
|
+
if (ownModule && importedModules.has(ownModule))
|
|
112
|
+
continue;
|
|
113
|
+
const name = typeof metadata.name === "string" ? metadata.name : undefined;
|
|
114
|
+
const filePath = typeof metadata.source === "string" ? metadata.source : undefined;
|
|
115
|
+
const ctx = {
|
|
116
|
+
label: `${manifest.kind}/${name ?? "(unnamed)"}`,
|
|
117
|
+
resource: { kind: manifest.kind, name },
|
|
118
|
+
filePath,
|
|
119
|
+
};
|
|
120
|
+
validateFieldTypes(metadata, isModuleDoc ? MODULE_METADATA_TYPES : KIND_METADATA_TYPES, ctx, out);
|
|
121
|
+
validateDeprecation(metadata, isModuleDoc, ctx, registry, aliases, out);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** How far a key may be from a known one and still be called a typo.
|
|
126
|
+
*
|
|
127
|
+
* Scaled, not absolute: at a flat 2, `date:` (a perfectly ordinary key an
|
|
128
|
+
* author might carry) is two edits from `name` and gets told it is a
|
|
129
|
+
* misspelling of it. The vocabulary is open, so a false accusation on a short
|
|
130
|
+
* key is worse than missing a typo on one. */
|
|
131
|
+
function typoThreshold(key, known) {
|
|
132
|
+
return Math.max(1, Math.floor(Math.min(key.length, known.length) / 3));
|
|
133
|
+
}
|
|
134
|
+
function validateFieldTypes(metadata, allowed, ctx, out) {
|
|
135
|
+
const known = Object.keys(allowed);
|
|
136
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
137
|
+
// Stamped by the loader, not authored — never a typo to report on.
|
|
138
|
+
if (key === "source")
|
|
139
|
+
continue;
|
|
140
|
+
// Listed as known so a typo still gets suggested against it, but its shape
|
|
141
|
+
// belongs to `validateDeprecation`, which can say what is actually wrong.
|
|
142
|
+
// Type-checking it here too would report one mistake twice.
|
|
143
|
+
if (key === "deprecated")
|
|
144
|
+
continue;
|
|
145
|
+
const expected = allowed[key];
|
|
146
|
+
if (expected === undefined) {
|
|
147
|
+
const near = known.find((k) => distance(key, k) <= typoThreshold(key, k));
|
|
148
|
+
if (near) {
|
|
149
|
+
out.push({
|
|
150
|
+
severity: DiagnosticSeverity.Warning,
|
|
151
|
+
code: "METADATA_UNKNOWN_FIELD",
|
|
152
|
+
source: SOURCE,
|
|
153
|
+
message: `${ctx.label}: 'metadata.${key}' is not a known field — did you mean '${near}'? ` +
|
|
154
|
+
`Nothing reads an unrecognized key, so this declares nothing.`,
|
|
155
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path: `metadata.${key}` },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (typeOf(value) !== expected) {
|
|
161
|
+
out.push({
|
|
162
|
+
severity: DiagnosticSeverity.Warning,
|
|
163
|
+
code: "METADATA_INVALID_TYPE",
|
|
164
|
+
source: SOURCE,
|
|
165
|
+
message: `${ctx.label}: 'metadata.${key}' must be ${describeType(expected)}.`,
|
|
166
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path: `metadata.${key}` },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function describeType(t) {
|
|
172
|
+
if (t === "string[]")
|
|
173
|
+
return "an array of strings";
|
|
174
|
+
if (t === "object")
|
|
175
|
+
return "an object";
|
|
176
|
+
return "a string";
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* `metadata.deprecated: { reason, replacedBy? }`.
|
|
180
|
+
*
|
|
181
|
+
* `replacedBy` is deliberately resolvable rather than free text, and its form
|
|
182
|
+
* follows the level: a module doc names another **module ref** (the `imports:`
|
|
183
|
+
* source grammar), a kind doc names an **alias-qualified kind** resolved through
|
|
184
|
+
* this file's own imports — the same grammar `kind:` / `extends:` use, so the
|
|
185
|
+
* replacement is a link a consumer can follow rather than a sentence they have to
|
|
186
|
+
* interpret.
|
|
187
|
+
*
|
|
188
|
+
* A kind whose replacement lives in a module this one does not import cannot be
|
|
189
|
+
* named; that case deprecates at module level with a module ref instead. Accepted
|
|
190
|
+
* over inventing a second grammar for it.
|
|
191
|
+
*/
|
|
192
|
+
function validateDeprecation(metadata, isModuleDoc, ctx, registry, aliases, out) {
|
|
193
|
+
const deprecated = metadata.deprecated;
|
|
194
|
+
if (deprecated === undefined)
|
|
195
|
+
return;
|
|
196
|
+
const at = "metadata.deprecated";
|
|
197
|
+
const push = (code, message, path = at, severity = DiagnosticSeverity.Warning) => {
|
|
198
|
+
out.push({
|
|
199
|
+
severity,
|
|
200
|
+
code,
|
|
201
|
+
source: SOURCE,
|
|
202
|
+
message: `${ctx.label}: ${message}`,
|
|
203
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path },
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
if (typeOf(deprecated) !== "object") {
|
|
207
|
+
push("INVALID_DEPRECATION", `'${at}' must be an object with a 'reason' (and an optional 'replacedBy'). ` +
|
|
208
|
+
`A bare 'true' says a thing is deprecated without saying what to do instead.`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const block = deprecated;
|
|
212
|
+
const allowed = new Set(["reason", "replacedBy"]);
|
|
213
|
+
for (const key of Object.keys(block)) {
|
|
214
|
+
if (!allowed.has(key)) {
|
|
215
|
+
push("INVALID_DEPRECATION", `'${at}.${key}' is not a recognized key (reason, replacedBy).`, `${at}.${key}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (typeof block.reason !== "string" || block.reason.trim() === "") {
|
|
219
|
+
push("INVALID_DEPRECATION", `'${at}.reason' is required and must be a non-empty string — it is what a consumer reads to know what to do instead.`, `${at}.reason`);
|
|
220
|
+
}
|
|
221
|
+
const replacedBy = block.replacedBy;
|
|
222
|
+
if (replacedBy === undefined)
|
|
223
|
+
return;
|
|
224
|
+
const path = `${at}.replacedBy`;
|
|
225
|
+
if (typeof replacedBy !== "string" || replacedBy.trim() === "") {
|
|
226
|
+
push("INVALID_DEPRECATION", `'${path}' must be a non-empty string.`, path);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (isModuleDoc) {
|
|
230
|
+
// A module is replaced by another module, addressed the way an import is.
|
|
231
|
+
// Catching alias form here is worth a dedicated message: it is the natural
|
|
232
|
+
// mistake, and it would otherwise be stored as an unresolvable ref.
|
|
233
|
+
if (ALIAS_KIND_RE.test(replacedBy)) {
|
|
234
|
+
push("INVALID_DEPRECATION", `'${path}: ${replacedBy}' looks like a kind reference, but a module doc's replacement is a ` +
|
|
235
|
+
`module ref (e.g. 'oci://ghcr.io/acme/thing'). Deprecate the kind itself to point at another kind.`, path);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// Kind level: resolve through this file's imports, exactly as `extends` does.
|
|
240
|
+
if (!ALIAS_KIND_RE.test(replacedBy)) {
|
|
241
|
+
push("INVALID_DEPRECATION", `'${path}: ${replacedBy}' must be an alias-qualified kind ("<Alias>.<Kind>", ` +
|
|
242
|
+
`e.g. 'Self.Migrations'), resolved via this file's imports.`, path);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const prefix = replacedBy.slice(0, replacedBy.indexOf("."));
|
|
246
|
+
if (prefix !== TELO_BUILTIN_ALIAS && !aliases.hasAlias(prefix)) {
|
|
247
|
+
push("DEPRECATION_REPLACEMENT_UNRESOLVED", `'${path}: ${replacedBy}' — alias '${prefix}' is not an import in this file's scope. ` +
|
|
248
|
+
`Declare the import or correct the alias.`, path);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const canonical = aliases.resolveKind(replacedBy);
|
|
252
|
+
if (!canonical || !registry.resolve(canonical)) {
|
|
253
|
+
push("DEPRECATION_REPLACEMENT_UNRESOLVED", `'${path}: ${replacedBy}' does not resolve to a known kind. A replacement a consumer ` +
|
|
254
|
+
`cannot follow is no better than none.`, path);
|
|
255
|
+
}
|
|
256
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@types/node": "^20.0.0",
|
|
49
49
|
"typescript": "^5.0.0",
|
|
50
50
|
"vitest": "^2.1.8",
|
|
51
|
-
"@telorun/sdk": "0.
|
|
51
|
+
"@telorun/sdk": "0.65.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@telorun/sdk": "*"
|
package/src/analyzer.ts
CHANGED
|
@@ -23,7 +23,11 @@ import {
|
|
|
23
23
|
resolveContract,
|
|
24
24
|
} from "./invocation-contract.js";
|
|
25
25
|
import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
buildKernelGlobalsSchema,
|
|
28
|
+
KERNEL_GLOBAL_NAMES,
|
|
29
|
+
mergeKernelGlobalsIntoContext,
|
|
30
|
+
} from "./kernel-globals.js";
|
|
27
31
|
import {
|
|
28
32
|
buildObservedStateIndex,
|
|
29
33
|
buildObservedStateResourcesSchema,
|
|
@@ -59,9 +63,20 @@ import {
|
|
|
59
63
|
resolveTypeFieldToSchema,
|
|
60
64
|
} from "./validate-cel-context.js";
|
|
61
65
|
import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
|
|
66
|
+
import {
|
|
67
|
+
BINDINGS_ANNOTATION,
|
|
68
|
+
bindingContextProperties,
|
|
69
|
+
bindingPathChain,
|
|
70
|
+
CEL_RESERVED_WORDS,
|
|
71
|
+
findBindingSites,
|
|
72
|
+
resolveBindingOrder,
|
|
73
|
+
schemaAtChain,
|
|
74
|
+
type BindingSites,
|
|
75
|
+
} from "./cel-bindings.js";
|
|
62
76
|
import { validateExtends } from "./validate-extends.js";
|
|
63
77
|
import { validateLogging } from "./validate-logging.js";
|
|
64
78
|
import { validateModuleArtifact } from "./validate-module-artifact.js";
|
|
79
|
+
import { validateModuleMetadata } from "./validate-module-metadata.js";
|
|
65
80
|
import { validateBaseMapping } from "./validate-base-mapping.js";
|
|
66
81
|
import { validateInvocationContract } from "./validate-invocation-contract.js";
|
|
67
82
|
import { collectStepInputIssues } from "./validate-step-inputs.js";
|
|
@@ -416,6 +431,9 @@ function buildStepContextSchema(
|
|
|
416
431
|
|
|
417
432
|
const invokeField = stepCtx.invoke;
|
|
418
433
|
const outputTypeField = stepCtx.outputType;
|
|
434
|
+
// Optional: the field a step uses to produce a result without dispatching.
|
|
435
|
+
// Only a kind that declares one has pure steps at all.
|
|
436
|
+
const valueField = stepCtx.value;
|
|
419
437
|
if (!invokeField || !outputTypeField) continue;
|
|
420
438
|
|
|
421
439
|
const steps = manifest[fieldName];
|
|
@@ -426,6 +444,13 @@ function buildStepContextSchema(
|
|
|
426
444
|
defSchema,
|
|
427
445
|
);
|
|
428
446
|
|
|
447
|
+
// The instance's own input contract, for typing a pure step that just
|
|
448
|
+
// forwards one of its values.
|
|
449
|
+
const ownInputs = resolveTypeFieldToSchema(
|
|
450
|
+
(manifest as Record<string, any>).inputType,
|
|
451
|
+
allManifests,
|
|
452
|
+
);
|
|
453
|
+
|
|
429
454
|
const stepProperties: Record<string, any> = {};
|
|
430
455
|
|
|
431
456
|
walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
|
|
@@ -435,7 +460,28 @@ function buildStepContextSchema(
|
|
|
435
460
|
// wrappers (try/if/while/switch/throw) don't produce a result and must
|
|
436
461
|
// not shadow real entries with a permissive `additionalProperties: true`,
|
|
437
462
|
// or unknown step references slip through chain validation.
|
|
438
|
-
if (typeof name !== "string"
|
|
463
|
+
if (typeof name !== "string") return;
|
|
464
|
+
if (!invoke || typeof invoke !== "object") {
|
|
465
|
+
// A pure step dispatches nothing, so there is no contract to resolve.
|
|
466
|
+
// Where its expression is a plain chain into something already typed —
|
|
467
|
+
// an earlier step's result, or the kind's own inputs — that type carries
|
|
468
|
+
// through; anything else (arithmetic, a call, a comprehension) stays
|
|
469
|
+
// permissive rather than guessed. Same rule as a named binding's.
|
|
470
|
+
if (valueField && valueField in s) {
|
|
471
|
+
const scopeRoot = {
|
|
472
|
+
properties: {
|
|
473
|
+
steps: { type: "object", properties: { ...stepProperties } },
|
|
474
|
+
...(ownInputs ? { inputs: ownInputs } : {}),
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
const chained = schemaAtChain(bindingPathChain(s[valueField]), scopeRoot);
|
|
478
|
+
stepProperties[name] = {
|
|
479
|
+
type: "object",
|
|
480
|
+
properties: { result: chained ?? PERMISSIVE_CONTRACT },
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
439
485
|
const invokedKind = invoke.kind as string | undefined;
|
|
440
486
|
const invokedName = invoke.name as string | undefined;
|
|
441
487
|
// A named `!ref` carries the target's own manifest (which may narrow the
|
|
@@ -765,6 +811,29 @@ function errorContextForPath(
|
|
|
765
811
|
return best?.schema;
|
|
766
812
|
}
|
|
767
813
|
|
|
814
|
+
/** Add a kind's named bindings to a resolved context, when the context declares
|
|
815
|
+
* a bindings region. They go UNDER the context's own properties: a scope
|
|
816
|
+
* variable wins over a same-named binding at runtime, so static typing has to
|
|
817
|
+
* agree (the collision itself is `BINDING_NAME_RESERVED`). */
|
|
818
|
+
function withBindingNames(
|
|
819
|
+
contextSchema: Record<string, any>,
|
|
820
|
+
resource: Record<string, any>,
|
|
821
|
+
): Record<string, any> {
|
|
822
|
+
const field = contextSchema[BINDINGS_ANNOTATION];
|
|
823
|
+
if (typeof field !== "string") return contextSchema;
|
|
824
|
+
const bindings = resource[field];
|
|
825
|
+
if (bindings === null || typeof bindings !== "object" || Array.isArray(bindings)) {
|
|
826
|
+
return contextSchema;
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
...contextSchema,
|
|
830
|
+
properties: {
|
|
831
|
+
...bindingContextProperties(bindings as Record<string, unknown>, contextSchema),
|
|
832
|
+
...(contextSchema.properties ?? {}),
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
768
837
|
/** Member-access chains in a CEL expression, or none when it doesn't parse.
|
|
769
838
|
* Best-effort: a syntax error is reported by the engine pass, not here. */
|
|
770
839
|
function celAccessChains(env: Environment, expr: string): string[][] {
|
|
@@ -1247,6 +1316,10 @@ export class StaticAnalyzer {
|
|
|
1247
1316
|
// would otherwise fail on a consumer's machine — or, for a mistyped platform
|
|
1248
1317
|
// axis, silently offer one platform's binary to every host.
|
|
1249
1318
|
diagnostics.push(...validateModuleArtifact(allManifests));
|
|
1319
|
+
// The descriptive `metadata:` surface. Nothing in the kernel branches on
|
|
1320
|
+
// these fields, which is precisely why they need a check: a mistyped one
|
|
1321
|
+
// has no runtime failure mode that would ever surface it.
|
|
1322
|
+
diagnostics.push(...validateModuleMetadata(allManifests, defs, aliases));
|
|
1250
1323
|
}
|
|
1251
1324
|
resolveSchemaTypeRefs(allManifests, aliases, aliasesByModule);
|
|
1252
1325
|
|
|
@@ -1695,6 +1768,9 @@ export class StaticAnalyzer {
|
|
|
1695
1768
|
// `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
|
|
1696
1769
|
// every region is read as a literal — the runtime never evaluates it.
|
|
1697
1770
|
let celEvalPaths: string[] = [];
|
|
1771
|
+
// The bindings field this kind declares (if any), read by the CEL sites that
|
|
1772
|
+
// see the names it introduces.
|
|
1773
|
+
let celBindingSites: BindingSites | undefined;
|
|
1698
1774
|
// The compile half alone: a field that resolves at startup, where observed
|
|
1699
1775
|
// state cannot exist yet.
|
|
1700
1776
|
let celCompilePaths: string[] = [];
|
|
@@ -1748,6 +1824,75 @@ export class StaticAnalyzer {
|
|
|
1748
1824
|
e.definition?.schema as Record<string, any> | undefined,
|
|
1749
1825
|
);
|
|
1750
1826
|
|
|
1827
|
+
celBindingSites = findBindingSites(e.definition?.schema as Record<string, any>);
|
|
1828
|
+
if (celBindingSites) {
|
|
1829
|
+
const declared = (m as Record<string, any>)[celBindingSites.field];
|
|
1830
|
+
const bindingsName = (m.metadata as any)?.name as string | undefined;
|
|
1831
|
+
const bindingsFile = (m.metadata as { source?: string } | undefined)?.source;
|
|
1832
|
+
const resourceRef = { kind: m.kind, name: bindingsName ?? "" };
|
|
1833
|
+
|
|
1834
|
+
// Which field holds the bindings would otherwise be decided by
|
|
1835
|
+
// schema walk order, silently.
|
|
1836
|
+
if (celBindingSites.fields.length > 1) {
|
|
1837
|
+
diagnostics.push({
|
|
1838
|
+
severity: DiagnosticSeverity.Error,
|
|
1839
|
+
code: "BINDING_FIELD_AMBIGUOUS",
|
|
1840
|
+
source: SOURCE,
|
|
1841
|
+
message: `${m.kind}/${bindingsName}: the kind's schema points '${BINDINGS_ANNOTATION}' at more than one field (${celBindingSites.fields.join(", ")}). Every annotated context must name the same bindings field.`,
|
|
1842
|
+
data: { resource: resourceRef, filePath: bindingsFile, path: celBindingSites.field },
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
if (declared !== null && typeof declared === "object" && !Array.isArray(declared)) {
|
|
1847
|
+
|
|
1848
|
+
for (const cycle of resolveBindingOrder(declared).cycles) {
|
|
1849
|
+
diagnostics.push({
|
|
1850
|
+
severity: DiagnosticSeverity.Error,
|
|
1851
|
+
code: "BINDING_CYCLE",
|
|
1852
|
+
source: SOURCE,
|
|
1853
|
+
message: `${m.kind}/${bindingsName}: '${celBindingSites.field}' has a cycle — ${cycle.join(" → ")}. A binding is resolved from the ones it references, so it cannot reference itself, directly or through others.`,
|
|
1854
|
+
data: {
|
|
1855
|
+
resource: resourceRef,
|
|
1856
|
+
filePath: bindingsFile,
|
|
1857
|
+
path: `${celBindingSites.field}.${cycle[0]}`,
|
|
1858
|
+
},
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// Every name the CEL environment already binds at this site: the
|
|
1863
|
+
// kernel globals (registered straight onto the environment, not
|
|
1864
|
+
// contributed by any annotation), the scope the annotated contexts
|
|
1865
|
+
// declare, and the two the analyzer merges per site. Shadowing one
|
|
1866
|
+
// would leave the binding silently unreachable there — as would
|
|
1867
|
+
// naming it after a CEL keyword, which never lexes as a reference.
|
|
1868
|
+
const inScope = new Set<string>([
|
|
1869
|
+
...KERNEL_GLOBAL_NAMES,
|
|
1870
|
+
...celBindingSites.scopeNames,
|
|
1871
|
+
]);
|
|
1872
|
+
if (celStepContextSchema) inScope.add("steps");
|
|
1873
|
+
if (celErrorScopes.size > 0) inScope.add("error");
|
|
1874
|
+
const keywords = new Set<string>(CEL_RESERVED_WORDS);
|
|
1875
|
+
|
|
1876
|
+
for (const name of Object.keys(declared)) {
|
|
1877
|
+
const shadows = inScope.has(name);
|
|
1878
|
+
if (!shadows && !keywords.has(name)) continue;
|
|
1879
|
+
diagnostics.push({
|
|
1880
|
+
severity: DiagnosticSeverity.Error,
|
|
1881
|
+
code: "BINDING_NAME_RESERVED",
|
|
1882
|
+
source: SOURCE,
|
|
1883
|
+
message: shadows
|
|
1884
|
+
? `${m.kind}/${bindingsName}: binding '${name}' shadows a variable already in scope here (${[...inScope].sort().join(", ")}). Rename the binding — a scope variable always wins, so this one would never be read.`
|
|
1885
|
+
: `${m.kind}/${bindingsName}: binding '${name}' is a CEL keyword, so no expression can read it as a reference. Rename the binding.`,
|
|
1886
|
+
data: {
|
|
1887
|
+
resource: resourceRef,
|
|
1888
|
+
filePath: bindingsFile,
|
|
1889
|
+
path: `${celBindingSites.field}.${name}`,
|
|
1890
|
+
},
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1751
1896
|
// The non-eval-field check only applies to runtime resource instances:
|
|
1752
1897
|
// structural / templating kinds (capability `Telo.Template`, or no
|
|
1753
1898
|
// definition) carry CEL the kernel evaluates by other rules.
|
|
@@ -1889,7 +2034,10 @@ export class StaticAnalyzer {
|
|
|
1889
2034
|
aliases,
|
|
1890
2035
|
allManifests: allManifests as Record<string, any>[],
|
|
1891
2036
|
});
|
|
1892
|
-
effectiveContext = mergeKernelGlobalsIntoContext(
|
|
2037
|
+
effectiveContext = mergeKernelGlobalsIntoContext(
|
|
2038
|
+
withBindingNames(resolvedContext, m as Record<string, any>),
|
|
2039
|
+
kernelGlobals,
|
|
2040
|
+
);
|
|
1893
2041
|
} else if (observedStateContext) {
|
|
1894
2042
|
// No `x-telo-context` matched, so nothing was chain-validated here
|
|
1895
2043
|
// before. Validate the observed-state segment alone rather than
|