@telorun/analyzer 0.71.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 (66) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +42 -2
  3. package/dist/catch-scope.d.ts +72 -0
  4. package/dist/catch-scope.d.ts.map +1 -0
  5. package/dist/catch-scope.js +102 -0
  6. package/dist/deprecation.d.ts +21 -0
  7. package/dist/deprecation.d.ts.map +1 -0
  8. package/dist/deprecation.js +26 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/manifest-visitor.d.ts +17 -1
  13. package/dist/manifest-visitor.d.ts.map +1 -1
  14. package/dist/manifest-visitor.js +5 -1
  15. package/dist/migrations/report.d.ts +1 -1
  16. package/dist/migrations/report.d.ts.map +1 -1
  17. package/dist/migrations/report.js +5 -0
  18. package/dist/ref-slot.d.ts +15 -0
  19. package/dist/ref-slot.d.ts.map +1 -1
  20. package/dist/ref-slot.js +7 -0
  21. package/dist/resolve-throws-union.d.ts +29 -1
  22. package/dist/resolve-throws-union.d.ts.map +1 -1
  23. package/dist/resolve-throws-union.js +111 -16
  24. package/dist/schema-compat.d.ts.map +1 -1
  25. package/dist/schema-compat.js +13 -1
  26. package/dist/schema-error-report.d.ts.map +1 -1
  27. package/dist/schema-error-report.js +48 -4
  28. package/dist/schema-keywords.d.ts.map +1 -1
  29. package/dist/schema-keywords.js +3 -1
  30. package/dist/schema-walk.d.ts +27 -0
  31. package/dist/schema-walk.d.ts.map +1 -1
  32. package/dist/schema-walk.js +44 -0
  33. package/dist/telo-version.d.ts +1 -1
  34. package/dist/telo-version.js +1 -1
  35. package/dist/types.d.ts +17 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +11 -0
  38. package/dist/validate-identifier-names.d.ts +2 -2
  39. package/dist/validate-identifier-names.d.ts.map +1 -1
  40. package/dist/validate-identifier-names.js +22 -7
  41. package/dist/validate-ref-slots.d.ts +1 -1
  42. package/dist/validate-ref-slots.d.ts.map +1 -1
  43. package/dist/validate-ref-slots.js +34 -0
  44. package/dist/validate-references.d.ts.map +1 -1
  45. package/dist/validate-references.js +27 -3
  46. package/dist/validate-throws-coverage.d.ts.map +1 -1
  47. package/dist/validate-throws-coverage.js +236 -85
  48. package/package.json +2 -2
  49. package/src/analyzer.ts +54 -1
  50. package/src/catch-scope.ts +157 -0
  51. package/src/deprecation.ts +36 -0
  52. package/src/index.ts +8 -1
  53. package/src/manifest-visitor.ts +19 -2
  54. package/src/migrations/report.ts +5 -1
  55. package/src/ref-slot.ts +19 -0
  56. package/src/resolve-throws-union.ts +139 -21
  57. package/src/schema-compat.ts +13 -0
  58. package/src/schema-error-report.ts +50 -6
  59. package/src/schema-keywords.ts +4 -1
  60. package/src/schema-walk.ts +56 -0
  61. package/src/telo-version.ts +1 -1
  62. package/src/types.ts +18 -0
  63. package/src/validate-identifier-names.ts +28 -9
  64. package/src/validate-ref-slots.ts +41 -1
  65. package/src/validate-references.ts +33 -3
  66. package/src/validate-throws-coverage.ts +333 -92
@@ -83,8 +83,42 @@ function checkAnnotation(annotation, manifest, path, issues) {
83
83
  }
84
84
  }
85
85
  }
