@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.
Files changed (122) hide show
  1. package/dist/analysis-registry.d.ts.map +1 -1
  2. package/dist/analysis-registry.js +4 -5
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +92 -10
  5. package/dist/call-graph.d.ts.map +1 -1
  6. package/dist/call-graph.js +10 -4
  7. package/dist/catch-scope.d.ts +72 -0
  8. package/dist/catch-scope.d.ts.map +1 -0
  9. package/dist/catch-scope.js +102 -0
  10. package/dist/cel-scope-query.d.ts +14 -0
  11. package/dist/cel-scope-query.d.ts.map +1 -1
  12. package/dist/cel-scope-query.js +36 -6
  13. package/dist/definition-registry.d.ts.map +1 -1
  14. package/dist/definition-registry.js +3 -4
  15. package/dist/deprecation.d.ts +21 -0
  16. package/dist/deprecation.d.ts.map +1 -0
  17. package/dist/deprecation.js +26 -0
  18. package/dist/flatten-for-analyzer.d.ts +2 -2
  19. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  20. package/dist/flatten-for-analyzer.js +1 -1
  21. package/dist/index.d.ts +5 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -2
  24. package/dist/manifest-visitor.d.ts +17 -1
  25. package/dist/manifest-visitor.d.ts.map +1 -1
  26. package/dist/manifest-visitor.js +16 -4
  27. package/dist/migrations/report.d.ts +1 -1
  28. package/dist/migrations/report.d.ts.map +1 -1
  29. package/dist/migrations/report.js +5 -0
  30. package/dist/module-alias-scope.d.ts +65 -0
  31. package/dist/module-alias-scope.d.ts.map +1 -0
  32. package/dist/module-alias-scope.js +25 -0
  33. package/dist/{zone-module-documents.d.ts → module-documents.d.ts} +11 -7
  34. package/dist/module-documents.d.ts.map +1 -0
  35. package/dist/ref-sentinel-target.d.ts +38 -0
  36. package/dist/ref-sentinel-target.d.ts.map +1 -0
  37. package/dist/ref-sentinel-target.js +13 -0
  38. package/dist/ref-slot.d.ts +15 -0
  39. package/dist/ref-slot.d.ts.map +1 -1
  40. package/dist/ref-slot.js +7 -0
  41. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  42. package/dist/resolve-schema-type-refs.js +2 -1
  43. package/dist/resolve-throws-union.d.ts +47 -2
  44. package/dist/resolve-throws-union.d.ts.map +1 -1
  45. package/dist/resolve-throws-union.js +199 -20
  46. package/dist/resolve-zone-requirements.d.ts +3 -3
  47. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  48. package/dist/resolve-zone-requirements.js +12 -12
  49. package/dist/schema-compat.d.ts.map +1 -1
  50. package/dist/schema-compat.js +13 -1
  51. package/dist/schema-error-report.d.ts.map +1 -1
  52. package/dist/schema-error-report.js +48 -4
  53. package/dist/schema-keywords.d.ts.map +1 -1
  54. package/dist/schema-keywords.js +3 -1
  55. package/dist/schema-walk.d.ts +27 -0
  56. package/dist/schema-walk.d.ts.map +1 -1
  57. package/dist/schema-walk.js +44 -0
  58. package/dist/telo-version.d.ts +1 -1
  59. package/dist/telo-version.js +1 -1
  60. package/dist/template-body.d.ts.map +1 -1
  61. package/dist/template-body.js +2 -4
  62. package/dist/types.d.ts +20 -3
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js +11 -0
  65. package/dist/validate-identifier-names.d.ts +2 -2
  66. package/dist/validate-identifier-names.d.ts.map +1 -1
  67. package/dist/validate-identifier-names.js +22 -7
  68. package/dist/validate-invocation-contract.d.ts +5 -0
  69. package/dist/validate-invocation-contract.d.ts.map +1 -1
  70. package/dist/validate-invocation-contract.js +114 -3
  71. package/dist/validate-logging.d.ts.map +1 -1
  72. package/dist/validate-logging.js +2 -2
  73. package/dist/validate-ref-slots.d.ts +1 -1
  74. package/dist/validate-ref-slots.d.ts.map +1 -1
  75. package/dist/validate-ref-slots.js +34 -0
  76. package/dist/validate-references.d.ts +16 -5
  77. package/dist/validate-references.d.ts.map +1 -1
  78. package/dist/validate-references.js +57 -15
  79. package/dist/validate-resource-inputs.d.ts +1 -26
  80. package/dist/validate-resource-inputs.d.ts.map +1 -1
  81. package/dist/validate-resource-inputs.js +12 -2
  82. package/dist/validate-schema-type-refs.d.ts.map +1 -1
  83. package/dist/validate-schema-type-refs.js +2 -1
  84. package/dist/validate-throws-coverage.d.ts +5 -1
  85. package/dist/validate-throws-coverage.d.ts.map +1 -1
  86. package/dist/validate-throws-coverage.js +241 -86
  87. package/package.json +3 -3
  88. package/src/analysis-registry.ts +4 -5
  89. package/src/analyzer.ts +115 -11
  90. package/src/call-graph.ts +9 -3
  91. package/src/catch-scope.ts +157 -0
  92. package/src/cel-scope-query.ts +45 -8
  93. package/src/definition-registry.ts +3 -6
  94. package/src/deprecation.ts +36 -0
  95. package/src/flatten-for-analyzer.ts +3 -3
  96. package/src/index.ts +10 -3
  97. package/src/manifest-visitor.ts +30 -5
  98. package/src/migrations/report.ts +5 -1
  99. package/src/module-alias-scope.ts +94 -0
  100. package/src/{zone-module-documents.ts → module-documents.ts} +10 -6
  101. package/src/ref-sentinel-target.ts +46 -0
  102. package/src/ref-slot.ts +19 -0
  103. package/src/resolve-schema-type-refs.ts +2 -1
  104. package/src/resolve-throws-union.ts +253 -20
  105. package/src/resolve-zone-requirements.ts +14 -14
  106. package/src/schema-compat.ts +13 -0
  107. package/src/schema-error-report.ts +50 -6
  108. package/src/schema-keywords.ts +4 -1
  109. package/src/schema-walk.ts +56 -0
  110. package/src/telo-version.ts +1 -1
  111. package/src/template-body.ts +2 -5
  112. package/src/types.ts +21 -3
  113. package/src/validate-identifier-names.ts +28 -9
  114. package/src/validate-invocation-contract.ts +128 -2
  115. package/src/validate-logging.ts +2 -3
  116. package/src/validate-ref-slots.ts +41 -1
  117. package/src/validate-references.ts +74 -14
  118. package/src/validate-resource-inputs.ts +26 -3
  119. package/src/validate-schema-type-refs.ts +2 -1
  120. package/src/validate-throws-coverage.ts +344 -92
  121. package/dist/zone-module-documents.d.ts.map +0 -1
  122. /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
