@telorun/analyzer 0.48.0 → 0.49.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/analysis-registry.d.ts +22 -11
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +36 -39
- package/dist/analyzer.d.ts +38 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +115 -83
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +72 -1
- package/dist/extends-resolution.d.ts +41 -0
- package/dist/extends-resolution.d.ts.map +1 -1
- package/dist/extends-resolution.js +68 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/invocation-contract.d.ts +100 -0
- package/dist/invocation-contract.d.ts.map +1 -0
- package/dist/invocation-contract.js +208 -0
- package/dist/schema-compat.d.ts +12 -4
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +185 -9
- package/dist/validate-base-mapping.js +11 -1
- package/dist/validate-cel-context.d.ts +0 -6
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +51 -4
- package/dist/validate-invocation-contract.d.ts +30 -0
- package/dist/validate-invocation-contract.d.ts.map +1 -0
- package/dist/validate-invocation-contract.js +394 -0
- package/dist/validate-step-inputs.d.ts +24 -0
- package/dist/validate-step-inputs.d.ts.map +1 -0
- package/dist/validate-step-inputs.js +87 -0
- package/dist/validate-throws-coverage.d.ts +1 -1
- package/dist/validate-throws-coverage.d.ts.map +1 -1
- package/dist/validate-throws-coverage.js +9 -1
- package/package.json +2 -2
- package/src/analysis-registry.ts +44 -34
- package/src/analyzer.ts +171 -100
- package/src/builtins.ts +74 -1
- package/src/extends-resolution.ts +86 -0
- package/src/index.ts +13 -1
- package/src/invocation-contract.ts +275 -0
- package/src/schema-compat.ts +191 -8
- package/src/validate-base-mapping.ts +14 -1
- package/src/validate-cel-context.ts +49 -4
- package/src/validate-invocation-contract.ts +450 -0
- package/src/validate-step-inputs.ts +117 -0
- package/src/validate-throws-coverage.ts +12 -2
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { effectiveContractField, mappingFieldFor, needsContractMapping, } from "./extends-resolution.js";
|
|
2
|
+
import { buildReferenceFieldMap, isRefEntry } from "./reference-field-map.js";
|
|
3
|
+
import { DiagnosticSeverity } from "./types.js";
|
|
4
|
+
const SOURCE = "telo-analyzer";
|
|
5
|
+
/**
|
|
6
|
+
* Phase 3 — static checks on declared invocation contracts.
|
|
7
|
+
*
|
|
8
|
+
* The runtime binds a contract to every instance and enforces it at dispatch;
|
|
9
|
+
* these are the failures worth catching before anything runs, and the ones the
|
|
10
|
+
* runtime cannot see at all (a declaration that is inert, an input nobody can
|
|
11
|
+
* supply).
|
|
12
|
+
*
|
|
13
|
+
* Diagnostics:
|
|
14
|
+
* - CONTRACT_MISSING_MAPPING: a definition that inherits its controller declares
|
|
15
|
+
* its own `inputType` / `outputType` without the `inputs:` / `result:` mapping
|
|
16
|
+
* that bridges it back to the inherited controller.
|
|
17
|
+
* - CONTRACT_INPUTS_SCHEMA_FORM: a leftover `inputs:` property map on a kind
|
|
18
|
+
* whose input contract is now `inputType:`.
|
|
19
|
+
* - CONTRACT_TYPE_NOT_FOUND: a contract names a type that is not declared in
|
|
20
|
+
* scope, so every call through it would fail at dispatch.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT diagnosed: an input that is neither `required:` nor
|
|
23
|
+
* defaulted. It is indistinguishable from a genuinely optional one — `Ai.Text`
|
|
24
|
+
* takes `prompt` OR `messages`, and `system` is optional on purpose — so the
|
|
25
|
+
* check fired ~40 times across the standard library on correct manifests with no
|
|
26
|
+
* way for an author to record the intent. A warning that cannot be silenced on
|
|
27
|
+
* correct code teaches people to ignore warnings.
|
|
28
|
+
*/
|
|
29
|
+
export function validateInvocationContract(manifests, registry, aliases, aliasesByModule = new Map()) {
|
|
30
|
+
const diagnostics = [];
|
|
31
|
+
const resolveDef = (kind, from) => {
|
|
32
|
+
const module = from?.metadata?.module;
|
|
33
|
+
const scope = (module ? aliasesByModule.get(module) : undefined) ?? aliases;
|
|
34
|
+
return registry.resolve(kind) ?? registry.resolve(scope.resolveKind(kind) ?? kind);
|
|
35
|
+
};
|
|
36
|
+
// A published dependency's declarations are not the consumer's to fix.
|
|
37
|
+
const importedModules = new Set();
|
|
38
|
+
for (const m of manifests) {
|
|
39
|
+
if (m.kind !== "Telo.Import")
|
|
40
|
+
continue;
|
|
41
|
+
const resolved = m.metadata?.resolvedModuleName;
|
|
42
|
+
if (resolved)
|
|
43
|
+
importedModules.add(resolved);
|
|
44
|
+
}
|
|
45
|
+
const isOwn = (m) => {
|
|
46
|
+
const ownModule = m.metadata?.module;
|
|
47
|
+
return !ownModule || !importedModules.has(ownModule);
|
|
48
|
+
};
|
|
49
|
+
for (const m of manifests) {
|
|
50
|
+
if (!isOwn(m))
|
|
51
|
+
continue;
|
|
52
|
+
const name = m.metadata?.name;
|
|
53
|
+
if (!name)
|
|
54
|
+
continue;
|
|
55
|
+
const filePath = m.metadata?.source;
|
|
56
|
+
const resource = { kind: m.kind, name };
|
|
57
|
+
const md = m;
|
|
58
|
+
if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract") {
|
|
59
|
+
checkMappingRequired(m, resource, filePath, resolveDef, diagnostics);
|
|
60
|
+
checkContractResolves(m, md, manifests, resource, filePath, diagnostics);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// A RESOURCE (an instance of some kind) — a leftover `inputs:` map is only
|
|
64
|
+
// meaningful against a kind whose schema no longer declares one.
|
|
65
|
+
const definition = resolveDef(m.kind, m);
|
|
66
|
+
if (!definition)
|
|
67
|
+
continue;
|
|
68
|
+
checkContractResolves(m, md, manifests, resource, filePath, diagnostics);
|
|
69
|
+
checkLeftoverInputsSchema(m, definition, md, resource, filePath, diagnostics);
|
|
70
|
+
checkRefSlotWiring(m, definition, manifests, resolveDef, resource, filePath, diagnostics);
|
|
71
|
+
}
|
|
72
|
+
return diagnostics;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The wiring rule: whether a ref slot may hold a resource whose input contract
|
|
76
|
+
* differs from the slot's declared kind.
|
|
77
|
+
*
|
|
78
|
+
* `extends` decides which resources a slot ACCEPTS; it never carried the
|
|
79
|
+
* dispatch contract. What matters per slot is whether the caller can supply the
|
|
80
|
+
* target's arguments at all:
|
|
81
|
+
*
|
|
82
|
+
* - the slot's declared kind declares no `inputType` and is not a run site →
|
|
83
|
+
* nothing to violate, accept;
|
|
84
|
+
* - the wiring site takes a paired author `inputs:` → the author supplies the
|
|
85
|
+
* arguments and can see both sides, so the call site check covers it;
|
|
86
|
+
* - the consumer's controller builds the arguments and knows only the slot's
|
|
87
|
+
* kind → the wired resource must not require anything that kind does not
|
|
88
|
+
* declare, because nothing could ever supply it.
|
|
89
|
+
*
|
|
90
|
+
* The run-site case is the same rule with an empty argument set: a `run()`
|
|
91
|
+
* dispatch passes nothing at all, so a target requiring any input can never be
|
|
92
|
+
* satisfied there. Both are keyed on declared capability and declared contracts,
|
|
93
|
+
* never on a kind's name.
|
|
94
|
+
*/
|
|
95
|
+
function checkRefSlotWiring(m, definition, manifests, resolveDef, resource, filePath, diagnostics) {
|
|
96
|
+
const schema = definition.schema;
|
|
97
|
+
if (!schema)
|
|
98
|
+
return;
|
|
99
|
+
for (const [path, entry] of buildReferenceFieldMap(schema)) {
|
|
100
|
+
if (!isRefEntry(entry))
|
|
101
|
+
continue;
|
|
102
|
+
// A slot that takes a paired `inputs:` is the author's to fill; its values
|
|
103
|
+
// are checked at the call site instead, against the target's own contract.
|
|
104
|
+
if (slotTakesPairedInputs(schema, path))
|
|
105
|
+
continue;
|
|
106
|
+
const slotDeclares = slotDeclaredInputs(entry.refs, resolveDef, manifests);
|
|
107
|
+
const runSite = isRunOnlySlot(entry.refs, resolveDef);
|
|
108
|
+
if (!runSite && slotDeclares === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
const ownModule = m.metadata?.module;
|
|
111
|
+
for (const name of refValuesAt(m, path)) {
|
|
112
|
+
// Scoped to the declaring module: a resource of the same name in another
|
|
113
|
+
// module is a different resource, and checking against its contract would
|
|
114
|
+
// report on something the author never wired.
|
|
115
|
+
const target = findInModule(manifests, name, ownModule);
|
|
116
|
+
if (!target)
|
|
117
|
+
continue;
|
|
118
|
+
const targetDef = resolveDef(target.kind, target);
|
|
119
|
+
const targetRequired = requiredInputsOf(contractSchemaFor(target, targetDef, resolveDef, manifests));
|
|
120
|
+
if (!targetRequired || targetRequired.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
const unsatisfiable = runSite
|
|
123
|
+
? targetRequired
|
|
124
|
+
: targetRequired.filter((key) => !(slotDeclares ?? []).includes(key));
|
|
125
|
+
if (unsatisfiable.length === 0)
|
|
126
|
+
continue;
|
|
127
|
+
diagnostics.push({
|
|
128
|
+
severity: DiagnosticSeverity.Error,
|
|
129
|
+
code: runSite ? "CONTRACT_INPUTS_AT_RUN_SITE" : "CONTRACT_SLOT_INPUTS_UNSATISFIABLE",
|
|
130
|
+
source: SOURCE,
|
|
131
|
+
message: runSite
|
|
132
|
+
? `${m.kind}/${resource.name}: '${name}' is wired at '${path}', which starts it with \`run()\` — ` +
|
|
133
|
+
`a dispatch that passes no arguments — but its contract requires ${list(unsatisfiable)}. ` +
|
|
134
|
+
`Nothing can supply them there. Invoke it from a step instead, or drop the requirement.`
|
|
135
|
+
: `${m.kind}/${resource.name}: '${name}' is wired at '${path}', where the consumer builds the ` +
|
|
136
|
+
`arguments from the slot's declared kind alone, but its contract requires ${list(unsatisfiable)} ` +
|
|
137
|
+
`which that kind does not declare. Nothing could supply them.`,
|
|
138
|
+
data: { resource, filePath, path },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const list = (keys) => keys.map((k) => `'${k}'`).join(", ");
|
|
144
|
+
/** The inputs a consumer can supply knowing only the slot — the UNION of every
|
|
145
|
+
* accepted kind's declared inputs.
|
|
146
|
+
*
|
|
147
|
+
* Union rather than the first match: a slot accepting several kinds may see any
|
|
148
|
+
* of them, and a name any accepted kind declares is one a consumer could
|
|
149
|
+
* plausibly supply. Taking the first kind's contract would make the check
|
|
150
|
+
* depend on the order `anyOf` branches happen to be written in. Undefined when
|
|
151
|
+
* no accepted kind declares a contract at all — the "nothing to violate" case. */
|
|
152
|
+
function slotDeclaredInputs(refs, resolveDef, manifests) {
|
|
153
|
+
let seen;
|
|
154
|
+
for (const ref of refs) {
|
|
155
|
+
const def = resolveDef(ref);
|
|
156
|
+
if (!def)
|
|
157
|
+
continue;
|
|
158
|
+
const schema = contractSchemaFor(undefined, def, resolveDef, manifests);
|
|
159
|
+
if (!schema)
|
|
160
|
+
continue;
|
|
161
|
+
seen ??= new Set();
|
|
162
|
+
for (const key of Object.keys((schema.properties ?? {}))) {
|
|
163
|
+
seen.add(key);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return seen ? [...seen] : undefined;
|
|
167
|
+
}
|
|
168
|
+
/** True when every kind a slot accepts is started rather than invoked — the
|
|
169
|
+
* capabilities whose dispatch verb is `run()`, which passes no arguments.
|
|
170
|
+
* Keyed on the declared capability, so a user-defined abstract resolves the
|
|
171
|
+
* same way a built-in does. */
|
|
172
|
+
function isRunOnlySlot(refs, resolveDef) {
|
|
173
|
+
if (refs.length === 0)
|
|
174
|
+
return false;
|
|
175
|
+
return refs.every((ref) => {
|
|
176
|
+
const def = resolveDef(ref);
|
|
177
|
+
const capability = def?.capability ?? ref;
|
|
178
|
+
return capability === "Telo.Runnable" || capability === "Telo.Service";
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** The resolved contract schema for a target: its own declaration first, then
|
|
182
|
+
* the nearest along `extends`. Inline schemas only — a named reference resolves
|
|
183
|
+
* through machinery this pass does not carry, and half-resolving would be worse
|
|
184
|
+
* than not reporting. */
|
|
185
|
+
function contractSchemaFor(manifest, definition, resolveDef, manifests, direction = "inputType") {
|
|
186
|
+
const declaringModule = (manifest ?? definition)?.metadata
|
|
187
|
+
?.module;
|
|
188
|
+
const own = manifest ? manifest[direction] : undefined;
|
|
189
|
+
const declared = own !== undefined && own !== null
|
|
190
|
+
? own
|
|
191
|
+
: effectiveContractField(definition, resolveDef, direction);
|
|
192
|
+
const inline = inlineSchemaOf(declared);
|
|
193
|
+
if (inline)
|
|
194
|
+
return inline;
|
|
195
|
+
// A `!ref` / bare name: resolve it to the named type resource in scope.
|
|
196
|
+
const named = typeof declared === "string"
|
|
197
|
+
? declared
|
|
198
|
+
: declared && typeof declared === "object" && typeof declared.name === "string"
|
|
199
|
+
? declared.name
|
|
200
|
+
: undefined;
|
|
201
|
+
if (!named)
|
|
202
|
+
return undefined;
|
|
203
|
+
const typeManifest = findInModule(manifests, named, declaringModule);
|
|
204
|
+
return typeManifest ? inlineSchemaOf(typeManifest) : undefined;
|
|
205
|
+
}
|
|
206
|
+
/** The input names a contract makes mandatory. Undefined means "no contract
|
|
207
|
+
* declared" — distinct from an empty list, which means "declared, requires
|
|
208
|
+
* nothing". */
|
|
209
|
+
function requiredInputsOf(schema) {
|
|
210
|
+
if (!schema)
|
|
211
|
+
return undefined;
|
|
212
|
+
return Array.isArray(schema.required) ? schema.required : [];
|
|
213
|
+
}
|
|
214
|
+
/** Whether the object containing this ref slot also declares an inputs field —
|
|
215
|
+
* the `invoke`/`inputs` pairing, recognised through the topology role rather
|
|
216
|
+
* than a field name, so a composer spelling it differently still counts. */
|
|
217
|
+
function slotTakesPairedInputs(schema, path) {
|
|
218
|
+
const parentPath = path.slice(0, Math.max(0, path.lastIndexOf(".")));
|
|
219
|
+
const parent = parentPath ? navigateSchema(schema, parentPath) : schema;
|
|
220
|
+
const properties = (parent?.properties ?? {});
|
|
221
|
+
return Object.values(properties).some((p) => p?.["x-telo-topology-role"] === "inputs");
|
|
222
|
+
}
|
|
223
|
+
/** Follow a field-map path (`a.b[].c`) through a schema's properties/items. */
|
|
224
|
+
function navigateSchema(schema, path) {
|
|
225
|
+
let node = schema;
|
|
226
|
+
for (const raw of path.split(".")) {
|
|
227
|
+
if (!node)
|
|
228
|
+
return undefined;
|
|
229
|
+
const key = raw.replace(/\[\]|\{\}/g, "");
|
|
230
|
+
let next = (node.properties ?? {})[key];
|
|
231
|
+
if (!next)
|
|
232
|
+
return undefined;
|
|
233
|
+
if (raw.includes("[]"))
|
|
234
|
+
next = (next.items ?? {});
|
|
235
|
+
node = next;
|
|
236
|
+
}
|
|
237
|
+
return node;
|
|
238
|
+
}
|
|
239
|
+
/** The `{kind, name}` references actually written at a field-map path. */
|
|
240
|
+
function refValuesAt(manifest, path) {
|
|
241
|
+
const out = [];
|
|
242
|
+
const walk = (node, segments) => {
|
|
243
|
+
if (node == null)
|
|
244
|
+
return;
|
|
245
|
+
if (segments.length === 0) {
|
|
246
|
+
const items = Array.isArray(node) ? node : [node];
|
|
247
|
+
for (const item of items) {
|
|
248
|
+
// A reference is `{kind, name}` — BOTH fields. Requiring only `name`
|
|
249
|
+
// would read an inline invoke step (`{ name, invoke, inputs }`) as a
|
|
250
|
+
// reference to a resource called after the step, which it is not: that
|
|
251
|
+
// `name` labels the step, and the step is an invoke site anyway.
|
|
252
|
+
if (item &&
|
|
253
|
+
typeof item === "object" &&
|
|
254
|
+
typeof item.name === "string" &&
|
|
255
|
+
typeof item.kind === "string") {
|
|
256
|
+
out.push(item.name);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const [head, ...rest] = segments;
|
|
262
|
+
const key = head.replace(/\[\]|\{\}/g, "");
|
|
263
|
+
const value = node[key];
|
|
264
|
+
if (head.includes("[]") && Array.isArray(value)) {
|
|
265
|
+
for (const item of value)
|
|
266
|
+
walk(item, rest);
|
|
267
|
+
}
|
|
268
|
+
else if (head.includes("{}") && value && typeof value === "object") {
|
|
269
|
+
for (const item of Object.values(value))
|
|
270
|
+
walk(item, rest);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
walk(value, rest);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
walk(manifest, path.split("."));
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* A named contract must name a type that exists.
|
|
281
|
+
*
|
|
282
|
+
* The runtime raises `ERR_CONTRACT_UNRESOLVABLE` on the first dispatch through
|
|
283
|
+
* an unresolvable contract, which is exactly the failure a checker should have
|
|
284
|
+
* caught: nothing about it depends on runtime values. Instance-level slots were
|
|
285
|
+
* already covered, because a module kind declares its `inputType` property with
|
|
286
|
+
* `x-telo-ref: Telo.Type`; the KIND-level fields are on `Telo.Definition`, which
|
|
287
|
+
* is deliberately excluded from reference validation, so they had no check at
|
|
288
|
+
* all and the same typo behaved differently depending on where it was written.
|
|
289
|
+
*/
|
|
290
|
+
function checkContractResolves(m, md, manifests, resource, filePath, diagnostics) {
|
|
291
|
+
const declaringModule = m.metadata?.module;
|
|
292
|
+
for (const direction of ["inputType", "outputType"]) {
|
|
293
|
+
const named = namedTypeReference(md[direction]);
|
|
294
|
+
if (!named)
|
|
295
|
+
continue;
|
|
296
|
+
if (findInModule(manifests, named, declaringModule))
|
|
297
|
+
continue;
|
|
298
|
+
diagnostics.push({
|
|
299
|
+
severity: DiagnosticSeverity.Error,
|
|
300
|
+
code: "CONTRACT_TYPE_NOT_FOUND",
|
|
301
|
+
source: SOURCE,
|
|
302
|
+
message: `${m.kind}/${resource.name}: \`${direction}\` names the type '${named}', which is not declared ` +
|
|
303
|
+
`in scope. The contract cannot be enforced, so every call through it would fail at dispatch. ` +
|
|
304
|
+
`Declare a \`Telo.JsonSchema\` with that name, or inline the shape.`,
|
|
305
|
+
data: { resource, filePath, path: direction },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** The name a contract field references, when it is a reference at all. An
|
|
310
|
+
* inline shape or a raw schema names nothing and resolves on its own. */
|
|
311
|
+
function namedTypeReference(value) {
|
|
312
|
+
if (typeof value === "string")
|
|
313
|
+
return value;
|
|
314
|
+
if (!value || typeof value !== "object")
|
|
315
|
+
return undefined;
|
|
316
|
+
const ref = value;
|
|
317
|
+
if (ref.schema && typeof ref.schema === "object")
|
|
318
|
+
return undefined;
|
|
319
|
+
return typeof ref.name === "string" ? ref.name : undefined;
|
|
320
|
+
}
|
|
321
|
+
/** A child that inherits its controller and REPLACES a contract must bridge it:
|
|
322
|
+
* contracts resolve to the nearest declaration and never merge, so the
|
|
323
|
+
* inherited controller only understands its own shape. Without the mapping the
|
|
324
|
+
* declaration is inert — precisely the silent no-op this rule exists to end. */
|
|
325
|
+
function checkMappingRequired(m, resource, filePath, resolveDef, diagnostics) {
|
|
326
|
+
const def = m;
|
|
327
|
+
const body = m;
|
|
328
|
+
for (const direction of ["inputType", "outputType"]) {
|
|
329
|
+
if (!needsContractMapping(def, resolveDef, direction))
|
|
330
|
+
continue;
|
|
331
|
+
const mappingField = mappingFieldFor(direction);
|
|
332
|
+
if (body[mappingField] != null)
|
|
333
|
+
continue;
|
|
334
|
+
diagnostics.push({
|
|
335
|
+
severity: DiagnosticSeverity.Error,
|
|
336
|
+
code: "CONTRACT_MISSING_MAPPING",
|
|
337
|
+
source: SOURCE,
|
|
338
|
+
message: `${m.kind}/${resource.name}: declares its own \`${direction}\` but inherits its controller, ` +
|
|
339
|
+
`and no \`${mappingField}:\` mapping bridges the two. The inherited controller only understands ` +
|
|
340
|
+
`the kind it came from, so without a mapping the declaration would never be applied. Add a ` +
|
|
341
|
+
`\`${mappingField}:\` mapping, or drop \`${direction}\` to inherit the contract unchanged.`,
|
|
342
|
+
data: { resource, filePath, path: direction },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** `inputs:` on a resource once meant "a JSON Schema property map" on the run
|
|
347
|
+
* kinds. It is values everywhere else, and now means values everywhere — so a
|
|
348
|
+
* leftover map against a kind that declares no `inputs` property is a migration
|
|
349
|
+
* the author has not finished, not an unknown field. */
|
|
350
|
+
function checkLeftoverInputsSchema(m, definition, md, resource, filePath, diagnostics) {
|
|
351
|
+
const value = md.inputs;
|
|
352
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
353
|
+
return;
|
|
354
|
+
const properties = (definition.schema?.properties ?? {});
|
|
355
|
+
if ("inputs" in properties)
|
|
356
|
+
return;
|
|
357
|
+
if (!("inputType" in properties))
|
|
358
|
+
return;
|
|
359
|
+
diagnostics.push({
|
|
360
|
+
severity: DiagnosticSeverity.Error,
|
|
361
|
+
code: "CONTRACT_INPUTS_SCHEMA_FORM",
|
|
362
|
+
source: SOURCE,
|
|
363
|
+
message: `${m.kind}/${resource.name}: \`inputs:\` no longer declares an input contract — it always means ` +
|
|
364
|
+
`values now. Move the property map to \`inputType:\` (a \`Telo.JsonSchema\` shape, a named type ` +
|
|
365
|
+
`reference, or an inline schema).`,
|
|
366
|
+
data: { resource, filePath, path: "inputs" },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/** The schema behind an INLINE type declaration, which is all this pass can read
|
|
370
|
+
* without a manifest lookup. A named reference resolves elsewhere; skipping it
|
|
371
|
+
* here keeps the check total rather than half-informed. */
|
|
372
|
+
function inlineSchemaOf(value) {
|
|
373
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
374
|
+
return undefined;
|
|
375
|
+
const obj = value;
|
|
376
|
+
if (obj.schema && typeof obj.schema === "object")
|
|
377
|
+
return obj.schema;
|
|
378
|
+
if (obj.properties && typeof obj.properties === "object")
|
|
379
|
+
return obj;
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
/** Find a manifest by name within a module, falling back to a unique global
|
|
383
|
+
* match. Names are unique per module, not per flattened graph, so an unscoped
|
|
384
|
+
* `find` can silently return another module's resource; an ambiguous global
|
|
385
|
+
* match resolves to nothing rather than to a guess. */
|
|
386
|
+
function findInModule(manifests, name, module) {
|
|
387
|
+
const byName = manifests.filter((t) => t.metadata?.name === name);
|
|
388
|
+
if (byName.length === 0)
|
|
389
|
+
return undefined;
|
|
390
|
+
const scoped = byName.filter((t) => t.metadata?.module === module);
|
|
391
|
+
if (scoped.length === 1)
|
|
392
|
+
return scoped[0];
|
|
393
|
+
return byName.length === 1 ? byName[0] : undefined;
|
|
394
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AliasResolver, ModuleScopes } from "./alias-resolver.js";
|
|
2
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
3
|
+
export interface StepInputIssue {
|
|
4
|
+
path: string;
|
|
5
|
+
targetLabel: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate every step's `inputs:` against the invoked target's declared input
|
|
10
|
+
* contract — the static half of what the kernel enforces at dispatch.
|
|
11
|
+
*
|
|
12
|
+
* Worth doing statically because a call site is where the mistake is made and
|
|
13
|
+
* where the author can see both sides: a misspelled key or a wrong-shaped value
|
|
14
|
+
* would otherwise surface at runtime inside the callee, several steps from its
|
|
15
|
+
* cause, naming a resource the author may not have written.
|
|
16
|
+
*
|
|
17
|
+
* CEL leaves are replaced by schema-shaped placeholders first (`substituteCelFields`),
|
|
18
|
+
* so an expression is never a false positive — only structural disagreement is
|
|
19
|
+
* reported. Nothing is hardcoded about `Run.Sequence`: the invoke field comes
|
|
20
|
+
* from `x-telo-step-context`, and the paired inputs field from whichever sibling
|
|
21
|
+
* property carries `x-telo-topology-role: inputs`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function collectStepInputIssues(manifest: Record<string, any>, defSchema: Record<string, any>, allManifests: Record<string, any>[], defs: DefinitionRegistry, aliases: AliasResolver, scopes: ModuleScopes): StepInputIssue[];
|
|
24
|
+
//# sourceMappingURL=validate-step-inputs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-step-inputs.d.ts","sourceRoot":"","sources":["../src/validate-step-inputs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAanE,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC7B,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC9B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,EACnC,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,EACtB,MAAM,EAAE,YAAY,GACnB,cAAc,EAAE,CAyElB"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { resolveContract } from "./invocation-contract.js";
|
|
2
|
+
import { substituteCelFields, validateAgainstSchema } from "./schema-compat.js";
|
|
3
|
+
import { analyzerContractScope, containerOf, gatherPropertySchemas, missingRequired, resolveLocalRef, walkStepArray, } from "./analyzer.js";
|
|
4
|
+
/**
|
|
5
|
+
* Validate every step's `inputs:` against the invoked target's declared input
|
|
6
|
+
* contract — the static half of what the kernel enforces at dispatch.
|
|
7
|
+
*
|
|
8
|
+
* Worth doing statically because a call site is where the mistake is made and
|
|
9
|
+
* where the author can see both sides: a misspelled key or a wrong-shaped value
|
|
10
|
+
* would otherwise surface at runtime inside the callee, several steps from its
|
|
11
|
+
* cause, naming a resource the author may not have written.
|
|
12
|
+
*
|
|
13
|
+
* CEL leaves are replaced by schema-shaped placeholders first (`substituteCelFields`),
|
|
14
|
+
* so an expression is never a false positive — only structural disagreement is
|
|
15
|
+
* reported. Nothing is hardcoded about `Run.Sequence`: the invoke field comes
|
|
16
|
+
* from `x-telo-step-context`, and the paired inputs field from whichever sibling
|
|
17
|
+
* property carries `x-telo-topology-role: inputs`.
|
|
18
|
+
*/
|
|
19
|
+
export function collectStepInputIssues(manifest, defSchema, allManifests, defs, aliases, scopes) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const props = defSchema.properties;
|
|
22
|
+
if (!props)
|
|
23
|
+
return out;
|
|
24
|
+
const contractScope = analyzerContractScope(defs, aliases, scopes, allManifests);
|
|
25
|
+
const readingModule = manifest.metadata?.module;
|
|
26
|
+
for (const [fieldName, fieldSchema] of Object.entries(props)) {
|
|
27
|
+
const stepCtx = fieldSchema["x-telo-step-context"];
|
|
28
|
+
if (!stepCtx?.invoke)
|
|
29
|
+
continue;
|
|
30
|
+
const steps = manifest[fieldName];
|
|
31
|
+
if (!Array.isArray(steps))
|
|
32
|
+
continue;
|
|
33
|
+
const stepItemSchema = resolveLocalRef(fieldSchema.items, defSchema);
|
|
34
|
+
if (!stepItemSchema)
|
|
35
|
+
continue;
|
|
36
|
+
// The inputs field is whichever sibling declares the role — never the literal
|
|
37
|
+
// name, so a composer that spells it differently still gets checked.
|
|
38
|
+
let inputsField;
|
|
39
|
+
for (const [key, sub] of gatherPropertySchemas(stepItemSchema)) {
|
|
40
|
+
if (sub?.["x-telo-topology-role"] === "inputs")
|
|
41
|
+
inputsField = key;
|
|
42
|
+
}
|
|
43
|
+
if (!inputsField)
|
|
44
|
+
continue;
|
|
45
|
+
walkStepArray(steps, stepItemSchema, defSchema, fieldName, (step, stepPath) => {
|
|
46
|
+
const invoke = step[stepCtx.invoke];
|
|
47
|
+
const values = step[inputsField];
|
|
48
|
+
if (!invoke || typeof invoke !== "object")
|
|
49
|
+
return;
|
|
50
|
+
if (!values || typeof values !== "object" || Array.isArray(values))
|
|
51
|
+
return;
|
|
52
|
+
const invokedKind = invoke.kind;
|
|
53
|
+
const invokedName = invoke.name;
|
|
54
|
+
const invokedManifest = invokedName
|
|
55
|
+
? allManifests.find((m) => m.metadata?.name === invokedName && (!invokedKind || m.kind === invokedKind))
|
|
56
|
+
: invoke;
|
|
57
|
+
const invokedDef = invokedKind
|
|
58
|
+
? contractScope.resolveIn(invokedKind, readingModule)
|
|
59
|
+
: undefined;
|
|
60
|
+
const contract = resolveContract("inputType", invokedManifest, invokedDef, contractScope);
|
|
61
|
+
if (!contract)
|
|
62
|
+
return;
|
|
63
|
+
// Findings AT a substituted path are about a placeholder, not about
|
|
64
|
+
// anything the author wrote — a `pattern`-constrained string or a `oneOf`
|
|
65
|
+
// of unrelated shapes cannot be satisfied by any stand-in. Structural
|
|
66
|
+
// findings (missing required, unknown property) are located at the
|
|
67
|
+
// container and survive the filter.
|
|
68
|
+
const celPaths = new Set();
|
|
69
|
+
const substituted = substituteCelFields(values, contract.schema, undefined, (p) => celPaths.add(p));
|
|
70
|
+
for (const issue of validateAgainstSchema(substituted, contract.schema)) {
|
|
71
|
+
if (celPaths.has(issue.path))
|
|
72
|
+
continue;
|
|
73
|
+
// A missing-required issue names the property that ISN'T there, so
|
|
74
|
+
// anchoring on it finds no node and the diagnostic degrades to 1:1 —
|
|
75
|
+
// losing the location of the most common contract mistake. Anchor on the
|
|
76
|
+
// container that should have held it, which does exist.
|
|
77
|
+
const anchor = missingRequired(issue) ? containerOf(issue.path) : issue.path;
|
|
78
|
+
out.push({
|
|
79
|
+
path: anchor ? `${stepPath}.${inputsField}.${anchor}` : `${stepPath}.${inputsField}`,
|
|
80
|
+
targetLabel: invokedName ?? invokedKind ?? "the invoked resource",
|
|
81
|
+
message: issue.message,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
2
|
-
import type
|
|
2
|
+
import { type ResourceManifest } from "@telorun/sdk";
|
|
3
3
|
import { type AliasResolver } from "./alias-resolver.js";
|
|
4
4
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
5
|
import { type AnalysisDiagnostic } from "./types.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-throws-coverage.d.ts","sourceRoot":"","sources":["../src/validate-throws-coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"validate-throws-coverage.d.ts","sourceRoot":"","sources":["../src/validate-throws-coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAOnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AA0ezE,oDAAoD;AACpD,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,WAAW,EAChB,eAAe,GAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAa,EACvD,WAAW,GAAE,GAAG,CAAC,MAAM,CAAa,GACnC,kBAAkB,EAAE,CAkDtB"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isTaggedSentinel } from "@telorun/templating";
|
|
2
|
+
import { AMBIENT_CONTRACT_ERROR_CODES, isAmbientContractErrorCode, } from "@telorun/sdk";
|
|
2
3
|
import { scopeResolverForModule } from "./alias-resolver.js";
|
|
3
4
|
import { createResolveCtx, resolveThrowsUnion, } from "./resolve-throws-union.js";
|
|
4
5
|
import { DiagnosticSeverity } from "./types.js";
|
|
@@ -186,12 +187,19 @@ function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env
|
|
|
186
187
|
const { proven, codes } = extractCoveredCodes(e.when, env);
|
|
187
188
|
if (proven) {
|
|
188
189
|
for (const c of codes) {
|
|
190
|
+
// An ambient kernel code (contract violations) is raised by the kernel,
|
|
191
|
+
// not declared by the kind, so naming it is legal and still typo-checked
|
|
192
|
+
// — but it is NOT part of the declared union, so it never counts toward
|
|
193
|
+
// coverage. Folding these into every union would make every bounded
|
|
194
|
+
// catches: block in the standard library incomplete overnight.
|
|
195
|
+
if (isAmbientContractErrorCode(c))
|
|
196
|
+
continue;
|
|
189
197
|
if (!declaredCodes.has(c)) {
|
|
190
198
|
diagnostics.push({
|
|
191
199
|
severity: DiagnosticSeverity.Error,
|
|
192
200
|
code: "UNDECLARED_THROW_CODE",
|
|
193
201
|
source: SOURCE,
|
|
194
|
-
message: `catches[${i}] references code '${c}' which is not in the handler's declared throw union {${[...declaredCodes].sort().join(", ") || "∅"}}${union.unbounded ? "
|
|
202
|
+
message: `catches[${i}] references code '${c}' which is not in the handler's declared throw union {${[...declaredCodes].sort().join(", ") || "∅"}} (ambient kernel codes ${AMBIENT_CONTRACT_ERROR_CODES.join(", ")} may also be named)${union.unbounded ? "; the union is unbounded, so a catch-all is required" : ""}.`,
|
|
195
203
|
data: { resource, filePath, path: `${arrayPath}[${i}].when` },
|
|
196
204
|
});
|
|
197
205
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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.61.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@telorun/sdk": "*"
|