86
+ // `throwsThrough` is read as `=== true`, so anything else is silently absent —
87
+ // and absent means the declaring resource's catch scope stops enclosing what
88
+ // it holds, so every route under it starts reporting UNCOVERED_THROW_CODE with
89
+ // nothing naming the cause. The same failure `X_TELO_REF_INVALID_USE` exists
90
+ // to prevent, one key over.
91
+ if (obj.throwsThrough !== undefined && typeof obj.throwsThrough !== "boolean") {
92
+ issues.push({
93
+ code: "X_TELO_REF_INVALID_THROWS_THROUGH",
94
+ manifest,
95
+ path,
96
+ message: `x-telo-ref at '${path}' declares 'throwsThrough: ${JSON.stringify(obj.throwsThrough)}', ` +
97
+ `which is not a boolean. Only 'true' declares that throws from this slot's target ` +
98
+ `surface through the declaring resource; anything else reads as absent, which ` +
99
+ `silently stops its catch list from enclosing what it holds.`,
100
+ });
101
+ }
102
+ // Closed, for the reason the token sets are: a misspelled key is indexed by
103
+ // nothing and read by nothing, so it validates, ships, and does exactly what
104
+ // omitting it would.
105
+ for (const key of Object.keys(obj)) {
106
+ if (REF_ANNOTATION_KEYS.has(key))
107
+ continue;
108
+ issues.push({
109
+ code: "X_TELO_REF_UNKNOWN_KEY",
110
+ manifest,
111
+ path,
112
+ message: `x-telo-ref at '${path}' declares unrecognized key '${key}'. Known keys: ` +
113
+ `${[...REF_ANNOTATION_KEYS].sort().join(", ")}. An unrecognized key is read by nothing, ` +
114
+ `so it has exactly the effect of leaving it out.`,
115
+ });
116
+ }
86
117
  return declaredUses(use);
87
118
  }
119
+ /** Every key the structured `x-telo-ref` form accepts — the write side of
120
+ * `readRefSlot`'s read side. Adding one belongs in both. */
121
+ const REF_ANNOTATION_KEYS = new Set(["kind", "use", "inputs", "throwsThrough"]);
88
122
  /** True when a node is a reference slot: it carries `x-telo-ref` directly or on
89
123
  * an `anyOf`/`oneOf` branch. */