- if (catchesFor) {
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
- /** Rule 1 + Rule 4: check declared-union coverage and reject undeclared codes
172
- * in coverage-proving `when:` clauses. Phase 2 accepts inherit/passthrough
173
- * handler unions too — when the resolved union is unbounded, a catch-all is
174
- * required (rule 4 extension). */
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 (let i = 0; i < entries.length; i++) {
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
- for (const c of codes) {
191
- // An ambient kernel code (contract violations) is raised by the kernel,
192
- // not declared by the kind, so naming it is legal and still typo-checked
193
- // but it is NOT part of the declared union, so it never counts toward
194
- // coverage. Folding these into every union would make every bounded
195
- // catches: block in the standard library incomplete overnight.
196
- if (isAmbientContractErrorCode(c))
197
- continue;
198
- if (!declaredCodes.has(c)) {
199
- diagnostics.push({
200
- severity: DiagnosticSeverity.Error,
201
- code: "UNDECLARED_THROW_CODE",
202
- source: SOURCE,
203
- 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" : ""}.`,
204
- data: { resource, filePath, path: `${arrayPath}[${i}].when` },
205
- });
206
- }
207
- else {
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 && !hasCatchAll) {
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). The catches: list must include a catch-all entry (no \`when:\`).`,
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
- if (!hasCatchAll) {
225
- // One diagnostic per block, not per code: every uncovered code sits at the
226
- // same `catches:` array, and one catch-all answers all of them at once. A
227
- // diagnostic each repeated the same location and the same fix N times.
228
- const uncovered = [...declaredCodes].filter((c) => !covered.has(c)).sort();
229
- if (uncovered.length > 0) {
230
- diagnostics.push({
231
- severity: DiagnosticSeverity.Error,
232
- code: "UNCOVERED_THROW_CODE",
233
- source: SOURCE,
234
- message: `handler ${handler?.name ? `\`!ref ${handler.name}\`` : `\`${handler?.kind ?? "?"}\``} can throw ${uncovered.length} code${uncovered.length === 1 ? "" : "s"} that no catches: entry handles: ${uncovered.map((c) => `'${c}'`).join(", ")}. ` +
235
- `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.`,
236
- data: { resource, filePath, path: arrayPath, uncovered },
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
- // Walk CEL expressions inside this entry's body / headers only
278
- // string-valued fields can contain CEL templates.
279
- collectCelStrings(e.body, `${arrayPath}[${i}].body`).forEach((entry) => {
280
- diagnostics.push(...checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env));
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 (!schemaHasStepContext(schema)) {
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 schemaHasStepContext(schema) {
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 (schemaHasStepContext(v))
488
+ if (schemaDrivesInvocables(v))
412
489
  return true;
413
490
  }
414
491
  }
415
- if (schema.items && schemaHasStepContext(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 (schemaHasStepContext(sub))
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 (schemaHasStepContext(v))
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
- const resolveCtx = createResolveCtx(manifests, defs, aliases, aliasesByModule, rootModules);
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
- for (const manifest of manifests) {
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
- const scopeResolver = scopeResolverFor(manifest);
447
- const resolvedKind = scopeResolver?.resolveKind(manifest.kind) ?? aliases.resolveKind(manifest.kind);
448
- const definition = defs.resolve(manifest.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
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 filePath = manifest.metadata?.source;
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
- diagnostics.push(...checkCatchAllPlacement(entries, resource, "catches", filePath, arrayPath));
457
- const handlerRef = resolveHandlerRef(siblingData[catchesFor]);
458
- const union = handlerRefUnion(handlerRef, manifests, resolveCtx, scopeResolver);
459
- diagnostics.push(...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handlerRef));
460
- diagnostics.push(...checkTypedErrorData(entries, union, resource, filePath, arrayPath, env));
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.70.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.18.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.86.0"
52
+ "@telorun/sdk": "0.87.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@telorun/sdk": "*"
@@ -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 ownerModule = (def?.metadata as { module?: string } | undefined)?.module;
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 ? this.aliasesByModule.get(module) : undefined) ?? this.aliases;
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 ownModule = def?.metadata?.module;
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);