@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
package/src/analyzer.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  type CelHandlers,
25
25
  } from "./cel-environment.js";
26
26
  import { DefinitionRegistry } from "./definition-registry.js";
27
+ import { readDeprecation } from "./deprecation.js";
27
28
  import { type ContractDirection, effectiveAuthorSchema } from "./extends-resolution.js";
28
29
  import {
29
30
  analyzerContractScope,
@@ -125,7 +126,12 @@ import {
125
126
  type SchemaIssue,
126
127
  } from "./schema-compat.js";
127
128
  import { collectValueSchemaIssues } from "./validate-value-schema.js";
128
- import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
129
+ import {
130
+ DiagnosticSeverity,
131
+ DiagnosticTag,
132
+ type AnalysisDiagnostic,
133
+ type AnalysisOptions,
134
+ } from "./types.js";
129
135
  import {
130
136
  extractAccessChains,
131
137
  extractContextsFromSchema,
@@ -1535,6 +1541,7 @@ export class StaticAnalyzer {
1535
1541
  aliases,
1536
1542
  rootModules,
1537
1543
  getCallGraph(),
1544
+ { aliasesByModule, rootModules },
1538
1545
  ),
1539
1546
  );
1540
1547
  // A file embed resolves at resource creation, so one written on a doc that
@@ -1911,6 +1918,52 @@ export class StaticAnalyzer {
1911
1918
  continue;
1912
1919
  }
1913
1920
 
1921
+ // A kind its own author marked `metadata.deprecated` still works exactly as
1922
+ // it did — the manifest is valid and keeps running — so this is a WARNING,
1923
+ // reported at the declaration because `kind:` is the line that has to
1924
+ // change. Emitted here rather than in a pass of its own so it reads the
1925
+ // kind THIS walk resolved: the resolution is alias- and gate-aware and
1926
+ // scope-dependent, and a second implementation of it would eventually
1927
+ // disagree about which definition a name means.
1928
+ //
1929
+ // Entry-module-scoped, like every other "not the consumer's to fix" check:
1930
+ // a library's internal use of a kind its own author deprecated is that
1931
+ // author's concern, and reporting it floods a consumer with lines they
1932
+ // cannot act on.
1933
+ //
1934
+ // No `DiagnosticFix`. `replacedBy` names a kind through the DECLARING
1935
+ // module's aliases; writing it into the consumer's file would produce a
1936
+ // prefix that resolves to nothing there — and a kind swap is not a
1937
+ // whole-value replacement anyway, since the successor needs its own import
1938
+ // and usually a different configuration.
1939
+ const deprecation = readDeprecation(definition.metadata);
1940
+ if (deprecation && (!ownModule || rootModules.has(ownModule))) {
1941
+ // Quoted CANONICALLY, not verbatim: `Self.Thing` is how the declaring
1942
+ // library names its own kind and means nothing where the warning lands.
1943
+ const declaringScope =
1944
+ scopeResolverForModule(
1945
+ (definition.metadata as { module?: string } | undefined)?.module,
1946
+ rootModules,
1947
+ aliasesByModule,
1948
+ ) ?? aliases;
1949
+ const replacement = deprecation.replacedBy
1950
+ ? (declaringScope.resolveKind(deprecation.replacedBy) ?? deprecation.replacedBy)
1951
+ : undefined;
1952
+ diagnostics.push({
1953
+ severity: DiagnosticSeverity.Warning,
1954
+ code: "DEPRECATED_KIND",
1955
+ source: SOURCE,
1956
+ // Warning-grade AND a deprecation: the severity says it must
1957
+ // eventually be dealt with, the tag says what it is, and an editor
1958
+ // strikes the kind through on the strength of the second.
1959
+ tags: [DiagnosticTag.Deprecated],
1960
+ message:
1961
+ `Kind '${m.kind}' is deprecated: ${deprecation.reason}` +
1962
+ (replacement ? ` Use '${replacement}' instead.` : ""),
1963
+ data: { resource, filePath, path: "kind" },
1964
+ });
1965
+ }
1966
+
1914
1967
  // Validate resource config against the definition's AUTHOR-FACING schema —
1915
1968
  // inheritance-resolved, with `kind` / `metadata` injected. See
1916
1969
  // `validationSchemaFor`, which is where both derivations and the reason
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Which `catches:` lists answer for one dispatch site.
3
+ *
4
+ * A catch list is one rung of a SCOPE LADDER — a route's, its router's, its
5
+ * server's — and every rule over those lists needs the same two answers: what
6
+ * a scope list is checked against, and which scopes enclose a given site. This
7
+ * module owns that model; the rule checks in `validate-throws-coverage.ts` read
8
+ * one answer instead of rebuilding it beside four unrelated jobs.
9
+ *
10
+ * Browser-safe: no Node built-ins.
11
+ */
12
+ import type { DefinitionRegistry } from "./definition-registry.js";
13
+ import type { AliasResolver } from "./alias-resolver.js";
14
+ import type { ResourceManifest } from "@telorun/sdk";
15
+ import { visitManifest } from "./manifest-visitor.js";
16
+ import { forEachDrivenSlot } from "./schema-walk.js";
17
+ import { resolveRefManifest, type ResolveCtx } from "./resolve-throws-union.js";
18
+
19
+ /** What a list proves it renders: the codes its coverage-proving `when:` clauses
20
+ * name, and whether it ends in a catch-all.
21
+ *
22
+ * Unfiltered by any denominator, because this is also what an enclosing scope
23
+ * contributes DOWNWARD — a server entry naming a code the server's own closure
24
+ * could not enumerate still renders it for the route that throws it. Filtering
25
+ * the local answer against the declared union happens where that answer is
26
+ * used, and subtracting from the declared set makes the two equivalent there. */
27
+ export interface ProvenCoverage {
28
+ codes: Set<string>;
29
+ hasCatchAll: boolean;
30
+ }
31
+
32
+ export const NO_COVERAGE: ProvenCoverage = { codes: new Set(), hasCatchAll: false };
33
+
34
+ /** A declaration inside another resource's `x-telo-scope` array, kept with the
35
+ * resource that encloses it and the path it is written at in that resource's
36
+ * document. Both are needed: the owner is the only place its kind's alias scope
37
+ * can be found, and the only document position lookup can reach. */
38
+ export interface ScopedManifest {
39
+ manifest: ResourceManifest;
40
+ owner: ResourceManifest;
41
+ /** Concrete owner-relative path of the declaration (`with[0]`). */
42
+ path: string;
43
+ }
44
+
45
+ /** Every `with:`-scoped declaration in the set, once each.
46
+ *
47
+ * Scoped resources are absent from the flat manifest list, so every check that
48
+ * iterates it skips them — and standing a server up around a test is exactly
49
+ * that shape, so the sanctioned pattern was the one the pass could not see.
50
+ * Discovered through the shared visitor rather than a second scope walk. */
51
+ export function collectScopedManifests(
52
+ manifests: ResourceManifest[],
53
+ defs: DefinitionRegistry,
54
+ aliases: AliasResolver,
55
+ aliasesByModule: Map<string, AliasResolver>,
56
+ rootModules: Set<string>,
57
+ ): ScopedManifest[] {
58
+ const scoped: ScopedManifest[] = [];
59
+ visitManifest(
60
+ manifests,
61
+ defs,
62
+ {
63
+ onScope: (event) => {
64
+ for (const { manifest, path } of event.declarations) {
65
+ if (!manifest?.kind || !manifest.metadata?.name) continue;
66
+ scoped.push({ manifest, owner: event.source, path });
67
+ }
68
+ },
69
+ },
70
+ { aliases, aliasesByModule, rootModules },
71
+ );
72
+ return scoped;
73
+ }
74
+
75
+ /** Which resources each resource's scope list encloses, along the slots that
76
+ * declare `throwsThrough` — one fact stated once, since the edge a throws
77
+ * closure crosses is the edge a catch scope encloses through. */
78
+ export function buildEnclosers(
79
+ manifests: ResourceManifest[],
80
+ definitionOf: (m: ResourceManifest) => { schema?: Record<string, any> } | undefined,
81
+ moduleOf: (m: ResourceManifest) => string | undefined,
82
+ ctx: ResolveCtx,
83
+ ): Map<ResourceManifest, ResourceManifest[]> {
84
+ const enclosers = new Map<ResourceManifest, ResourceManifest[]>();
85
+ for (const manifest of manifests) {
86
+ const definition = definitionOf(manifest);
87
+ if (!definition?.schema) continue;
88
+ forEachDrivenSlot(definition.schema, manifest, (driven) => {
89
+ if (driven.kind !== "ref" || !driven.slot.throwsThrough) return;
90
+ const target = resolveRefManifest(driven.data, ctx, moduleOf(manifest));
91
+ if (!target || target === manifest) return;
92
+ const list = enclosers.get(target);
93
+ if (list) list.push(manifest);
94
+ else enclosers.set(target, [manifest]);
95
+ });
96
+ }
97
+ return enclosers;
98
+ }
99
+
100
+ /**
101
+ * What every scope enclosing a resource is guaranteed to render for it.
102
+ *
103
+ * **Across enclosers this INTERSECTS, and that is the whole correctness of the
104
+ * reduction.** Coverage claims that a throw cannot escape unrendered, so it holds
105
+ * only when EVERY path to the site renders it — a router mounted on a public
106
+ * server with a catch-all and an internal one without is covered on one path and
107
+ * bare on the other, and unioning the two reported it as fully covered while the
108
+ * internal server answered with the built-in envelope. A resource with no
109
+ * encloser contributes nothing rather than everything: the empty set is the
110
+ * identity for the site's own coverage, and treating "no paths" as "all paths
111
+ * agree" would assert coverage no list provides.
112
+ *
113
+ * A resource's OWN scope list is unioned in, because it applies on every path.
114
+ *
115
+ * Memoized, and a cycle among `throwsThrough` edges resolves to what has been
116
+ * accumulated so far rather than raising: this answers what a scope renders, and
117
+ * no cycle makes that answer larger.
118
+ */
119
+ export function enclosingCoverage(
120
+ manifest: ResourceManifest,
121
+ ownScope: Map<ResourceManifest, ProvenCoverage>,
122
+ enclosers: Map<ResourceManifest, ResourceManifest[]>,
123
+ memo: Map<ResourceManifest, ProvenCoverage> = new Map(),
124
+ walking: Set<ResourceManifest> = new Set(),
125
+ ): ProvenCoverage {
126
+ const cached = memo.get(manifest);
127
+ if (cached) return cached;
128
+ if (walking.has(manifest)) return NO_COVERAGE;
129
+ walking.add(manifest);
130
+ try {
131
+ const own = ownScope.get(manifest);
132
+ const result: ProvenCoverage = {
133
+ codes: new Set(own?.codes ?? []),
134
+ hasCatchAll: own?.hasCatchAll ?? false,
135
+ };
136
+
137
+ const outer = (enclosers.get(manifest) ?? []).map((e) =>
138
+ enclosingCoverage(e, ownScope, enclosers, memo, walking),
139
+ );
140
+ if (outer.length > 0) {
141
+ const [first, ...rest] = outer;
142
+ const shared = new Set(first.codes);
143
+ let everyCatchAll = first.hasCatchAll;
144
+ for (const other of rest) {
145
+ for (const code of [...shared]) if (!other.codes.has(code)) shared.delete(code);
146
+ everyCatchAll &&= other.hasCatchAll;
147
+ }
148
+ for (const code of shared) result.codes.add(code);
149
+ if (everyCatchAll) result.hasCatchAll = true;
150
+ }
151
+
152
+ memo.set(manifest, result);
153
+ return result;
154
+ } finally {
155
+ walking.delete(manifest);
156
+ }
157
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `metadata.deprecated` — the annotation's single reader.
3
+ *
4
+ * Structural validity belongs to `validate-module-metadata.ts` (the strict half,
5
+ * the `ref-slot.ts` split). This side is deliberately lenient: it reads a
6
+ * well-formed block and treats everything else as absent, so a malformed
7
+ * declaration in a published dependency never becomes a warning at a consumer's
8
+ * use site. The consumer can fix neither one, and reporting the second blames
9
+ * the wrong author.
10
+ */
11
+
12
+ /** A deprecation as declared: why, and optionally what to use instead. */
13
+ export interface Deprecation {
14
+ /** What a consumer reads to know what to do instead. Always non-empty. */
15
+ reason: string;
16
+ /** Alias-qualified kind (kind docs) or module ref (module docs), **as written
17
+ * in the declaring file's own scope** — `Self.Thing` means nothing to a
18
+ * consumer, so a use site resolves it before quoting it. */
19
+ replacedBy?: string;
20
+ }
21
+
22
+ export function readDeprecation(metadata: unknown): Deprecation | undefined {
23
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
24
+ const block = (metadata as Record<string, unknown>).deprecated;
25
+ if (block === null || typeof block !== "object" || Array.isArray(block)) return undefined;
26
+
27
+ const { reason, replacedBy } = block as Record<string, unknown>;
28
+ if (typeof reason !== "string" || reason.trim() === "") return undefined;
29
+
30
+ return {
31
+ reason: reason.trim(),
32
+ ...(typeof replacedBy === "string" && replacedBy.trim() !== ""
33
+ ? { replacedBy: replacedBy.trim() }
34
+ : {}),
35
+ };
36
+ }
package/src/index.ts CHANGED
@@ -185,6 +185,8 @@ export {
185
185
  export type { RefSlot, RefUse, RefUseCases } from "./ref-slot.js";
186
186
  export { isStepSlot, readStepSlot, STEP_FRAGMENT } from "./step-slot.js";
187
187
  export type { StepSlot } from "./step-slot.js";
188
+ export { readDeprecation } from "./deprecation.js";
189
+ export type { Deprecation } from "./deprecation.js";
188
190
  export {
189
191
  ANNOTATION_KEYWORDS,
190
192
  registerTeloKeywords,
@@ -489,7 +491,12 @@ export type { CelScopeQueryContext, ContextDeclarationSite } from "./cel-scope-q
489
491
  export { navigateConcretePath } from "./manifest-path.js";
490
492
  export { ManifestAnalysis } from "./manifest-analysis.js";
491
493
  export type { ManifestRef } from "./manifest-analysis.js";
492
- export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity, diagnosticFix } from "./types.js";
494
+ export {
495
+ DEFAULT_MANIFEST_FILENAME,
496
+ DiagnosticSeverity,
497
+ DiagnosticTag,
498
+ diagnosticFix,
499
+ } from "./types.js";
493
500
  export type {
494
501
  AnalysisDiagnostic,
495
502
  AnalysisOptions,
@@ -66,8 +66,21 @@ export interface ScopeBoundaryEvent {
66
66
  source: ResourceManifest;
67
67
  /** Dot-form prefixes of every `x-telo-scope` field on this resource. */
68
68
  scopePrefixes: string[];
69
- /** Scope-field JSON Pointer manifests declared within that scope. */
69
+ /** VISIBILITY pointer (an `x-telo-scope` entry, e.g. `/steps`) the manifests
70
+ * visible there. Keyed by where scoped names may be REFERENCED, which is not
71
+ * where they are DECLARED: `Run.Sequence` declares `x-telo-scope:
72
+ * ["/steps", "/targets"]` on its `with:` field, so one declaration appears
73
+ * under two keys and neither names `with`. Use {@link declarations} for the
74
+ * declaration site. */
70
75
  manifestsByPointer: Map<string, ResourceManifest[]>;
76
+ /** Every inline declaration inside this resource's scopes, ONCE each, with the
77
+ * concrete path it is written at in the owner's document (`with[0]`).
78
+ *
79
+ * A diagnostic about a scoped resource has to anchor there: the resource is
80
+ * not a top-level document, so position lookup finds the OWNER and then walks
81
+ * this path into it. Deriving one from a visibility pointer instead names a
82
+ * region the declaration is not in (`steps[0]`). */
83
+ declarations: { manifest: ResourceManifest; path: string }[];
71
84
  /** Names of every resource declared inside this resource's scopes. Used by
72
85
  * the dependency graph to drop boot edges to scoped (on-demand) targets. */
73
86
  enclosedNames: Set<string>;
@@ -271,6 +284,7 @@ export function visitManifest(
271
284
  if (refScopeMap && (wantsRefs || wantsScope)) {
272
285
  const manifestsByPointer = new Map<string, ResourceManifest[]>();
273
286
  const scopeRefEntries: { path: string; refName: string }[] = [];
287
+ const declarations: { manifest: ResourceManifest; path: string }[] = [];
274
288
  for (const [fieldPath, entry] of refScopeMap) {
275
289
  if (!isScopeEntry(entry)) continue;
276
290
  const raw: ResourceManifest[] = [];
@@ -278,6 +292,7 @@ export function visitManifest(
278
292
  const items = Array.isArray(fe.value) ? fe.value : [fe.value];
279
293
  items.forEach((v, i) => {
280
294
  if (!v || typeof v !== "object") return;
295
+ const declarationPath = Array.isArray(fe.value) ? `${fe.path}[${i}]` : fe.path;
281
296
  // A scope entry must be an inline resource definition; a `!ref`
282
297
  // (tagged sentinel or resolved `{kind, name}`) is not — record it
283
298
  // so a static diagnostic flags it instead of registering a
@@ -288,12 +303,13 @@ export function visitManifest(
288
303
  (typeof rec.kind === "string" && typeof rec.name === "string")
289
304
  ) {
290
305
  scopeRefEntries.push({
291
- path: Array.isArray(fe.value) ? `${fe.path}[${i}]` : fe.path,
306
+ path: declarationPath,
292
307
  refName: isRefSentinel(v) ? v.source : String(rec.name),
293
308
  });
294
309
  return;
295
310
  }
296
311
  raw.push(v as ResourceManifest);
312
+ declarations.push({ manifest: v as ResourceManifest, path: declarationPath });
297
313
  });
298
314
  }
299
315
  const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
@@ -313,6 +329,7 @@ export function visitManifest(
313
329
  source: r,
314
330
  scopePrefixes,
315
331
  manifestsByPointer,
332
+ declarations,
316
333
  enclosedNames,
317
334
  scopeRefEntries,
318
335
  });
@@ -21,7 +21,7 @@ import type {
21
21
  MigrationPath,
22
22
  MigrationRewrite,
23
23
  } from "./types.js";
24
- import type { AnalysisDiagnostic, DiagnosticFix } from "../types.js";
24
+ import { DiagnosticTag, type AnalysisDiagnostic, type DiagnosticFix } from "../types.js";
25
25
 
26
26
  /** One patch that applied, as the reporting side needs to see it. */
27
27
  export interface AppliedPatch {
@@ -63,6 +63,10 @@ export function toDiagnostic(
63
63
  severity: applied.entry.severity,
64
64
  code: applied.entry.code,
65
65
  source: "telo-analyzer",
66
+ // A migration IS a deprecation — a legacy spelling still read, and the one
67
+ // an author is being asked to stop writing — so the range carries the tag
68
+ // whatever severity the entry chose for itself.
69
+ tags: [DiagnosticTag.Deprecated],
66
70
  message: `${rewrite.summary}\n${applied.entry.reason}\n${closing}`,
67
71
  data: {
68
72
  filePath: source,
package/src/ref-slot.ts CHANGED
@@ -94,6 +94,21 @@ export interface RefSlot {
94
94
  * that carries this call's arguments. Replaces
95
95
  * `x-telo-topology-role: inputs`. */
96
96
  inputs?: string;
97
+ /** Throws raised by the target — or by anything the target drives — surface
98
+ * through the DECLARING resource, so its own throws union includes the
99
+ * target's and its catch scope encloses the target's.
100
+ *
101
+ * It exists because the relation is real where `use` correctly says nothing:
102
+ * `Http.Server.mounts[].mount` is a `dependency` (the server holds the mount
103
+ * and calls a convention method on it — control reaches a route through the
104
+ * MOUNT's own `trigger.inbound` slot, not through this one), yet a throw from
105
+ * that route is exactly what the server renders. Following every `dependency`
106
+ * edge instead would drag a connection's throws into a router's denominator.
107
+ *
108
+ * Declared by the kind that HOLDS, because only it knows the throws surface
109
+ * through it, and it is the same fact its runtime establishes by rethrowing —
110
+ * so the annotation and the behaviour cannot disagree. */
111
+ throwsThrough?: boolean;
97
112
  /** `x-telo-inline: true` on the slot or any `anyOf` branch — accepts an inline
98
113
  * `{kind, ...config}` definition, not only a `!ref`. */
99
114
  inline: boolean;
@@ -228,6 +243,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
228
243
  const uses = new Set<RefUse>();
229
244
  let useCases: RefUseCases | undefined;
230
245
  let inputs: string | undefined;
246
+ let throwsThrough = false;
231
247
 
232
248
  for (const carrier of nodes) {
233
249
  const annotation = carrier["x-telo-ref"];
@@ -239,6 +255,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
239
255
  for (const use of normalizeUses(obj.use)) uses.add(use);
240
256
  useCases ??= readUseCases(obj.use);
241
257
  if (typeof obj.inputs === "string") inputs ??= obj.inputs;
258
+ if (obj.throwsThrough === true) throwsThrough = true;
242
259
  }
243
260
 
244
261
  const slot: RefSlot = {
@@ -249,6 +266,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
249
266
  };
250
267
  if (useCases) slot.useCases = useCases;
251
268
  if (inputs !== undefined) slot.inputs = inputs;
269
+ if (throwsThrough) slot.throwsThrough = true;
252
270
  return slot;
253
271
  }
254
272
 
@@ -274,6 +292,7 @@ export function refSlotAnnotation(slot: RefSlot): Record<string, unknown> {
274
292
  else if (slot.uses.length === 1) annotation.use = slot.uses[0];
275
293
  else if (slot.uses.length > 1) annotation.use = slot.uses;
276
294
  if (slot.inputs !== undefined) annotation.inputs = slot.inputs;
295
+ if (slot.throwsThrough) annotation.throwsThrough = true;
277
296
  return annotation;
278
297
  }
279
298
 
@@ -4,7 +4,9 @@ import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js"
4
4
  import { resolveScopedName } from "./call-graph.js";
5
5
  import { refSentinelTarget, type RefSentinelTarget } from "./ref-sentinel-target.js";
6
6
  import type { DefinitionRegistry } from "./definition-registry.js";
7
+ import { possibleUses, readRefSlot, transfersControl, type RefSlot } from "./ref-slot.js";
7
8
  import { readStepSlot } from "./step-slot.js";
9
+ import { forEachDrivenSlot } from "./schema-walk.js";
8
10
 
9
11
  export interface ThrowsCodeMeta {
10
12
  data?: Record<string, any>;
@@ -263,6 +265,16 @@ export function resolveThrowsUnion(
263
265
  }
264
266
  }
265
267
 
268
+ /**
269
+ * `throws.inherit: true` — the union a composer's own STEP BODIES reach.
270
+ *
271
+ * Deliberately steps only, and not every slot the resource drives: `inherit` is
272
+ * a DECLARATION that a kind's union is the union of what it dispatches, and a
273
+ * kind that does not make that claim must not have it inferred — a kind holding
274
+ * a `call` ref it catches internally would silently gain codes it never lets
275
+ * escape. What a CATCH SCOPE needs is a different question with a different
276
+ * answer, and it has its own resolver below.
277
+ */
266
278
  function resolveInherited(
267
279
  manifest: ResourceManifest,
268
280
  definition: ResourceDefinition,
@@ -284,6 +296,66 @@ function resolveInherited(
284
296
  return result;
285
297
  }
286
298
 
299
+ /**
300
+ * The union a SCOPE-LEVEL `catches:` list can be asked to render — everything
301
+ * the resource it is written on drives, transitively.
302
+ *
303
+ * `x-telo-catches-for: ""` is itself the claim that this is the denominator, so
304
+ * nothing is inferred from a kind that did not opt in, and no definition has to
305
+ * declare a `throws:` block to carry a catch scope (the kernel forbids one on a
306
+ * `Telo.Service` and a `Telo.Mount`, and rightly: what a router renders is not
307
+ * what a router THROWS).
308
+ *
309
+ * Three edges, three answers. A **step body** contributes its own traversal,
310
+ * subtraction included. A **control-transferring ref** contributes the target's
311
+ * own declared union — a route handler is a leaf here, and asking what IT drives
312
+ * would credit this scope with codes the handler catches internally. A
313
+ * **`throwsThrough` ref** recurses, because the target is another scope on the
314
+ * same ladder: a server renders what its mounts' routes throw, not what the
315
+ * mounts themselves declare.
316
+ */
317
+ export function resolveScopeUnion(
318
+ manifest: ResourceManifest,
319
+ definition: ResourceDefinition,
320
+ ctx: ResolveCtx,
321
+ seen: Set<ResourceManifest> = new Set(),
322
+ ): ThrowsUnion {
323
+ const result: ThrowsUnion = { codes: new Map(), unbounded: false };
324
+ if (seen.has(manifest)) return result;
325
+ seen.add(manifest);
326
+ const ownerModule = (manifest.metadata as { module?: string } | undefined)?.module;
327
+
328
+ forEachDrivenSlot(definition.schema, manifest, (driven) => {
329
+ if (driven.kind === "step") {
330
+ unionInto(
331
+ result,
332
+ collectStepArrayThrows(driven.data, driven.slot.invoke, undefined, ctx, ownerModule),
333
+ );
334
+ return;
335
+ }
336
+ if (driven.slot.throwsThrough) {
337
+ const target = resolveRefManifest(driven.data, ctx, ownerModule);
338
+ const targetDef = target
339
+ ? definitionFor(
340
+ target.kind,
341
+ ctx.defs,
342
+ ctx.aliases,
343
+ scopeResolverFor(ctx, (target.metadata as { module?: string } | undefined)?.module),
344
+ )
345
+ : undefined;
346
+ if (target && targetDef) unionInto(result, resolveScopeUnion(target, targetDef, ctx, seen));
347
+ // A target that cannot be resolved says nothing about what it throws, so
348
+ // the scope's union is no longer enumerable.
349
+ else result.unbounded = true;
350
+ return;
351
+ }
352
+ if (!possibleUses(driven.slot).some(transfersControl)) return;
353
+ unionInto(result, resolveRefTargetThrows(driven.data, ctx, ownerModule));
354
+ });
355
+
356
+ return result;
357
+ }
358
+
287
359
  function collectStepArrayThrows(
288
360
  steps: unknown[],
289
361
  invokeField: string,
@@ -410,9 +482,32 @@ function resolveStepInvokeThrows(
410
482
  ctx: ResolveCtx,
411
483
  ownerModule: string | undefined,
412
484
  ): ThrowsUnion {
413
- const invokeRef = step[invokeField];
414
- if (!invokeRef || typeof invokeRef !== "object") return emptyUnion();
415
- const invokedKind = invokeRef.kind as string | undefined;
485
+ return resolveRefTargetThrows(step[invokeField], ctx, ownerModule, () =>
486
+ resolvePassthroughAtCallSite(step, enclosingTryCodes),
487
+ );
488
+ }
489
+
490
+ /**
491
+ * The effective throw union behind a resolved reference value.
492
+ *
493
+ * Shared by both ways a resource drives another: a step's `invoke:` and a
494
+ * reference slot that carries throws. The two used to differ only in where the
495
+ * ref value was read from, and keeping one copy is what stops a router's
496
+ * denominator and a sequence's from disagreeing about what a name resolves to.
497
+ *
498
+ * `onPassthrough` is the one genuine difference: a passthrough kind's union is a
499
+ * property of the CALL SITE (`inputs.code`), which only a step has. A reference
500
+ * slot has no such site, so the union is unbounded there rather than guessed.
501
+ */
502
+ function resolveRefTargetThrows(
503
+ refValue: unknown,
504
+ ctx: ResolveCtx,
505
+ ownerModule: string | undefined,
506
+ onPassthrough?: () => ThrowsUnion,
507
+ ): ThrowsUnion {
508
+ if (!refValue || typeof refValue !== "object" || Array.isArray(refValue)) return emptyUnion();
509
+ const ref = refValue as Record<string, any>;
510
+ const invokedKind = ref.kind as string | undefined;
416
511
  // A reference that still carries its parse-time sentinel — a library-internal
417
512
  // `!ref` inside a manifest forwarded into a consumer's flat set, where Phase
418
513
  // 2.5 had nothing to resolve it against. The target is in the declaring
@@ -422,7 +517,7 @@ function resolveStepInvokeThrows(
422
517
  // its consumer and get the consumer's `catches:` rejected for the code the
423
518
  // entry point documents.
424
519
  if (!invokedKind) {
425
- const sentinel = refSentinelTarget(invokeRef);
520
+ const sentinel = refSentinelTarget(ref);
426
521
  if (!sentinel) return emptyUnion();
427
522
  const target = findSentinelTarget(ctx, sentinel, ownerModule);
428
523
  if (target) return resolveThrowsUnion(target, ctx);
@@ -430,32 +525,19 @@ function resolveStepInvokeThrows(
430
525
  }
431
526
 
432
527
  // The invoked kind's alias resolves in the OWNER manifest's lexical scope (the
433
- // composer that declares the step), so a library's step referencing its own
528
+ // resource that declares the slot), so a library's step referencing its own
434
529
  // import resolves against that library, not the consumer.
435
530
  const scopeResolver = scopeResolverFor(ctx, ownerModule);
436
531
  const definition = definitionFor(invokedKind, ctx.defs, ctx.aliases, scopeResolver);
437
532
  if (!definition) return { codes: new Map(), unbounded: true };
438
533
 
439
534
  if (definition.throws?.passthrough) {
440
- return resolvePassthroughAtCallSite(step, enclosingTryCodes);
535
+ return onPassthrough ? onPassthrough() : { codes: new Map(), unbounded: true };
441
536
  }
442
537
 
443
538
  // Named manifest: resolve the full chain (covers transitive inherit).
444
- const invokeName = invokeRef.name as string | undefined;
445
- if (invokeName) {
446
- const scopedInvokedKind = scopeResolver?.resolveKind(invokedKind);
447
- const target = findTarget(
448
- ctx,
449
- invokeName,
450
- ownerModule,
451
- (m) =>
452
- m.kind === invokedKind ||
453
- ctx.aliases.resolveKind(m.kind) === invokedKind ||
454
- m.kind === ctx.aliases.resolveKind(invokedKind) ||
455
- (scopedInvokedKind !== undefined && m.kind === scopedInvokedKind),
456
- );
457
- if (target) return resolveThrowsUnion(target, ctx);
458
- }
539
+ const target = resolveRefManifest(ref, ctx, ownerModule);
540
+ if (target) return resolveThrowsUnion(target, ctx);
459
541
 
460
542
  // Fall back to the definition's own explicit codes. Mark unbounded when the
461
543
  // definition depends on call-site or transitive resolution we couldn't
@@ -466,6 +548,42 @@ function resolveStepInvokeThrows(
466
548
  return { codes, unbounded };
467
549
  }
468
550
 
551
+ /**
552
+ * The manifest a resolved reference value names, in either shape it arrives in —
553
+ * a `{kind, name}` pair, or a `!ref` still carrying its parse-time sentinel.
554
+ *
555
+ * Exported because the catch-scope enclosure walk asks the same question about
556
+ * the same values; resolving a name twice by two rules is how two passes end up
557
+ * disagreeing about which resource a slot points at.
558
+ */
559
+ export function resolveRefManifest(
560
+ refValue: unknown,
561
+ ctx: ResolveCtx,
562
+ ownerModule: string | undefined,
563
+ ): ResourceManifest | undefined {
564
+ if (!refValue || typeof refValue !== "object" || Array.isArray(refValue)) return undefined;
565
+ const ref = refValue as Record<string, any>;
566
+ const kind = ref.kind as string | undefined;
567
+ if (!kind) {
568
+ const sentinel = refSentinelTarget(ref);
569
+ return sentinel ? findSentinelTarget(ctx, sentinel, ownerModule) : undefined;
570
+ }
571
+ const name = ref.name as string | undefined;
572
+ if (!name) return undefined;
573
+ const scopeResolver = scopeResolverFor(ctx, ownerModule);
574
+ const scopedKind = scopeResolver?.resolveKind(kind);
575
+ return findTarget(
576
+ ctx,
577
+ name,
578
+ ownerModule,
579
+ (m) =>
580
+ m.kind === kind ||
581
+ ctx.aliases.resolveKind(m.kind) === kind ||
582
+ m.kind === ctx.aliases.resolveKind(kind) ||
583
+ (scopedKind !== undefined && m.kind === scopedKind),
584
+ );
585
+ }
586
+
469
587
  /** Resolve a passthrough-style invocable at a specific call site. Recognised forms
470
588
  * (see "passthrough: true" in the plan):
471
589
  * - constant literal (no template) → `{ <literal> }`
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  celBaseOfValueType,
10
10
  celTypeOfValueType,
11
+ isCompiledValue,
11
12
  readValueTypeSlot,
12
13
  valueBrandBases,
13
14
  valueTypeOf,
@@ -799,6 +800,18 @@ export function substituteCelFields(
799
800
  mark();
800
801
  return celPlaceholderForSchema(resolved);
801
802
  }
803
+ // The same fact in its third spelling. An expression reaches this walk as a
804
+ // `${{ … }}` string or a `!cel` sentinel BEFORE `precompileDoc`, and as a
805
+ // CompiledValue after — so a caller running under `compile: true` (every
806
+ // `telo run`, unlike `telo check`) handed one to AJV as a plain object, and a
807
+ // slot typed `boolean` rejected a `when:` the author wrote correctly. The
808
+ // kernel's `stripCompiledValues` has always substituted here; missing it on
809
+ // this side made one manifest mean two things depending on which command read
810
+ // it.
811
+ if (isCompiledValue(data)) {
812
+ mark();
813
+ return celPlaceholderForSchema(resolved);
814
+ }
802
815
  if (Array.isArray(data)) {
803
816
  const item = resolveRefIn((resolved.items ?? {}) as Record<string, any>, root, external);
804
817
  return data.map((element, i) =>