@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
@@ -3,16 +3,25 @@ import { isTaggedSentinel } from "@telorun/templating";
3
3
  import {
4
4
  AMBIENT_CONTRACT_ERROR_CODES,
5
5
  isAmbientContractErrorCode,
6
+ type ResourceDefinition,
6
7
  type ResourceManifest,
7
8
  } from "@telorun/sdk";
8
9
  import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js";
9
10
  import type { DefinitionRegistry } from "./definition-registry.js";
10
11
  import {
11
12
  createResolveCtx,
13
+ resolveScopeUnion,
12
14
  resolveThrowsUnion,
13
15
  type ThrowsCodeMeta,
14
16
  type ThrowsUnion,
15
17
  } from "./resolve-throws-union.js";
18
+ import {
19
+ buildEnclosers,
20
+ collectScopedManifests,
21
+ enclosingCoverage,
22
+ type ProvenCoverage,
23
+ type ScopedManifest,
24
+ } from "./catch-scope.js";
16
25
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
17
26
  import { extractAccessChains, validateChainAgainstSchema } from "./validate-cel-context.js";
18
27
  import { isStepSlot } from "./step-slot.js";
@@ -103,7 +112,11 @@ function walkSchemaData(
103
112
  }
104
113
  } else {
105
114
  const catchesFor = propSchema["x-telo-catches-for"] as string | undefined;
106
- if (catchesFor) {
115
+ // The EMPTY pointer names the resource the list is written on, the
116
+ // spelling `x-telo-schema-projection-from` already uses for the same
117
+ // "this declaration, not one it references" meaning — so the test is
118
+ // presence, never truthiness.
119
+ if (catchesFor !== undefined) {
107
120
  // Fire even when absent so the coverage check can flag handlers
108
121
  // whose declared union is non-empty but the list is missing.
109
122
  ctx.onCatches(entries, nextPath, dataObj, catchesFor);
@@ -211,6 +224,7 @@ function checkCatchAllPlacement(
211
224
  channel: "returns" | "catches",
212
225
  filePath: string | undefined,
213
226
  arrayPath: string,
227
+ routing: { kind: string; name: string } = resource,
214
228
  ): AnalysisDiagnostic[] {
215
229
  const diagnostics: AnalysisDiagnostic[] = [];
216
230
  for (let i = 0; i < entries.length - 1; i++) {
@@ -221,90 +235,120 @@ function checkCatchAllPlacement(
221
235
  code: "CATCHALL_NOT_LAST",
222
236
  source: SOURCE,
223
237
  message: `${channel}: catch-all entry (no \`when:\`) at index ${i} must be last — entries after it are unreachable.`,
224
- data: { resource, filePath, path: `${arrayPath}[${i}]` },
238
+ data: { resource: routing, filePath, path: `${arrayPath}[${i}]` },
225
239
  });
226
240
  }
227
241
  }
228
242
  return diagnostics;
229
243
  }
230
244
 
231
- /** Rule 1 + Rule 4: check declared-union coverage and reject undeclared codes
232
- * in coverage-proving `when:` clauses. Phase 2 accepts inherit/passthrough
233
- * handler unions too when the resolved union is unbounded, a catch-all is
234
- * required (rule 4 extension). */
235
- function checkCatchesCoverage(
245
+ /** Read one list's {@link ProvenCoverage} the codes its coverage-proving
246
+ * `when:` clauses name, and whether it ends in a catch-all. */
247
+ function provenCoverage(entries: OutcomeEntry[], env: Environment): ProvenCoverage {
248
+ const codes = new Set<string>();
249
+ let hasCatchAll = false;
250
+ for (const e of entries) {
251
+ if (!e) continue;
252
+ if (!e.when) {
253
+ hasCatchAll = true;
254
+ continue;
255
+ }
256
+ const { proven, codes: entryCodes } = extractCoveredCodes(e.when, env);
257
+ if (!proven) continue;
258
+ for (const c of entryCodes) codes.add(c);
259
+ }
260
+ return { codes, hasCatchAll };
261
+ }
262
+
263
+ /** Rule 4: a coverage-proving `when:` may only name a code the list's own
264
+ * denominator can produce. Runs for EVERY list, scope lists included — the
265
+ * denominator differs (a handler's union, or the enclosing resource's own), the
266
+ * typo check does not. */
267
+ function checkUndeclaredCodes(
236
268
  entries: OutcomeEntry[],
237
269
  union: ThrowsUnion,
238
270
  resource: { kind: string; name: string },
239
271
  filePath: string | undefined,
240
272
  arrayPath: string,
241
273
  env: Environment,
242
- handler: { kind: string; name?: string } | null,
274
+ denominator: string,
275
+ routing: { kind: string; name: string } = resource,
243
276
  ): AnalysisDiagnostic[] {
244
277
  const diagnostics: AnalysisDiagnostic[] = [];
245
278
  const declaredCodes = new Set(union.codes.keys());
246
- const covered = new Set<string>();
247
- let hasCatchAll = false;
248
279
 
249
280
  for (let i = 0; i < entries.length; i++) {
250
281
  const e = entries[i];
251
- if (!e) continue;
252
- if (!e.when) {
253
- hasCatchAll = true;
254
- continue;
255
- }
282
+ if (!e?.when) continue;
256
283
  const { proven, codes } = extractCoveredCodes(e.when, env);
257
- if (proven) {
258
- for (const c of codes) {
259
- // An ambient kernel code (contract violations) is raised by the kernel,
260
- // not declared by the kind, so naming it is legal and still typo-checked
261
- // — but it is NOT part of the declared union, so it never counts toward
262
- // coverage. Folding these into every union would make every bounded
263
- // catches: block in the standard library incomplete overnight.
264
- if (isAmbientContractErrorCode(c)) continue;
265
- if (!declaredCodes.has(c)) {
266
- diagnostics.push({
267
- severity: DiagnosticSeverity.Error,
268
- code: "UNDECLARED_THROW_CODE",
269
- source: SOURCE,
270
- 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" : ""}.`,
271
- data: { resource, filePath, path: `${arrayPath}[${i}].when` },
272
- });
273
- } else {
274
- covered.add(c);
275
- }
276
- }
284
+ if (!proven) continue;
285
+ for (const c of codes) {
286
+ // An ambient kernel code (contract violations) is raised by the kernel,
287
+ // not declared by the kind, so naming it is legal and still typo-checked
288
+ // — but it is NOT part of the declared union, so it never counts toward
289
+ // coverage. Folding these into every union would make every bounded
290
+ // catches: block in the standard library incomplete overnight.
291
+ if (isAmbientContractErrorCode(c)) continue;
292
+ if (declaredCodes.has(c)) continue;
293
+ diagnostics.push({
294
+ severity: DiagnosticSeverity.Error,
295
+ code: "UNDECLARED_THROW_CODE",
296
+ source: SOURCE,
297
+ 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" : ""}.`,
298
+ data: { resource: routing, filePath, path: `${arrayPath}[${i}].when` },
299
+ });
277
300
  }
278
301
  }
279
302
 
303
+ return diagnostics;
304
+ }
305
+
306
+ /** Rule 1 + the unbounded-union rule, asked ONCE per dispatch site over every
307
+ * list that can render its throws — the site's own, its resource's scope list,
308
+ * and every scope enclosing that resource.
309
+ *
310
+ * Asking it per list is what would make this a false check rather than a
311
+ * missing one: a route that declares no `catches:` under a router that renders
312
+ * everything is completely covered, and reporting it fires on precisely the
313
+ * manifests scope lists exist to enable. */
314
+ function checkCoverage(
315
+ union: ThrowsUnion,
316
+ resource: { kind: string; name: string },
317
+ filePath: string | undefined,
318
+ arrayPath: string,
319
+ handler: { kind: string; name?: string } | null,
320
+ covered: ProvenCoverage,
321
+ routing: { kind: string; name: string } = resource,
322
+ ): AnalysisDiagnostic[] {
323
+ const diagnostics: AnalysisDiagnostic[] = [];
324
+ if (covered.hasCatchAll) return diagnostics;
325
+
280
326
  // Unbounded union (passthrough or transitive): authors can't enumerate the
281
- // codes, so a catch-all is mandatory.
282
- if (union.unbounded && !hasCatchAll) {
327
+ // codes, so a catch-all is mandatory — at this list or any enclosing scope.
328
+ if (union.unbounded) {
283
329
  diagnostics.push({
284
330
  severity: DiagnosticSeverity.Error,
285
331
  code: "UNBOUNDED_UNION_NEEDS_CATCHALL",
286
332
  source: SOURCE,
287
- 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:\`).`,
288
- data: { resource, filePath, path: arrayPath },
333
+ 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.`,
334
+ data: { resource: routing, filePath, path: arrayPath },
289
335
  });
290
336
  }
291
337
 
292
- if (!hasCatchAll) {
293
- // One diagnostic per block, not per code: every uncovered code sits at the
294
- // same `catches:` array, and one catch-all answers all of them at once. A
295
- // diagnostic each repeated the same location and the same fix N times.
296
- const uncovered = [...declaredCodes].filter((c) => !covered.has(c)).sort();
297
- if (uncovered.length > 0) {
298
- diagnostics.push({
299
- severity: DiagnosticSeverity.Error,
300
- code: "UNCOVERED_THROW_CODE",
301
- source: SOURCE,
302
- message:
303
- `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(", ")}. ` +
304
- `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.`,
305
- data: { resource, filePath, path: arrayPath, uncovered },
306
- });
307
- }
338
+ // One diagnostic per site, not per code: every uncovered code sits at the
339
+ // same dispatch site, and one catch-all answers all of them at once. A
340
+ // diagnostic each repeated the same location and the same fix N times.
341
+ const uncovered = [...union.codes.keys()].filter((c) => !covered.codes.has(c)).sort();
342
+ if (uncovered.length > 0) {
343
+ diagnostics.push({
344
+ severity: DiagnosticSeverity.Error,
345
+ code: "UNCOVERED_THROW_CODE",
346
+ source: SOURCE,
347
+ message:
348
+ `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(", ")}. ` +
349
+ `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.`,
350
+ data: { resource: routing, filePath, path: arrayPath, uncovered },
351
+ });
308
352
  }
309
353
 
310
354
  return diagnostics;
@@ -321,6 +365,7 @@ function checkTypedErrorData(
321
365
  filePath: string | undefined,
322
366
  arrayPath: string,
323
367
  env: Environment,
368
+ routing: { kind: string; name: string } = resource,
324
369
  ): AnalysisDiagnostic[] {
325
370
  const diagnostics: AnalysisDiagnostic[] = [];
326
371
  // If the union is unbounded we can't narrow data schemas reliably — skip
@@ -350,20 +395,16 @@ function checkTypedErrorData(
350
395
  const schemas = applicable.map((c) => dataByCode[c]).filter(Boolean) as Record<string, any>[];
351
396
  if (schemas.length === 0) continue;
352
397
  const dataSchema = intersectDataSchemas(schemas);
353
- // Walk CEL expressions inside this entry's body / headers only
354
- // string-valued fields can contain CEL templates.
355
- collectCelStrings(e.body, `${arrayPath}[${i}].body`).forEach((entry) => {
398
+ // The WHOLE entry, not an enumerated `body` / `headers` pair. An HTTP catch
399
+ // entry keeps its body at `content[<mime>].body`, so reading `e.body` walked
400
+ // a field that shape never has and this check was inert for every catch list
401
+ // in the standard library. Walking the entry also covers `when:` and the
402
+ // per-MIME header overrides, which are equally places `error.data` is read.
403
+ collectCelStrings(e, `${arrayPath}[${i}]`).forEach((entry) => {
356
404
  diagnostics.push(
357
- ...checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env),
405
+ ...checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env, routing),
358
406
  );
359
407
  });
360
- if (e.headers) {
361
- collectCelStrings(e.headers, `${arrayPath}[${i}].headers`).forEach((entry) => {
362
- diagnostics.push(
363
- ...checkCelChainAgainstDataSchema(entry, dataSchema, resource, filePath, env),
364
- );
365
- });
366
- }
367
408
  }
368
409
  return diagnostics;
369
410
  }
@@ -404,6 +445,14 @@ interface CelString {
404
445
 
405
446
  function collectCelStrings(value: unknown, path: string): CelString[] {
406
447
  const out: CelString[] = [];
448
+ // A `!cel` sentinel and a `${{ … }}` string are load-equivalent, and the
449
+ // formatter normalizes to the tag — so recognising only the string form left
450
+ // this check answering about a spelling no manifest in the repository uses,
451
+ // while walking the sentinel as a plain object found nothing.
452
+ if (isTaggedSentinel(value)) {
453
+ if (value.engine === "cel") out.push({ expr: value.source.trim(), path });
454
+ return out;
455
+ }
407
456
  if (typeof value === "string") {
408
457
  for (const m of value.matchAll(TEMPLATE_REGEX)) {
409
458
  out.push({ expr: m[1].trim(), path });
@@ -430,6 +479,7 @@ function checkCelChainAgainstDataSchema(
430
479
  resource: { kind: string; name: string },
431
480
  filePath: string | undefined,
432
481
  env: Environment,
482
+ routing: { kind: string; name: string } = resource,
433
483
  ): AnalysisDiagnostic[] {
434
484
  let ast: ASTNode;
435
485
  try {
@@ -450,7 +500,7 @@ function checkCelChainAgainstDataSchema(
450
500
  code: "CEL_UNKNOWN_FIELD",
451
501
  source: SOURCE,
452
502
  message: `${resource.kind}/${resource.name}: CEL at '${entry.path}': error.data.${err}`,
453
- data: { resource, filePath, path: entry.path },
503
+ data: { resource: routing, filePath, path: entry.path },
454
504
  });
455
505
  }
456
506
  }
@@ -463,6 +513,31 @@ function checkCelChainAgainstDataSchema(
463
513
  * carrying the legacy `x-telo-step-context` annotation. That is what drives the
464
514
  * resolver's generic step traversal; a definition with `inherit: true` and no
465
515
  * such array has no invocables to inherit from. */
516
+ /** The capabilities whose lifecycle includes a dispatch a caller can catch. On
517
+ * every other one a thrown error is a boot-time failure, not a structured
518
+ * runtime error for a downstream caller — a provider resolves configuration, a
519
+ * type has no instance, a sink is written to directly, and a service or mount
520
+ * is STARTED rather than called (what a router renders is not what a router
521
+ * throws).
522
+ *
523
+ * The strict half of the kernel's `ResourceDefinitionSchema` rule 8, and it has
524
+ * to exist here for the reason every `x-telo-*` accessor has a strict half: the
525
+ * kernel refuses at `create()`, which is a boot failure on a manifest that
526
+ * passed `telo check` — the static/runtime disagreement this repository treats
527
+ * as a defect. The two must agree; a change to either belongs in both.
528
+ *
529
+ * **Reported for a DEPENDENCY's definition too**, unlike `X_TELO_REF_UNRESOLVED`
530
+ * and `DEPRECATED_KIND`, which are entry-module-scoped. Those report something a
531
+ * consumer can live with — a slot that cannot be checked, a kind that still
532
+ * works — so silence costs them nothing and the noise is not theirs to fix.
533
+ * This one reports a definition the kernel REFUSES, so the manifest importing it
534
+ * cannot start at all: withholding that would replace a `telo check` error with
535
+ * an identical boot failure and no earlier warning. The action is a consumer's
536
+ * to take (pin another version, report upstream) even though the edit is not.
537
+ * Matches the neighbouring `INHERIT_WITHOUT_STEP_CONTEXT`, which is fatal the
538
+ * same way. */
539
+ const THROWS_CAPABLE_CAPABILITIES = new Set(["Telo.Invocable", "Telo.Runnable"]);
540
+
466
541
  function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
467
542
  const diagnostics: AnalysisDiagnostic[] = [];
468
543
  for (const m of manifests) {
@@ -471,9 +546,29 @@ function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiag
471
546
  if (!throws) continue;
472
547
  const name = (m.metadata?.name as string | undefined) ?? "<unnamed>";
473
548
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
549
+
550
+ // Only a DECLARED capability is judged. One inherited through `extends` is
551
+ // resolved elsewhere, and an unknown one is third-party extensibility the
552
+ // kernel's schema deliberately leaves open.
553
+ const capability = (m as Record<string, any>).capability as string | undefined;
554
+ if (typeof capability === "string" && !THROWS_CAPABLE_CAPABILITIES.has(capability)) {
555
+ diagnostics.push({
556
+ severity: DiagnosticSeverity.Error,
557
+ code: "THROWS_ON_NON_DISPATCH_CAPABILITY",
558
+ source: SOURCE,
559
+ message:
560
+ `Telo.Definition '${name}' declares throws: but its capability is '${capability}'. ` +
561
+ `A throw union describes what a CALLER can catch, so it is only meaningful on ` +
562
+ `${[...THROWS_CAPABLE_CAPABILITIES].join(" or ")}; on '${capability}' a thrown error is a ` +
563
+ `boot-time failure with no caller to render it. The kernel refuses this definition at ` +
564
+ `create(), so a manifest carrying it cannot start.`,
565
+ data: { resource: { kind: m.kind, name }, filePath, path: "throws" },
566
+ });
567
+ continue;
568
+ }
474
569
  if (throws.inherit === true) {
475
570
  const schema = (m as Record<string, any>).schema as Record<string, any> | undefined;
476
- if (!schemaHasStepContext(schema)) {
571
+ if (!schemaDrivesInvocables(schema)) {
477
572
  diagnostics.push({
478
573
  severity: DiagnosticSeverity.Error,
479
574
  code: "INHERIT_WITHOUT_STEP_CONTEXT",
@@ -491,25 +586,25 @@ function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiag
491
586
  return diagnostics;
492
587
  }
493
588
 
494
- function schemaHasStepContext(schema: Record<string, any> | undefined): boolean {
589
+ function schemaDrivesInvocables(schema: Record<string, any> | undefined): boolean {
495
590
  if (!schema || typeof schema !== "object") return false;
496
591
  if (isStepSlot(schema)) return true;
497
592
  const props = schema.properties;
498
593
  if (props && typeof props === "object") {
499
594
  for (const v of Object.values(props as Record<string, any>)) {
500
- if (schemaHasStepContext(v)) return true;
595
+ if (schemaDrivesInvocables(v)) return true;
501
596
  }
502
597
  }
503
- if (schema.items && schemaHasStepContext(schema.items)) return true;
598
+ if (schema.items && schemaDrivesInvocables(schema.items)) return true;
504
599
  for (const key of ["oneOf", "anyOf", "allOf"] as const) {
505
600
  const arr = schema[key];
506
601
  if (Array.isArray(arr)) {
507
- for (const sub of arr) if (schemaHasStepContext(sub)) return true;
602
+ for (const sub of arr) if (schemaDrivesInvocables(sub)) return true;
508
603
  }
509
604
  }
510
605
  if (schema.$defs && typeof schema.$defs === "object") {
511
606
  for (const v of Object.values(schema.$defs as Record<string, any>)) {
512
- if (schemaHasStepContext(v)) return true;
607
+ if (schemaDrivesInvocables(v)) return true;
513
608
  }
514
609
  }
515
610
  return false;
@@ -523,11 +618,35 @@ export function validateThrowsCoverage(
523
618
  env: Environment,
524
619
  aliasesByModule: Map<string, AliasResolver> = new Map(),
525
620
  rootModules: Set<string> = new Set(),
621
+ /** Each imported library's full document set, so the walk can follow an
622
+ * exported entry point into the siblings it invokes — which a consumer's
623
+ * flat set does not carry. */
624
+ moduleManifests: Map<string, ResourceManifest[]> = new Map(),
526
625
  ): AnalysisDiagnostic[] {
527
626
  const diagnostics: AnalysisDiagnostic[] = [];
528
627
  diagnostics.push(...validateThrowsDeclarations(manifests));
529
628
 
530
- const resolveCtx = createResolveCtx(manifests, defs, aliases, aliasesByModule, rootModules);
629
+ // A `with:`-scoped declaration is a resource like any other — it has a kind, a
630
+ // name, and, for a scoped `Http.Server`, a catch list that renders what its
631
+ // mounts throw. It is simply not in the flat set, so every check here used to
632
+ // skip it: its own entries went unchecked AND its coverage reached nothing it
633
+ // encloses. Standing a server up around a test is exactly that shape, so the
634
+ // sanctioned pattern was the one the pass could not see.
635
+ //
636
+ // Discovered through the shared visitor rather than a second scope walk, and
637
+ // folded into the pool every name is resolved against — a scoped mount whose
638
+ // target the resolver cannot find reads as an empty union, which reports every
639
+ // entry of that server's list as naming a code nothing throws.
640
+ const scoped = collectScopedManifests(manifests, defs, aliases, aliasesByModule, rootModules);
641
+ const allManifests = [...manifests, ...scoped.map((s) => s.manifest)];
642
+ const resolveCtx = createResolveCtx(
643
+ allManifests,
644
+ defs,
645
+ aliases,
646
+ aliasesByModule,
647
+ rootModules,
648
+ moduleManifests,
649
+ );
531
650
 
532
651
  // The alias resolver for a manifest's own lexical scope — an imported library's
533
652
  // resolver when it owns the manifest, else undefined (fall back to root aliases).
@@ -538,43 +657,176 @@ export function validateThrowsCoverage(
538
657
  aliasesByModule,
539
658
  );
540
659
 
541
- for (const manifest of manifests) {
660
+ // Pass 1 read every outcome list, and record which resources each scope
661
+ // list encloses. A scope list has to be known before any site it covers is
662
+ // judged, so collection and judgement cannot be one loop.
663
+ const sites: CatchSite[] = [];
664
+ const scopeOf = new Map<ResourceManifest, ProvenCoverage>();
665
+ const scopedBy = new Map<ResourceManifest, ScopedManifest>();
666
+ for (const s of scoped) scopedBy.set(s.manifest, s);
667
+
668
+ const definitionFor = (manifest: ResourceManifest) => {
669
+ // A scoped declaration's kind is written in the alias scope of the module
670
+ // that declared the ENCLOSING resource; it carries no `metadata.module` of
671
+ // its own to find one by.
672
+ const anchor = scopedBy.get(manifest)?.owner ?? manifest;
673
+ const scopeResolver = scopeResolverFor(anchor);
674
+ const resolvedKind =
675
+ scopeResolver?.resolveKind(manifest.kind) ?? aliases.resolveKind(manifest.kind);
676
+ return defs.resolve(manifest.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
677
+ };
678
+
679
+ const enclosers = buildEnclosers(
680
+ allManifests,
681
+ definitionFor,
682
+ (m) => ((scopedBy.get(m)?.owner ?? m).metadata as { module?: string } | undefined)?.module,
683
+ resolveCtx,
684
+ );
685
+
686
+ for (const manifest of allManifests) {
542
687
  if (!manifest.kind || !manifest.metadata?.name) continue;
543
688
  if (manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract") continue;
544
- const scopeResolver = scopeResolverFor(manifest);
545
- const resolvedKind = scopeResolver?.resolveKind(manifest.kind) ?? aliases.resolveKind(manifest.kind);
546
- const definition =
547
- defs.resolve(manifest.kind) ?? (resolvedKind ? defs.resolve(resolvedKind) : undefined);
689
+ // A scoped declaration is reported against the document it is WRITTEN in —
690
+ // its owner's at its own position inside that owner's scope array, because
691
+ // position lookup finds a TOP-LEVEL doc by (kind, name) and a scoped
692
+ // resource is not one. The message still names the scoped resource, so the
693
+ // reader is not sent to a resource that has no `catches:` at all.
694
+ const enclosing = scopedBy.get(manifest);
695
+ const anchor = enclosing?.owner ?? manifest;
696
+ const pathPrefix = enclosing ? `${enclosing.path}.` : "";
697
+ const scopeResolver = scopeResolverFor(anchor);
698
+ const definition = definitionFor(manifest);
548
699
  if (!definition?.schema) continue;
549
700
  const resource = { kind: manifest.kind, name: manifest.metadata.name as string };
550
- const filePath = (manifest.metadata as { source?: string } | undefined)?.source;
701
+ const routing = enclosing
702
+ ? { kind: anchor.kind, name: anchor.metadata!.name as string }
703
+ : resource;
704
+ const filePath = (anchor.metadata as { source?: string } | undefined)?.source;
551
705
 
552
706
  collectOutcomeLists(
553
707
  manifest,
554
708
  definition.schema,
555
709
  (ret) => {
556
710
  diagnostics.push(
557
- ...checkCatchAllPlacement(ret.entries, resource, "returns", filePath, ret.arrayPath),
711
+ ...checkCatchAllPlacement(
712
+ ret.entries,
713
+ resource,
714
+ "returns",
715
+ filePath,
716
+ `${pathPrefix}${ret.arrayPath}`,
717
+ routing,
718
+ ),
558
719
  );
559
720
  },
560
721
  (entries, arrayPath, siblingData, catchesFor) => {
561
- diagnostics.push(
562
- ...checkCatchAllPlacement(entries, resource, "catches", filePath, arrayPath),
563
- );
564
- const handlerRef = resolveHandlerRef(siblingData[catchesFor]);
565
- const union = handlerRefUnion(handlerRef, manifests, resolveCtx, scopeResolver);
566
- diagnostics.push(
567
- ...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handlerRef),
568
- );
569
- diagnostics.push(
570
- ...checkTypedErrorData(entries, union, resource, filePath, arrayPath, env),
571
- );
722
+ const site: CatchSite = {
723
+ manifest,
724
+ definition,
725
+ resource,
726
+ routing,
727
+ filePath,
728
+ entries,
729
+ arrayPath: `${pathPrefix}${arrayPath}`,
730
+ scopeResolver,
731
+ handlerRef: catchesFor === "" ? null : resolveHandlerRef(siblingData[catchesFor]),
732
+ isScope: catchesFor === "",
733
+ };
734
+ sites.push(site);
735
+ if (site.isScope) scopeOf.set(manifest, provenCoverage(entries, env));
572
736
  },
573
737
  );
574
738
  }
739
+
740
+ const coverageMemo = new Map<ResourceManifest, ProvenCoverage>();
741
+ const scopeCoverageFor = (manifest: ResourceManifest): ProvenCoverage =>
742
+ enclosingCoverage(manifest, scopeOf, enclosers, coverageMemo);
743
+
744
+ // Pass 2 — judge each list against its own denominator, and each dispatch site
745
+ // against everything that can render its throws.
746
+ for (const site of sites) {
747
+ diagnostics.push(
748
+ ...checkCatchAllPlacement(
749
+ site.entries,
750
+ site.resource,
751
+ "catches",
752
+ site.filePath,
753
+ site.arrayPath,
754
+ site.routing,
755
+ ),
756
+ );
757
+ const union = site.isScope
758
+ ? resolveScopeUnion(site.manifest, site.definition, resolveCtx)
759
+ : handlerRefUnion(site.handlerRef, allManifests, resolveCtx, site.scopeResolver);
760
+ diagnostics.push(
761
+ ...checkUndeclaredCodes(
762
+ site.entries,
763
+ union,
764
+ site.resource,
765
+ site.filePath,
766
+ site.arrayPath,
767
+ env,
768
+ site.isScope
769
+ ? "the throw union of everything this resource drives"
770
+ : "the handler's declared throw union",
771
+ site.routing,
772
+ ),
773
+ );
774
+ diagnostics.push(
775
+ ...checkTypedErrorData(
776
+ site.entries,
777
+ union,
778
+ site.resource,
779
+ site.filePath,
780
+ site.arrayPath,
781
+ env,
782
+ site.routing,
783
+ ),
784
+ );
785
+ if (site.isScope) continue;
786
+
787
+ const own = provenCoverage(site.entries, env);
788
+ const scope = scopeCoverageFor(site.manifest);
789
+ const covered: ProvenCoverage = {
790
+ codes: new Set([...own.codes, ...scope.codes]),
791
+ hasCatchAll: own.hasCatchAll || scope.hasCatchAll,
792
+ };
793
+ diagnostics.push(
794
+ ...checkCoverage(
795
+ union,
796
+ site.resource,
797
+ site.filePath,
798
+ site.arrayPath,
799
+ site.handlerRef,
800
+ covered,
801
+ site.routing,
802
+ ),
803
+ );
804
+ }
805
+
575
806
  return diagnostics;
576
807
  }
577
808
 
809
+ /** One `catches:` list in one manifest, with everything needed to judge it. */
810
+ interface CatchSite {
811
+ manifest: ResourceManifest;
812
+ definition: ResourceDefinition;
813
+ /** Named in the MESSAGE — the resource whose list this is. */
814
+ resource: { kind: string; name: string };
815
+ /** Named in `data.resource`, which is how position lookup finds a document:
816
+ * it searches TOP-LEVEL docs by (kind, name), so a scoped resource routes
817
+ * through its owner while the message still names the scoped one. */
818
+ routing: { kind: string; name: string };
819
+ filePath: string | undefined;
820
+ entries: OutcomeEntry[];
821
+ arrayPath: string;
822
+ scopeResolver: AliasResolver | undefined;
823
+ /** The handler this list renders throws for; null for a scope list. */
824
+ handlerRef: { kind: string; name?: string } | null;
825
+ /** `x-telo-catches-for: ""` — the list covers everything its resource drives
826
+ * and owes coverage of nothing on its own. */
827
+ isScope: boolean;
828
+ }
829
+
578
830
  /** Resolve a handler ref's effective throw union. Prefers the named manifest
579
831
  * (so `inherit: true` handlers expose their transitive union); falls back to
580
832
  * the definition's own codes when no name is given. */
@@ -1 +0,0 @@
1
- {"version":3,"file":"zone-module-documents.d.ts","sourceRoot":"","sources":["../src/zone-module-documents.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,mBAAmB;IAClC,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC"}