90
124
  function carriesRefAnnotation(obj) {
@@ -1 +1 @@
1
- {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAarD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAE/F,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAMnE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,kBAAkB;AAC5B;8EAC8E;AAC9E,aAAa,UAAQ,GACpB,OAAO,CAwBT;AAgED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CA0hBtB"}
1
+ {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAarD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAE/F,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAMnE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,kBAAkB;AAC5B;8EAC8E;AAC9E,aAAa,UAAQ,GACpB,OAAO,CAwBT;AAgED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAwjBtB"}
@@ -509,14 +509,38 @@ export function validateReferences(resources, context) {
509
509
  for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
510
510
  if (fieldValue == null)
511
511
  continue;
512
- const issues = registry.validateWithRefs(fieldValue, subSchema);
512
+ // CEL leaves become schema-shaped placeholders first, exactly as the
513
+ // sibling-ref branch below does and for the same reason: a slot
514
+ // anchored at a shared value-shape is overwhelmingly written as
515
+ // expressions, so validating it raw reports every one of them as a
516
+ // type error and the check fires only on the literal case nobody
517
+ // writes. Omitting it here made one annotation mean two different
518
+ // things depending on which branch resolved it — a `when:` typed
519
+ // `boolean` accepted a `!cel` at a route's inline slot and rejected
520
+ // the identical expression at a slot anchored on the carrier that
521
+ // declares that very shape.
522
+ const substituted = substituteCelFields(fieldValue, subSchema);
523
+ // Anchored at the offending node INSIDE the value, not at the slot:
524
+ // a `returns:` list is an array of entries, and reporting every one
525
+ // of its issues on the `returns:` line puts three diagnostics on one
526
+ // line and none on the entry that is wrong.
527
+ const issues = registry.validateResourceConfig(substituted, subSchema);
513
528
  for (const issue of issues) {
514
529
  diagnostics.push({
515
530
  severity: DiagnosticSeverity.Error,
516
531
  code: "DEPENDENT_SCHEMA_MISMATCH",
517
532
  source: SOURCE,
518
- message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
519
- data: { resource: resourceData, filePath, path: concretePath },
533
+ message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue.message}`,
534
+ data: {
535
+ resource: resourceData,
536
+ filePath,
537
+ // An index-first sub-path (`[0].content`) joins with no dot.
538
+ path: !issue.path
539
+ ? concretePath
540
+ : issue.path.startsWith("[")
541
+ ? `${concretePath}${issue.path}`
542
+ : `${concretePath}.${issue.path}`,
543
+ },
520
544
  });
521
545
  }
522
546
  }
@@ -1 +1 @@
1
- {"version":3,"file":"validate-throws-coverage.d.ts","sourceRoot":"","sources":["../src/validate-throws-coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAOnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAsfzE,oDAAoD;AACpD,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,WAAW,EAChB,eAAe,GAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAa,EACvD,WAAW,GAAE,GAAG,CAAC,MAAM,CAAa;AACpC;;+BAE+B;AAC/B,eAAe,GAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAa,GAC3D,kBAAkB,EAAE,CAyDtB"}
1
+ {"version":3,"file":"validate-throws-coverage.d.ts","sourceRoot":"","sources":["../src/validate-throws-coverage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAEjE,OAAO,EAIL,KAAK,gBAAgB,EACtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAenE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AA4kBzE,oDAAoD;AACpD,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,WAAW,EAChB,eAAe,GAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAa,EACvD,WAAW,GAAE,GAAG,CAAC,MAAM,CAAa;AACpC;;+BAE+B;AAC/B,eAAe,GAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAa,GAC3D,kBAAkB,EAAE,CAsLtB"}
@@ -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,23 +485,23 @@ 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
  }
@@ -438,32 +515,106 @@ export function validateThrowsCoverage(manifests, defs, aliases, env, aliasesByM
438
515
  moduleManifests = new Map()) {
439
516
  const diagnostics = [];
440
517
  diagnostics.push(...validateThrowsDeclarations(manifests));
441
- const resolveCtx = createResolveCtx(manifests, defs, aliases, aliasesByModule, rootModules, moduleManifests);
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);
442
532
  // The alias resolver for a manifest's own lexical scope — an imported library's
443
533
  // resolver when it owns the manifest, else undefined (fall back to root aliases).
444
534
  const scopeResolverFor = (m) => scopeResolverForModule(m.metadata?.module, rootModules, aliasesByModule);
445
- 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) {
446
554
  if (!manifest.kind || !manifest.metadata?.name)
447
555
  continue;
448
556
  if (manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract")
449
557
  continue;
450
- const scopeResolver = scopeResolverFor(manifest);
451
- const resolvedKind = scopeResolver?.resolveKind(manifest.kind) ?? aliases.resolveKind(manifest.kind);
452
- 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);
453
568
  if (!definition?.schema)
454
569
  continue;
455
570
  const resource = { kind: manifest.kind, name: manifest.metadata.name };
456
- 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;
457
575
  collectOutcomeLists(manifest, definition.schema, (ret) => {
458
- diagnostics.push(...checkCatchAllPlacement(ret.entries, resource, "returns", filePath, ret.arrayPath));
576
+ diagnostics.push(...checkCatchAllPlacement(ret.entries, resource, "returns", filePath, `${pathPrefix}${ret.arrayPath}`, routing));
459
577
  }, (entries, arrayPath, siblingData, catchesFor) => {
460
- diagnostics.push(...checkCatchAllPlacement(entries, resource, "catches", filePath, arrayPath));
461
- const handlerRef = resolveHandlerRef(siblingData[catchesFor]);
462
- const union = handlerRefUnion(handlerRef, manifests, resolveCtx, scopeResolver);
463
- diagnostics.push(...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handlerRef));
464
- 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));
465
593
  });
466
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
+ }
467
618
  return diagnostics;
468
619
  }
469
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.71.0",
3
+ "version": "0.72.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -43,7 +43,7 @@
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",