@telorun/analyzer 0.70.0 → 0.72.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.map +1 -1
- package/dist/analysis-registry.js +4 -5
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +92 -10
- package/dist/call-graph.d.ts.map +1 -1
- package/dist/call-graph.js +10 -4
- package/dist/catch-scope.d.ts +72 -0
- package/dist/catch-scope.d.ts.map +1 -0
- package/dist/catch-scope.js +102 -0
- package/dist/cel-scope-query.d.ts +14 -0
- package/dist/cel-scope-query.d.ts.map +1 -1
- package/dist/cel-scope-query.js +36 -6
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +3 -4
- package/dist/deprecation.d.ts +21 -0
- package/dist/deprecation.d.ts.map +1 -0
- package/dist/deprecation.js +26 -0
- package/dist/flatten-for-analyzer.d.ts +2 -2
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/manifest-visitor.d.ts +17 -1
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +16 -4
- package/dist/migrations/report.d.ts +1 -1
- package/dist/migrations/report.d.ts.map +1 -1
- package/dist/migrations/report.js +5 -0
- package/dist/module-alias-scope.d.ts +65 -0
- package/dist/module-alias-scope.d.ts.map +1 -0
- package/dist/module-alias-scope.js +25 -0
- package/dist/{zone-module-documents.d.ts → module-documents.d.ts} +11 -7
- package/dist/module-documents.d.ts.map +1 -0
- package/dist/ref-sentinel-target.d.ts +38 -0
- package/dist/ref-sentinel-target.d.ts.map +1 -0
- package/dist/ref-sentinel-target.js +13 -0
- package/dist/ref-slot.d.ts +15 -0
- package/dist/ref-slot.d.ts.map +1 -1
- package/dist/ref-slot.js +7 -0
- package/dist/resolve-schema-type-refs.d.ts.map +1 -1
- package/dist/resolve-schema-type-refs.js +2 -1
- package/dist/resolve-throws-union.d.ts +47 -2
- package/dist/resolve-throws-union.d.ts.map +1 -1
- package/dist/resolve-throws-union.js +199 -20
- package/dist/resolve-zone-requirements.d.ts +3 -3
- package/dist/resolve-zone-requirements.d.ts.map +1 -1
- package/dist/resolve-zone-requirements.js +12 -12
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +13 -1
- package/dist/schema-error-report.d.ts.map +1 -1
- package/dist/schema-error-report.js +48 -4
- package/dist/schema-keywords.d.ts.map +1 -1
- package/dist/schema-keywords.js +3 -1
- package/dist/schema-walk.d.ts +27 -0
- package/dist/schema-walk.d.ts.map +1 -1
- package/dist/schema-walk.js +44 -0
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/template-body.d.ts.map +1 -1
- package/dist/template-body.js +2 -4
- package/dist/types.d.ts +20 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +11 -0
- package/dist/validate-identifier-names.d.ts +2 -2
- package/dist/validate-identifier-names.d.ts.map +1 -1
- package/dist/validate-identifier-names.js +22 -7
- package/dist/validate-invocation-contract.d.ts +5 -0
- package/dist/validate-invocation-contract.d.ts.map +1 -1
- package/dist/validate-invocation-contract.js +114 -3
- package/dist/validate-logging.d.ts.map +1 -1
- package/dist/validate-logging.js +2 -2
- package/dist/validate-ref-slots.d.ts +1 -1
- package/dist/validate-ref-slots.d.ts.map +1 -1
- package/dist/validate-ref-slots.js +34 -0
- package/dist/validate-references.d.ts +16 -5
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +57 -15
- package/dist/validate-resource-inputs.d.ts +1 -26
- package/dist/validate-resource-inputs.d.ts.map +1 -1
- package/dist/validate-resource-inputs.js +12 -2
- package/dist/validate-schema-type-refs.d.ts.map +1 -1
- package/dist/validate-schema-type-refs.js +2 -1
- package/dist/validate-throws-coverage.d.ts +5 -1
- package/dist/validate-throws-coverage.d.ts.map +1 -1
- package/dist/validate-throws-coverage.js +241 -86
- package/package.json +3 -3
- package/src/analysis-registry.ts +4 -5
- package/src/analyzer.ts +115 -11
- package/src/call-graph.ts +9 -3
- package/src/catch-scope.ts +157 -0
- package/src/cel-scope-query.ts +45 -8
- package/src/definition-registry.ts +3 -6
- package/src/deprecation.ts +36 -0
- package/src/flatten-for-analyzer.ts +3 -3
- package/src/index.ts +10 -3
- package/src/manifest-visitor.ts +30 -5
- package/src/migrations/report.ts +5 -1
- package/src/module-alias-scope.ts +94 -0
- package/src/{zone-module-documents.ts → module-documents.ts} +10 -6
- package/src/ref-sentinel-target.ts +46 -0
- package/src/ref-slot.ts +19 -0
- package/src/resolve-schema-type-refs.ts +2 -1
- package/src/resolve-throws-union.ts +253 -20
- package/src/resolve-zone-requirements.ts +14 -14
- package/src/schema-compat.ts +13 -0
- package/src/schema-error-report.ts +50 -6
- package/src/schema-keywords.ts +4 -1
- package/src/schema-walk.ts +56 -0
- package/src/telo-version.ts +1 -1
- package/src/template-body.ts +2 -5
- package/src/types.ts +21 -3
- package/src/validate-identifier-names.ts +28 -9
- package/src/validate-invocation-contract.ts +128 -2
- package/src/validate-logging.ts +2 -3
- package/src/validate-ref-slots.ts +41 -1
- package/src/validate-references.ts +74 -14
- package/src/validate-resource-inputs.ts +26 -3
- package/src/validate-schema-type-refs.ts +2 -1
- package/src/validate-throws-coverage.ts +344 -92
- package/dist/zone-module-documents.d.ts.map +0 -1
- /package/dist/{zone-module-documents.js → module-documents.js} +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isTaggedSentinel } from "@telorun/templating";
|
|
2
2
|
import { AMBIENT_CONTRACT_ERROR_CODES, isAmbientContractErrorCode, } from "@telorun/sdk";
|
|
3
3
|
import { scopeResolverForModule } from "./alias-resolver.js";
|
|
4
|
-
import { createResolveCtx, resolveThrowsUnion, } from "./resolve-throws-union.js";
|
|
4
|
+
import { createResolveCtx, resolveScopeUnion, resolveThrowsUnion, } from "./resolve-throws-union.js";
|
|
5
|
+
import { buildEnclosers, collectScopedManifests, enclosingCoverage, } from "./catch-scope.js";
|
|
5
6
|
import { DiagnosticSeverity } from "./types.js";
|
|
6
7
|
import { extractAccessChains, validateChainAgainstSchema } from "./validate-cel-context.js";
|
|
7
8
|
import { isStepSlot } from "./step-slot.js";
|
|
@@ -49,7 +50,11 @@ function walkSchemaData(schema, data, path, ctx) {
|
|
|
49
50
|
}
|
|
50
51
|
else {
|
|
51
52
|
const catchesFor = propSchema["x-telo-catches-for"];
|
|
52
|
-
|
|
53
|
+
// The EMPTY pointer names the resource the list is written on, the
|
|
54
|
+
// spelling `x-telo-schema-projection-from` already uses for the same
|
|
55
|
+
// "this declaration, not one it references" meaning — so the test is
|
|
56
|
+
// presence, never truthiness.
|
|
57
|
+
if (catchesFor !== undefined) {
|
|
53
58
|
// Fire even when absent so the coverage check can flag handlers
|
|
54
59
|
// whose declared union is non-empty but the list is missing.
|
|
55
60
|
ctx.onCatches(entries, nextPath, dataObj, catchesFor);
|
|
@@ -152,7 +157,7 @@ function isErrorCodeRef(node) {
|
|
|
152
157
|
return obj.op === "id" && obj.args === "error";
|
|
153
158
|
}
|
|
154
159
|
/** Rule 7: within an outcome list, a no-`when:` entry must be the last entry. */
|
|
155
|
-
function checkCatchAllPlacement(entries, resource, channel, filePath, arrayPath) {
|
|
160
|
+
function checkCatchAllPlacement(entries, resource, channel, filePath, arrayPath, routing = resource) {
|
|
156
161
|
const diagnostics = [];
|
|
157
162
|
for (let i = 0; i < entries.length - 1; i++) {
|
|
158
163
|
const e = entries[i];
|
|
@@ -162,80 +167,103 @@ function checkCatchAllPlacement(entries, resource, channel, filePath, arrayPath)
|
|
|
162
167
|
code: "CATCHALL_NOT_LAST",
|
|
163
168
|
source: SOURCE,
|
|
164
169
|
message: `${channel}: catch-all entry (no \`when:\`) at index ${i} must be last — entries after it are unreachable.`,
|
|
165
|
-
data: { resource, filePath, path: `${arrayPath}[${i}]` },
|
|
170
|
+
data: { resource: routing, filePath, path: `${arrayPath}[${i}]` },
|
|
166
171
|
});
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
return diagnostics;
|
|
170
175
|
}
|
|
171
|
-
/**
|
|
172
|
-
*
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handler) {
|
|
176
|
-
const diagnostics = [];
|
|
177
|
-
const declaredCodes = new Set(union.codes.keys());
|
|
178
|
-
const covered = new Set();
|
|
176
|
+
/** Read one list's {@link ProvenCoverage} — the codes its coverage-proving
|
|
177
|
+
* `when:` clauses name, and whether it ends in a catch-all. */
|
|
178
|
+
function provenCoverage(entries, env) {
|
|
179
|
+
const codes = new Set();
|
|
179
180
|
let hasCatchAll = false;
|
|
180
|
-
for (
|
|
181
|
-
const e = entries[i];
|
|
181
|
+
for (const e of entries) {
|
|
182
182
|
if (!e)
|
|
183
183
|
continue;
|
|
184
184
|
if (!e.when) {
|
|
185
185
|
hasCatchAll = true;
|
|
186
186
|
continue;
|
|
187
187
|
}
|
|
188
|
+
const { proven, codes: entryCodes } = extractCoveredCodes(e.when, env);
|
|
189
|
+
if (!proven)
|
|
190
|
+
continue;
|
|
191
|
+
for (const c of entryCodes)
|
|
192
|
+
codes.add(c);
|
|
193
|
+
}
|
|
194
|
+
return { codes, hasCatchAll };
|
|
195
|
+
}
|
|
196
|
+
/** Rule 4: a coverage-proving `when:` may only name a code the list's own
|
|
197
|
+
* denominator can produce. Runs for EVERY list, scope lists included — the
|
|
198
|
+
* denominator differs (a handler's union, or the enclosing resource's own), the
|
|
199
|
+
* typo check does not. */
|
|
200
|
+
function checkUndeclaredCodes(entries, union, resource, filePath, arrayPath, env, denominator, routing = resource) {
|
|
201
|
+
const diagnostics = [];
|
|
202
|
+
const declaredCodes = new Set(union.codes.keys());
|
|
203
|
+
for (let i = 0; i < entries.length; i++) {
|
|
204
|
+
const e = entries[i];
|
|
205
|
+
if (!e?.when)
|
|
206
|
+
continue;
|
|
188
207
|
const { proven, codes } = extractCoveredCodes(e.when, env);
|
|
189
|
-
if (proven)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
covered.add(c);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
208
|
+
if (!proven)
|
|
209
|
+
continue;
|
|
210
|
+
for (const c of codes) {
|
|
211
|
+
// An ambient kernel code (contract violations) is raised by the kernel,
|
|
212
|
+
// not declared by the kind, so naming it is legal and still typo-checked
|
|
213
|
+
// — but it is NOT part of the declared union, so it never counts toward
|
|
214
|
+
// coverage. Folding these into every union would make every bounded
|
|
215
|
+
// catches: block in the standard library incomplete overnight.
|
|
216
|
+
if (isAmbientContractErrorCode(c))
|
|
217
|
+
continue;
|
|
218
|
+
if (declaredCodes.has(c))
|
|
219
|
+
continue;
|
|
220
|
+
diagnostics.push({
|
|
221
|
+
severity: DiagnosticSeverity.Error,
|
|
222
|
+
code: "UNDECLARED_THROW_CODE",
|
|
223
|
+
source: SOURCE,
|
|
224
|
+
message: `catches[${i}] references code '${c}' which is not in ${denominator} {${[...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" : ""}.`,
|
|
225
|
+
data: { resource: routing, filePath, path: `${arrayPath}[${i}].when` },
|
|
226
|
+
});
|
|
211
227
|
}
|
|
212
228
|
}
|
|
229
|
+
return diagnostics;
|
|
230
|
+
}
|
|
231
|
+
/** Rule 1 + the unbounded-union rule, asked ONCE per dispatch site over every
|
|
232
|
+
* list that can render its throws — the site's own, its resource's scope list,
|
|
233
|
+
* and every scope enclosing that resource.
|
|
234
|
+
*
|
|
235
|
+
* Asking it per list is what would make this a false check rather than a
|
|
236
|
+
* missing one: a route that declares no `catches:` under a router that renders
|
|
237
|
+
* everything is completely covered, and reporting it fires on precisely the
|
|
238
|
+
* manifests scope lists exist to enable. */
|
|
239
|
+
function checkCoverage(union, resource, filePath, arrayPath, handler, covered, routing = resource) {
|
|
240
|
+
const diagnostics = [];
|
|
241
|
+
if (covered.hasCatchAll)
|
|
242
|
+
return diagnostics;
|
|
213
243
|
// Unbounded union (passthrough or transitive): authors can't enumerate the
|
|
214
|
-
// codes, so a catch-all is mandatory.
|
|
215
|
-
if (union.unbounded
|
|
244
|
+
// codes, so a catch-all is mandatory — at this list or any enclosing scope.
|
|
245
|
+
if (union.unbounded) {
|
|
216
246
|
diagnostics.push({
|
|
217
247
|
severity: DiagnosticSeverity.Error,
|
|
218
248
|
code: "UNBOUNDED_UNION_NEEDS_CATCHALL",
|
|
219
249
|
source: SOURCE,
|
|
220
|
-
message: `The handler's throw union is unbounded (inherit/passthrough resolution couldn't enumerate all codes).
|
|
221
|
-
data: { resource, filePath, path: arrayPath },
|
|
250
|
+
message: `The handler's throw union is unbounded (inherit/passthrough resolution couldn't enumerate all codes). A catch-all entry (no \`when:\`) is required — on this catches: list or on an enclosing one.`,
|
|
251
|
+
data: { resource: routing, filePath, path: arrayPath },
|
|
222
252
|
});
|
|
223
253
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
238
|
-
}
|
|
254
|
+
// One diagnostic per site, not per code: every uncovered code sits at the
|
|
255
|
+
// same dispatch site, and one catch-all answers all of them at once. A
|
|
256
|
+
// diagnostic each repeated the same location and the same fix N times.
|
|
257
|
+
const uncovered = [...union.codes.keys()].filter((c) => !covered.codes.has(c)).sort();
|
|
258
|
+
if (uncovered.length > 0) {
|
|
259
|
+
diagnostics.push({
|
|
260
|
+
severity: DiagnosticSeverity.Error,
|
|
261
|
+
code: "UNCOVERED_THROW_CODE",
|
|
262
|
+
source: SOURCE,
|
|
263
|
+
message: `handler ${handler?.name ? `\`!ref ${handler.name}\`` : `\`${handler?.kind ?? "?"}\``} can throw ${uncovered.length} code${uncovered.length === 1 ? "" : "s"} that no catches: entry handles — at this list or any enclosing scope: ${uncovered.map((c) => `'${c}'`).join(", ")}. ` +
|
|
264
|
+
`Give each a matching \`when:\` (e.g. \`when: !cel "error.code == '${uncovered[0]}'"\`), or add a catch-all entry — one with no \`when:\`, placed last.`,
|
|
265
|
+
data: { resource: routing, filePath, path: arrayPath, uncovered },
|
|
266
|
+
});
|
|
239
267
|
}
|
|
240
268
|
return diagnostics;
|
|
241
269
|
}
|
|
@@ -243,7 +271,7 @@ function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env
|
|
|
243
271
|
* type-check against the data schema declared for the matched code(s). When the
|
|
244
272
|
* matching `when:` disjunctively covers multiple codes, use the intersection
|
|
245
273
|
* of their data schemas so only fields present on every code narrow through. */
|
|
246
|
-
function checkTypedErrorData(entries, union, resource, filePath, arrayPath, env) {
|
|
274
|
+
function checkTypedErrorData(entries, union, resource, filePath, arrayPath, env, routing = resource) {
|
|
247
275
|
const diagnostics = [];
|
|
248
276
|
// If the union is unbounded we can't narrow data schemas reliably — skip
|
|
249
277
|
// typed-data checks for those entries. The catch-all path still provides
|
|
@@ -274,16 +302,14 @@ function checkTypedErrorData(entries, union, resource, filePath, arrayPath, env)
|
|
|
274
302
|
if (schemas.length === 0)
|
|
275
303
|
continue;
|
|
276
304
|
const dataSchema = intersectDataSchemas(schemas);
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
305
|
+
// The WHOLE entry, not an enumerated `body` / `headers` pair. An HTTP catch
|
|
306
|
+
// entry keeps its body at `content[<mime>].body`, so reading `e.body` walked
|
|
307
|
+
// a field that shape never has and this check was inert for every catch list
|
|
308
|
+
// in the standard library. Walking the entry also covers `when:` and the
|
|
309
|
+
// per-MIME header overrides, which are equally places `error.data` is read.
|
|
310
|
+
collectCelStrings(e, `${arrayPath}[${i}]`).forEach((entry) => {
|
|
311
|
+
diagnostics.push(...checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env, routing));
|
|
281
312
|
});
|
|
282
|
-
if (e.headers) {
|
|
283
|
-
collectCelStrings(e.headers, `${arrayPath}[${i}].headers`).forEach((entry) => {
|
|
284
|
-
diagnostics.push(...checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env));
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
313
|
}
|
|
288
314
|
return diagnostics;
|
|
289
315
|
}
|
|
@@ -319,6 +345,15 @@ function intersectPropertySchemas(schemas) {
|
|
|
319
345
|
}
|
|
320
346
|
function collectCelStrings(value, path) {
|
|
321
347
|
const out = [];
|
|
348
|
+
// A `!cel` sentinel and a `${{ … }}` string are load-equivalent, and the
|
|
349
|
+
// formatter normalizes to the tag — so recognising only the string form left
|
|
350
|
+
// this check answering about a spelling no manifest in the repository uses,
|
|
351
|
+
// while walking the sentinel as a plain object found nothing.
|
|
352
|
+
if (isTaggedSentinel(value)) {
|
|
353
|
+
if (value.engine === "cel")
|
|
354
|
+
out.push({ expr: value.source.trim(), path });
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
322
357
|
if (typeof value === "string") {
|
|
323
358
|
for (const m of value.matchAll(TEMPLATE_REGEX)) {
|
|
324
359
|
out.push({ expr: m[1].trim(), path });
|
|
@@ -338,7 +373,7 @@ function collectCelStrings(value, path) {
|
|
|
338
373
|
}
|
|
339
374
|
return out;
|
|
340
375
|
}
|
|
341
|
-
function checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env) {
|
|
376
|
+
function checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env, routing = resource) {
|
|
342
377
|
let ast;
|
|
343
378
|
try {
|
|
344
379
|
ast = env.parse(entry.expr).ast;
|
|
@@ -360,7 +395,7 @@ function checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, e
|
|
|
360
395
|
code: "CEL_UNKNOWN_FIELD",
|
|
361
396
|
source: SOURCE,
|
|
362
397
|
message: `${resource.kind}/${resource.name}: CEL at '${entry.path}': error.data.${err}`,
|
|
363
|
-
data: { resource, filePath, path: entry.path },
|
|
398
|
+
data: { resource: routing, filePath, path: entry.path },
|
|
364
399
|
});
|
|
365
400
|
}
|
|
366
401
|
}
|
|
@@ -372,6 +407,30 @@ function checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, e
|
|
|
372
407
|
* carrying the legacy `x-telo-step-context` annotation. That is what drives the
|
|
373
408
|
* resolver's generic step traversal; a definition with `inherit: true` and no
|
|
374
409
|
* such array has no invocables to inherit from. */
|
|
410
|
+
/** The capabilities whose lifecycle includes a dispatch a caller can catch. On
|
|
411
|
+
* every other one a thrown error is a boot-time failure, not a structured
|
|
412
|
+
* runtime error for a downstream caller — a provider resolves configuration, a
|
|
413
|
+
* type has no instance, a sink is written to directly, and a service or mount
|
|
414
|
+
* is STARTED rather than called (what a router renders is not what a router
|
|
415
|
+
* throws).
|
|
416
|
+
*
|
|
417
|
+
* The strict half of the kernel's `ResourceDefinitionSchema` rule 8, and it has
|
|
418
|
+
* to exist here for the reason every `x-telo-*` accessor has a strict half: the
|
|
419
|
+
* kernel refuses at `create()`, which is a boot failure on a manifest that
|
|
420
|
+
* passed `telo check` — the static/runtime disagreement this repository treats
|
|
421
|
+
* as a defect. The two must agree; a change to either belongs in both.
|
|
422
|
+
*
|
|
423
|
+
* **Reported for a DEPENDENCY's definition too**, unlike `X_TELO_REF_UNRESOLVED`
|
|
424
|
+
* and `DEPRECATED_KIND`, which are entry-module-scoped. Those report something a
|
|
425
|
+
* consumer can live with — a slot that cannot be checked, a kind that still
|
|
426
|
+
* works — so silence costs them nothing and the noise is not theirs to fix.
|
|
427
|
+
* This one reports a definition the kernel REFUSES, so the manifest importing it
|
|
428
|
+
* cannot start at all: withholding that would replace a `telo check` error with
|
|
429
|
+
* an identical boot failure and no earlier warning. The action is a consumer's
|
|
430
|
+
* to take (pin another version, report upstream) even though the edit is not.
|
|
431
|
+
* Matches the neighbouring `INHERIT_WITHOUT_STEP_CONTEXT`, which is fatal the
|
|
432
|
+
* same way. */
|
|
433
|
+
const THROWS_CAPABLE_CAPABILITIES = new Set(["Telo.Invocable", "Telo.Runnable"]);
|
|
375
434
|
function validateThrowsDeclarations(manifests) {
|
|
376
435
|
const diagnostics = [];
|
|
377
436
|
for (const m of manifests) {
|
|
@@ -382,9 +441,27 @@ function validateThrowsDeclarations(manifests) {
|
|
|
382
441
|
continue;
|
|
383
442
|
const name = m.metadata?.name ?? "<unnamed>";
|
|
384
443
|
const filePath = m.metadata?.source;
|
|
444
|
+
// Only a DECLARED capability is judged. One inherited through `extends` is
|
|
445
|
+
// resolved elsewhere, and an unknown one is third-party extensibility the
|
|
446
|
+
// kernel's schema deliberately leaves open.
|
|
447
|
+
const capability = m.capability;
|
|
448
|
+
if (typeof capability === "string" && !THROWS_CAPABLE_CAPABILITIES.has(capability)) {
|
|
449
|
+
diagnostics.push({
|
|
450
|
+
severity: DiagnosticSeverity.Error,
|
|
451
|
+
code: "THROWS_ON_NON_DISPATCH_CAPABILITY",
|
|
452
|
+
source: SOURCE,
|
|
453
|
+
message: `Telo.Definition '${name}' declares throws: but its capability is '${capability}'. ` +
|
|
454
|
+
`A throw union describes what a CALLER can catch, so it is only meaningful on ` +
|
|
455
|
+
`${[...THROWS_CAPABLE_CAPABILITIES].join(" or ")}; on '${capability}' a thrown error is a ` +
|
|
456
|
+
`boot-time failure with no caller to render it. The kernel refuses this definition at ` +
|
|
457
|
+
`create(), so a manifest carrying it cannot start.`,
|
|
458
|
+
data: { resource: { kind: m.kind, name }, filePath, path: "throws" },
|
|
459
|
+
});
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
385
462
|
if (throws.inherit === true) {
|
|
386
463
|
const schema = m.schema;
|
|
387
|
-
if (!
|
|
464
|
+
if (!schemaDrivesInvocables(schema)) {
|
|
388
465
|
diagnostics.push({
|
|
389
466
|
severity: DiagnosticSeverity.Error,
|
|
390
467
|
code: "INHERIT_WITHOUT_STEP_CONTEXT",
|
|
@@ -400,7 +477,7 @@ function validateThrowsDeclarations(manifests) {
|
|
|
400
477
|
}
|
|
401
478
|
return diagnostics;
|
|
402
479
|
}
|
|
403
|
-
function
|
|
480
|
+
function schemaDrivesInvocables(schema) {
|
|
404
481
|
if (!schema || typeof schema !== "object")
|
|
405
482
|
return false;
|
|
406
483
|
if (isStepSlot(schema))
|
|
@@ -408,58 +485,136 @@ function schemaHasStepContext(schema) {
|
|
|
408
485
|
const props = schema.properties;
|
|
409
486
|
if (props && typeof props === "object") {
|
|
410
487
|
for (const v of Object.values(props)) {
|
|
411
|
-
if (
|
|
488
|
+
if (schemaDrivesInvocables(v))
|
|
412
489
|
return true;
|
|
413
490
|
}
|
|
414
491
|
}
|
|
415
|
-
if (schema.items &&
|
|
492
|
+
if (schema.items && schemaDrivesInvocables(schema.items))
|
|
416
493
|
return true;
|
|
417
494
|
for (const key of ["oneOf", "anyOf", "allOf"]) {
|
|
418
495
|
const arr = schema[key];
|
|
419
496
|
if (Array.isArray(arr)) {
|
|
420
497
|
for (const sub of arr)
|
|
421
|
-
if (
|
|
498
|
+
if (schemaDrivesInvocables(sub))
|
|
422
499
|
return true;
|
|
423
500
|
}
|
|
424
501
|
}
|
|
425
502
|
if (schema.$defs && typeof schema.$defs === "object") {
|
|
426
503
|
for (const v of Object.values(schema.$defs)) {
|
|
427
|
-
if (
|
|
504
|
+
if (schemaDrivesInvocables(v))
|
|
428
505
|
return true;
|
|
429
506
|
}
|
|
430
507
|
}
|
|
431
508
|
return false;
|
|
432
509
|
}
|
|
433
510
|
/** Entry point — invoked once per analyze() run. */
|
|
434
|
-
export function validateThrowsCoverage(manifests, defs, aliases, env, aliasesByModule = new Map(), rootModules = new Set()
|
|
511
|
+
export function validateThrowsCoverage(manifests, defs, aliases, env, aliasesByModule = new Map(), rootModules = new Set(),
|
|
512
|
+
/** Each imported library's full document set, so the walk can follow an
|
|
513
|
+
* exported entry point into the siblings it invokes — which a consumer's
|
|
514
|
+
* flat set does not carry. */
|
|
515
|
+
moduleManifests = new Map()) {
|
|
435
516
|
const diagnostics = [];
|
|
436
517
|
diagnostics.push(...validateThrowsDeclarations(manifests));
|
|
437
|
-
|
|
518
|
+
// A `with:`-scoped declaration is a resource like any other — it has a kind, a
|
|
519
|
+
// name, and, for a scoped `Http.Server`, a catch list that renders what its
|
|
520
|
+
// mounts throw. It is simply not in the flat set, so every check here used to
|
|
521
|
+
// skip it: its own entries went unchecked AND its coverage reached nothing it
|
|
522
|
+
// encloses. Standing a server up around a test is exactly that shape, so the
|
|
523
|
+
// sanctioned pattern was the one the pass could not see.
|
|
524
|
+
//
|
|
525
|
+
// Discovered through the shared visitor rather than a second scope walk, and
|
|
526
|
+
// folded into the pool every name is resolved against — a scoped mount whose
|
|
527
|
+
// target the resolver cannot find reads as an empty union, which reports every
|
|
528
|
+
// entry of that server's list as naming a code nothing throws.
|
|
529
|
+
const scoped = collectScopedManifests(manifests, defs, aliases, aliasesByModule, rootModules);
|
|
530
|
+
const allManifests = [...manifests, ...scoped.map((s) => s.manifest)];
|
|
531
|
+
const resolveCtx = createResolveCtx(allManifests, defs, aliases, aliasesByModule, rootModules, moduleManifests);
|
|
438
532
|
// The alias resolver for a manifest's own lexical scope — an imported library's
|
|
439
533
|
// resolver when it owns the manifest, else undefined (fall back to root aliases).
|
|
440
534
|
const scopeResolverFor = (m) => scopeResolverForModule(m.metadata?.module, rootModules, aliasesByModule);
|
|
441
|
-
|
|
535
|
+
// Pass 1 — read every outcome list, and record which resources each scope
|
|
536
|
+
// list encloses. A scope list has to be known before any site it covers is
|
|
537
|
+
// judged, so collection and judgement cannot be one loop.
|
|
538
|
+
const sites = [];
|
|
539
|
+
const scopeOf = new Map();
|
|
540
|
+
const scopedBy = new Map();
|
|
541
|
+
for (const s of scoped)
|
|
542
|
+
scopedBy.set(s.manifest, s);
|
|
543
|
+
const definitionFor = (manifest) => {
|
|
544
|
+
// A scoped declaration's kind is written in the alias scope of the module
|
|
545
|
+
// that declared the ENCLOSING resource; it carries no `metadata.module` of
|
|
546
|
+
// its own to find one by.
|
|
547
|
+
const anchor = scopedBy.get(manifest)?.owner ?? manifest;
|
|
548
|
+
const scopeResolver = scopeResolverFor(anchor);
|
|
549
|
+
const resolvedKind = scopeResolver?.resolveKind(manifest.kind) ?? aliases.resolveKind(manifest.kind);
|
|
550
|
+
return defs.resolve(manifest.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
|
|
551
|
+
};
|
|
552
|
+
const enclosers = buildEnclosers(allManifests, definitionFor, (m) => (scopedBy.get(m)?.owner ?? m).metadata?.module, resolveCtx);
|
|
553
|
+
for (const manifest of allManifests) {
|
|
442
554
|
if (!manifest.kind || !manifest.metadata?.name)
|
|
443
555
|
continue;
|
|
444
556
|
if (manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract")
|
|
445
557
|
continue;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
558
|
+
// A scoped declaration is reported against the document it is WRITTEN in —
|
|
559
|
+
// its owner's — at its own position inside that owner's scope array, because
|
|
560
|
+
// position lookup finds a TOP-LEVEL doc by (kind, name) and a scoped
|
|
561
|
+
// resource is not one. The message still names the scoped resource, so the
|
|
562
|
+
// reader is not sent to a resource that has no `catches:` at all.
|
|
563
|
+
const enclosing = scopedBy.get(manifest);
|
|
564
|
+
const anchor = enclosing?.owner ?? manifest;
|
|
565
|
+
const pathPrefix = enclosing ? `${enclosing.path}.` : "";
|
|
566
|
+
const scopeResolver = scopeResolverFor(anchor);
|
|
567
|
+
const definition = definitionFor(manifest);
|
|
449
568
|
if (!definition?.schema)
|
|
450
569
|
continue;
|
|
451
570
|
const resource = { kind: manifest.kind, name: manifest.metadata.name };
|
|
452
|
-
const
|
|
571
|
+
const routing = enclosing
|
|
572
|
+
? { kind: anchor.kind, name: anchor.metadata.name }
|
|
573
|
+
: resource;
|
|
574
|
+
const filePath = anchor.metadata?.source;
|
|
453
575
|
collectOutcomeLists(manifest, definition.schema, (ret) => {
|
|
454
|
-
diagnostics.push(...checkCatchAllPlacement(ret.entries, resource, "returns", filePath, ret.arrayPath));
|
|
576
|
+
diagnostics.push(...checkCatchAllPlacement(ret.entries, resource, "returns", filePath, `${pathPrefix}${ret.arrayPath}`, routing));
|
|
455
577
|
}, (entries, arrayPath, siblingData, catchesFor) => {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
578
|
+
const site = {
|
|
579
|
+
manifest,
|
|
580
|
+
definition,
|
|
581
|
+
resource,
|
|
582
|
+
routing,
|
|
583
|
+
filePath,
|
|
584
|
+
entries,
|
|
585
|
+
arrayPath: `${pathPrefix}${arrayPath}`,
|
|
586
|
+
scopeResolver,
|
|
587
|
+
handlerRef: catchesFor === "" ? null : resolveHandlerRef(siblingData[catchesFor]),
|
|
588
|
+
isScope: catchesFor === "",
|
|
589
|
+
};
|
|
590
|
+
sites.push(site);
|
|
591
|
+
if (site.isScope)
|
|
592
|
+
scopeOf.set(manifest, provenCoverage(entries, env));
|
|
461
593
|
});
|
|
462
594
|
}
|
|
595
|
+
const coverageMemo = new Map();
|
|
596
|
+
const scopeCoverageFor = (manifest) => enclosingCoverage(manifest, scopeOf, enclosers, coverageMemo);
|
|
597
|
+
// Pass 2 — judge each list against its own denominator, and each dispatch site
|
|
598
|
+
// against everything that can render its throws.
|
|
599
|
+
for (const site of sites) {
|
|
600
|
+
diagnostics.push(...checkCatchAllPlacement(site.entries, site.resource, "catches", site.filePath, site.arrayPath, site.routing));
|
|
601
|
+
const union = site.isScope
|
|
602
|
+
? resolveScopeUnion(site.manifest, site.definition, resolveCtx)
|
|
603
|
+
: handlerRefUnion(site.handlerRef, allManifests, resolveCtx, site.scopeResolver);
|
|
604
|
+
diagnostics.push(...checkUndeclaredCodes(site.entries, union, site.resource, site.filePath, site.arrayPath, env, site.isScope
|
|
605
|
+
? "the throw union of everything this resource drives"
|
|
606
|
+
: "the handler's declared throw union", site.routing));
|
|
607
|
+
diagnostics.push(...checkTypedErrorData(site.entries, union, site.resource, site.filePath, site.arrayPath, env, site.routing));
|
|
608
|
+
if (site.isScope)
|
|
609
|
+
continue;
|
|
610
|
+
const own = provenCoverage(site.entries, env);
|
|
611
|
+
const scope = scopeCoverageFor(site.manifest);
|
|
612
|
+
const covered = {
|
|
613
|
+
codes: new Set([...own.codes, ...scope.codes]),
|
|
614
|
+
hasCatchAll: own.hasCatchAll || scope.hasCatchAll,
|
|
615
|
+
};
|
|
616
|
+
diagnostics.push(...checkCoverage(union, site.resource, site.filePath, site.arrayPath, site.handlerRef, covered, site.routing));
|
|
617
|
+
}
|
|
463
618
|
return diagnostics;
|
|
464
619
|
}
|
|
465
620
|
/** Resolve a handler ref's effective throw union. Prefers the named manifest
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.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.19.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.87.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@telorun/sdk": "*"
|
package/src/analysis-registry.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { visitManifest as runVisitManifest, type ManifestVisitor } from "./manif
|
|
|
9
9
|
import type { ContractDirection, DefResolver } from "./extends-resolution.js";
|
|
10
10
|
import { resolveContract } from "./invocation-contract.js";
|
|
11
11
|
import { createResolveCtx, resolveThrowsUnion } from "./resolve-throws-union.js";
|
|
12
|
+
import { moduleAliasScope } from "./module-alias-scope.js";
|
|
12
13
|
import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
|
|
13
14
|
import { resolveSchemaTypeRefs as resolveSchemaTypeRefsIn } from "./resolve-schema-type-refs.js";
|
|
14
15
|
import type { AnalysisContext } from "./types.js";
|
|
@@ -258,8 +259,7 @@ export class AnalysisRegistry {
|
|
|
258
259
|
*/
|
|
259
260
|
resolveSchemaFrom(schemaFrom: string, declaringKind: string): Record<string, any> | undefined {
|
|
260
261
|
const def = this.resolveDefinition(declaringKind);
|
|
261
|
-
const
|
|
262
|
-
const scope = (ownerModule ? this.aliasesByModule.get(ownerModule) : undefined) ?? this.aliases;
|
|
262
|
+
const scope = moduleAliasScope(def?.metadata, this.aliases, this.aliasesByModule);
|
|
263
263
|
return this.defs.resolveSchemaFromNode(schemaFrom, scope);
|
|
264
264
|
}
|
|
265
265
|
|
|
@@ -337,7 +337,7 @@ export class AnalysisRegistry {
|
|
|
337
337
|
* the kind was read off. Falls back to the global table when the module is
|
|
338
338
|
* unknown or is a root. */
|
|
339
339
|
resolveDefinitionIn(kind: string, module?: string): ResourceDefinition | undefined {
|
|
340
|
-
const scope = (module
|
|
340
|
+
const scope = moduleAliasScope({ module }, this.aliases, this.aliasesByModule);
|
|
341
341
|
const canonical = scope.resolveKind(kind);
|
|
342
342
|
return this.defs.resolve(kind) ?? (canonical ? this.defs.resolve(canonical) : undefined);
|
|
343
343
|
}
|
|
@@ -354,8 +354,7 @@ export class AnalysisRegistry {
|
|
|
354
354
|
resolverForDefinition(def: {
|
|
355
355
|
metadata?: { module?: string };
|
|
356
356
|
}): (kind: string) => ResourceDefinition | undefined {
|
|
357
|
-
const
|
|
358
|
-
const scope = (ownModule ? this.aliasesByModule.get(ownModule) : undefined) ?? this.aliases;
|
|
357
|
+
const scope = moduleAliasScope(def?.metadata, this.aliases, this.aliasesByModule);
|
|
359
358
|
return (kind) => {
|
|
360
359
|
const canonical = scope.resolveKind(kind);
|
|
361
360
|
return this.defs.resolve(kind) ?? (canonical ? this.defs.resolve(canonical) : undefined);
|