@telorun/analyzer 0.70.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/analysis-registry.d.ts.map +1 -1
  2. package/dist/analysis-registry.js +4 -5
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +92 -10
  5. package/dist/call-graph.d.ts.map +1 -1
  6. package/dist/call-graph.js +10 -4
  7. package/dist/catch-scope.d.ts +72 -0
  8. package/dist/catch-scope.d.ts.map +1 -0
  9. package/dist/catch-scope.js +102 -0
  10. package/dist/cel-scope-query.d.ts +14 -0
  11. package/dist/cel-scope-query.d.ts.map +1 -1
  12. package/dist/cel-scope-query.js +36 -6
  13. package/dist/definition-registry.d.ts.map +1 -1
  14. package/dist/definition-registry.js +3 -4
  15. package/dist/deprecation.d.ts +21 -0
  16. package/dist/deprecation.d.ts.map +1 -0
  17. package/dist/deprecation.js +26 -0
  18. package/dist/flatten-for-analyzer.d.ts +2 -2
  19. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  20. package/dist/flatten-for-analyzer.js +1 -1
  21. package/dist/index.d.ts +5 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -2
  24. package/dist/manifest-visitor.d.ts +17 -1
  25. package/dist/manifest-visitor.d.ts.map +1 -1
  26. package/dist/manifest-visitor.js +16 -4
  27. package/dist/migrations/report.d.ts +1 -1
  28. package/dist/migrations/report.d.ts.map +1 -1
  29. package/dist/migrations/report.js +5 -0
  30. package/dist/module-alias-scope.d.ts +65 -0
  31. package/dist/module-alias-scope.d.ts.map +1 -0
  32. package/dist/module-alias-scope.js +25 -0
  33. package/dist/{zone-module-documents.d.ts → module-documents.d.ts} +11 -7
  34. package/dist/module-documents.d.ts.map +1 -0
  35. package/dist/ref-sentinel-target.d.ts +38 -0
  36. package/dist/ref-sentinel-target.d.ts.map +1 -0
  37. package/dist/ref-sentinel-target.js +13 -0
  38. package/dist/ref-slot.d.ts +15 -0
  39. package/dist/ref-slot.d.ts.map +1 -1
  40. package/dist/ref-slot.js +7 -0
  41. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  42. package/dist/resolve-schema-type-refs.js +2 -1
  43. package/dist/resolve-throws-union.d.ts +47 -2
  44. package/dist/resolve-throws-union.d.ts.map +1 -1
  45. package/dist/resolve-throws-union.js +199 -20
  46. package/dist/resolve-zone-requirements.d.ts +3 -3
  47. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  48. package/dist/resolve-zone-requirements.js +12 -12
  49. package/dist/schema-compat.d.ts.map +1 -1
  50. package/dist/schema-compat.js +13 -1
  51. package/dist/schema-error-report.d.ts.map +1 -1
  52. package/dist/schema-error-report.js +48 -4
  53. package/dist/schema-keywords.d.ts.map +1 -1
  54. package/dist/schema-keywords.js +3 -1
  55. package/dist/schema-walk.d.ts +27 -0
  56. package/dist/schema-walk.d.ts.map +1 -1
  57. package/dist/schema-walk.js +44 -0
  58. package/dist/telo-version.d.ts +1 -1
  59. package/dist/telo-version.js +1 -1
  60. package/dist/template-body.d.ts.map +1 -1
  61. package/dist/template-body.js +2 -4
  62. package/dist/types.d.ts +20 -3
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js +11 -0
  65. package/dist/validate-identifier-names.d.ts +2 -2
  66. package/dist/validate-identifier-names.d.ts.map +1 -1
  67. package/dist/validate-identifier-names.js +22 -7
  68. package/dist/validate-invocation-contract.d.ts +5 -0
  69. package/dist/validate-invocation-contract.d.ts.map +1 -1
  70. package/dist/validate-invocation-contract.js +114 -3
  71. package/dist/validate-logging.d.ts.map +1 -1
  72. package/dist/validate-logging.js +2 -2
  73. package/dist/validate-ref-slots.d.ts +1 -1
  74. package/dist/validate-ref-slots.d.ts.map +1 -1
  75. package/dist/validate-ref-slots.js +34 -0
  76. package/dist/validate-references.d.ts +16 -5
  77. package/dist/validate-references.d.ts.map +1 -1
  78. package/dist/validate-references.js +57 -15
  79. package/dist/validate-resource-inputs.d.ts +1 -26
  80. package/dist/validate-resource-inputs.d.ts.map +1 -1
  81. package/dist/validate-resource-inputs.js +12 -2
  82. package/dist/validate-schema-type-refs.d.ts.map +1 -1
  83. package/dist/validate-schema-type-refs.js +2 -1
  84. package/dist/validate-throws-coverage.d.ts +5 -1
  85. package/dist/validate-throws-coverage.d.ts.map +1 -1
  86. package/dist/validate-throws-coverage.js +241 -86
  87. package/package.json +3 -3
  88. package/src/analysis-registry.ts +4 -5
  89. package/src/analyzer.ts +115 -11
  90. package/src/call-graph.ts +9 -3
  91. package/src/catch-scope.ts +157 -0
  92. package/src/cel-scope-query.ts +45 -8
  93. package/src/definition-registry.ts +3 -6
  94. package/src/deprecation.ts +36 -0
  95. package/src/flatten-for-analyzer.ts +3 -3
  96. package/src/index.ts +10 -3
  97. package/src/manifest-visitor.ts +30 -5
  98. package/src/migrations/report.ts +5 -1
  99. package/src/module-alias-scope.ts +94 -0
  100. package/src/{zone-module-documents.ts → module-documents.ts} +10 -6
  101. package/src/ref-sentinel-target.ts +46 -0
  102. package/src/ref-slot.ts +19 -0
  103. package/src/resolve-schema-type-refs.ts +2 -1
  104. package/src/resolve-throws-union.ts +253 -20
  105. package/src/resolve-zone-requirements.ts +14 -14
  106. package/src/schema-compat.ts +13 -0
  107. package/src/schema-error-report.ts +50 -6
  108. package/src/schema-keywords.ts +4 -1
  109. package/src/schema-walk.ts +56 -0
  110. package/src/telo-version.ts +1 -1
  111. package/src/template-body.ts +2 -5
  112. package/src/types.ts +21 -3
  113. package/src/validate-identifier-names.ts +28 -9
  114. package/src/validate-invocation-contract.ts +128 -2
  115. package/src/validate-logging.ts +2 -3
  116. package/src/validate-ref-slots.ts +41 -1
  117. package/src/validate-references.ts +74 -14
  118. package/src/validate-resource-inputs.ts +26 -3
  119. package/src/validate-schema-type-refs.ts +2 -1
  120. package/src/validate-throws-coverage.ts +344 -92
  121. package/dist/zone-module-documents.d.ts.map +0 -1
  122. /package/dist/{zone-module-documents.js → module-documents.js} +0 -0
@@ -1,8 +1,12 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { isTaggedSentinel } from "@telorun/templating";
3
3
  import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js";
4
+ import { resolveScopedName } from "./call-graph.js";
5
+ import { refSentinelTarget, type RefSentinelTarget } from "./ref-sentinel-target.js";
4
6
  import type { DefinitionRegistry } from "./definition-registry.js";
7
+ import { possibleUses, readRefSlot, transfersControl, type RefSlot } from "./ref-slot.js";
5
8
  import { readStepSlot } from "./step-slot.js";
9
+ import { forEachDrivenSlot } from "./schema-walk.js";
6
10
 
7
11
  export interface ThrowsCodeMeta {
8
12
  data?: Record<string, any>;
@@ -40,6 +44,23 @@ export interface ResolveCtx {
40
44
  aliasesByModule: Map<string, AliasResolver>;
41
45
  /** The consumer/root module names; resources owned by these resolve against `aliases`. */
42
46
  rootModules: Set<string>;
47
+ /**
48
+ * Every imported library's FULL manifest list, keyed by module name.
49
+ *
50
+ * A consumer's flat set holds a library's EXPORTED instances and nothing else,
51
+ * so the siblings an exported entry point invokes are not in it. Without this
52
+ * the walk stops at the first such hop, and the difference is not academic: a
53
+ * library whose entry point raises its own code through an internal guard
54
+ * presented an empty union to its consumer, which then had its `catches:`
55
+ * rejected for the very code the entry point documents.
56
+ *
57
+ * Consulted only as a FALLBACK, after the flat set — the flat set is what the
58
+ * consumer's own resources resolve against, and a library-internal name must
59
+ * never shadow one of them.
60
+ */
61
+ moduleManifests: Map<string, ResourceManifest[]>;
62
+ /** Keyed `<module>\0<name>`: resource names are module-scoped, so two
63
+ * libraries each declaring a `query` are two different unions. */
43
64
  memo: Map<string, ThrowsUnion>;
44
65
  inProgress: Set<string>;
45
66
  }
@@ -50,6 +71,7 @@ export function createResolveCtx(
50
71
  aliases: AliasResolver,
51
72
  aliasesByModule: Map<string, AliasResolver> = new Map(),
52
73
  rootModules: Set<string> = new Set(),
74
+ moduleManifests: Map<string, ResourceManifest[]> = new Map(),
53
75
  ): ResolveCtx {
54
76
  return {
55
77
  allManifests,
@@ -57,11 +79,90 @@ export function createResolveCtx(
57
79
  aliases,
58
80
  aliasesByModule,
59
81
  rootModules,
82
+ moduleManifests,
60
83
  memo: new Map(),
61
84
  inProgress: new Set(),
62
85
  };
63
86
  }
64
87
 
88
+ /**
89
+ * A dispatch target named by a resolved `{kind, name}` ref.
90
+ *
91
+ * The flat set is the scope such a ref was resolved in, so it is asked first;
92
+ * the owning library's own documents are the fallback for a name the flattened
93
+ * view dropped. `kindMatches` applies to BOTH — one function, one rule, or the
94
+ * next caller inherits whichever half it happened to hit.
95
+ */
96
+ function findTarget(
97
+ ctx: ResolveCtx,
98
+ name: string,
99
+ ownerModule: string | undefined,
100
+ kindMatches: (m: ResourceManifest) => boolean,
101
+ ): ResourceManifest | undefined {
102
+ const flat = ctx.allManifests.find((m) => m.metadata?.name === name && kindMatches(m));
103
+ if (flat) return flat;
104
+ if (!ownerModule) return undefined;
105
+ return ctx.moduleManifests
106
+ .get(ownerModule)
107
+ ?.find((m) => m.metadata?.name === name && kindMatches(m));
108
+ }
109
+
110
+ /**
111
+ * The target of a `!ref` that still carries its sentinel — a library-internal
112
+ * reference inside a manifest forwarded into a consumer's flat set, where Phase
113
+ * 2.5 had nothing to resolve it against.
114
+ *
115
+ * **The declaring library is asked FIRST**, and that ordering is the whole rule:
116
+ * a bare name in a library manifest is unambiguously library-internal, so
117
+ * searching the consumer's flat set first let any consumer resource that
118
+ * happened to share the name supply another library's throw union — the same
119
+ * false `{∅}` this branch exists to remove, arrived at from the other side.
120
+ *
121
+ * An ALIAS-qualified source is the cross-module case and is resolved through the
122
+ * declaring module's own alias table, never by dropping the alias and matching
123
+ * the bare name anywhere.
124
+ *
125
+ * Ambiguity resolves to NOTHING rather than to a guess (`resolveScopedName`'s
126
+ * rule), and the caller reads that as unbounded — the safe direction here, since
127
+ * a union that cannot be enumerated must not read as empty.
128
+ */
129
+ function findSentinelTarget(
130
+ ctx: ResolveCtx,
131
+ target: RefSentinelTarget,
132
+ ownerModule: string | undefined,
133
+ ): ResourceManifest | undefined {
134
+ const named = (pool: readonly ResourceManifest[] | undefined): ResourceManifest[] =>
135
+ (pool ?? []).filter((m) => m.metadata?.name === target.name);
136
+
137
+ if (target.alias !== undefined && target.alias !== "Self") {
138
+ // A forwarded export: its module is whatever the DECLARING module aliases
139
+ // that prefix to, and it keeps its export name in the flat set.
140
+ // A root-owned manifest resolves aliases against the global table, which is
141
+ // what `scopeResolverForModule` returns undefined for — so fall back to it
142
+ // rather than reading a root's own alias as unresolvable.
143
+ const resolver = scopeResolverFor(ctx, ownerModule) ?? ctx.aliases;
144
+ const module = resolver.moduleForAlias(target.alias);
145
+ if (!module) return undefined;
146
+ return named(ctx.allManifests).find((m) => declaringModuleOf(m) === module);
147
+ }
148
+
149
+ const own = named(ctx.moduleManifests.get(ownerModule ?? ""));
150
+ if (own.length === 1) return own[0];
151
+ if (own.length > 1) return undefined;
152
+ return resolveScopedName(named(ctx.allManifests), declaringModuleOf, ownerModule);
153
+ }
154
+
155
+ const declaringModuleOf = (m: ResourceManifest): string | undefined =>
156
+ (m.metadata as { module?: string } | undefined)?.module;
157
+
158
+ /** Memo key. Module-scoped, because resource names are. */
159
+ function memoKey(manifest: ResourceManifest): string | undefined {
160
+ const name = manifest.metadata?.name as string | undefined;
161
+ if (!name) return undefined;
162
+ const mod = (manifest.metadata as { module?: string } | undefined)?.module ?? "";
163
+ return `${mod}\0${name}`;
164
+ }
165
+
65
166
  function emptyUnion(): ThrowsUnion {
66
167
  return { codes: new Map(), unbounded: false };
67
168
  }
@@ -114,7 +215,7 @@ export function resolveThrowsUnion(
114
215
  manifest: ResourceManifest,
115
216
  ctx: ResolveCtx,
116
217
  ): ThrowsUnion {
117
- const name = manifest.metadata?.name as string | undefined;
218
+ const name = memoKey(manifest);
118
219
 
119
220
  if (name) {
120
221
  const cached = ctx.memo.get(name);
@@ -164,6 +265,16 @@ export function resolveThrowsUnion(
164
265
  }
165
266
  }
166
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
+ */
167
278
  function resolveInherited(
168
279
  manifest: ResourceManifest,
169
280
  definition: ResourceDefinition,
@@ -185,6 +296,66 @@ function resolveInherited(
185
296
  return result;
186
297
  }
187
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
+
188
359
  function collectStepArrayThrows(
189
360
  steps: unknown[],
190
361
  invokeField: string,
@@ -311,36 +482,62 @@ function resolveStepInvokeThrows(
311
482
  ctx: ResolveCtx,
312
483
  ownerModule: string | undefined,
313
484
  ): ThrowsUnion {
314
- const invokeRef = step[invokeField];
315
- if (!invokeRef || typeof invokeRef !== "object") return emptyUnion();
316
- const invokedKind = invokeRef.kind as string | undefined;
317
- if (!invokedKind) return emptyUnion();
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;
511
+ // A reference that still carries its parse-time sentinel — a library-internal
512
+ // `!ref` inside a manifest forwarded into a consumer's flat set, where Phase
513
+ // 2.5 had nothing to resolve it against. The target is in the declaring
514
+ // library's own documents, so it is looked up there; only a name that is not
515
+ // there either is UNKNOWN, which is unbounded rather than empty. Reading it as
516
+ // empty is what made a library's exported entry point present a `{∅}` union to
517
+ // its consumer and get the consumer's `catches:` rejected for the code the
518
+ // entry point documents.
519
+ if (!invokedKind) {
520
+ const sentinel = refSentinelTarget(ref);
521
+ if (!sentinel) return emptyUnion();
522
+ const target = findSentinelTarget(ctx, sentinel, ownerModule);
523
+ if (target) return resolveThrowsUnion(target, ctx);
524
+ return { codes: new Map(), unbounded: true };
525
+ }
318
526
 
319
527
  // The invoked kind's alias resolves in the OWNER manifest's lexical scope (the
320
- // 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
321
529
  // import resolves against that library, not the consumer.
322
530
  const scopeResolver = scopeResolverFor(ctx, ownerModule);
323
531
  const definition = definitionFor(invokedKind, ctx.defs, ctx.aliases, scopeResolver);
324
532
  if (!definition) return { codes: new Map(), unbounded: true };
325
533
 
326
534
  if (definition.throws?.passthrough) {
327
- return resolvePassthroughAtCallSite(step, enclosingTryCodes);
535
+ return onPassthrough ? onPassthrough() : { codes: new Map(), unbounded: true };
328
536
  }
329
537
 
330
538
  // Named manifest: resolve the full chain (covers transitive inherit).
331
- const invokeName = invokeRef.name as string | undefined;
332
- if (invokeName) {
333
- const scopedInvokedKind = scopeResolver?.resolveKind(invokedKind);
334
- const target = ctx.allManifests.find(
335
- (m) =>
336
- m.metadata?.name === invokeName &&
337
- (m.kind === invokedKind ||
338
- ctx.aliases.resolveKind(m.kind) === invokedKind ||
339
- m.kind === ctx.aliases.resolveKind(invokedKind) ||
340
- (scopedInvokedKind !== undefined && m.kind === scopedInvokedKind)),
341
- );
342
- if (target) return resolveThrowsUnion(target, ctx);
343
- }
539
+ const target = resolveRefManifest(ref, ctx, ownerModule);
540
+ if (target) return resolveThrowsUnion(target, ctx);
344
541
 
345
542
  // Fall back to the definition's own explicit codes. Mark unbounded when the
346
543
  // definition depends on call-site or transitive resolution we couldn't
@@ -351,6 +548,42 @@ function resolveStepInvokeThrows(
351
548
  return { codes, unbounded };
352
549
  }
353
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
+
354
587
  /** Resolve a passthrough-style invocable at a specific call site. Recognised forms
355
588
  * (see "passthrough: true" in the plan):
356
589
  * - constant literal (no template) → `{ <literal> }`
@@ -14,7 +14,7 @@
14
14
  * Browser-safe: no Node built-ins.
15
15
  */
16
16
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
17
- import { isRefSentinel } from "@telorun/templating";
17
+ import { refSentinelTarget } from "./ref-sentinel-target.js";
18
18
  import type { AliasResolver } from "./alias-resolver.js";
19
19
  import {
20
20
  buildCallGraph,
@@ -28,9 +28,10 @@ import {
28
28
  propertySchemas,
29
29
  resolveLocalRef,
30
30
  } from "./manifest-navigation.js";
31
- import type { ZoneModuleDocuments } from "./zone-module-documents.js";
31
+ import type { ModuleDocuments } from "./module-documents.js";
32
32
  import { readProvidesZone, readRequiresZone } from "./zone-slot.js";
33
33
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
34
+ import { moduleAliasScope } from "./module-alias-scope.js";
34
35
 
35
36
  const SOURCE = "telo-analyzer";
36
37
 
@@ -125,7 +126,7 @@ function definitionResolver(
125
126
  aliasesByModule: Map<string, AliasResolver>,
126
127
  ) {
127
128
  return (kind: string, module?: string): ResourceDefinition | undefined => {
128
- const scope = (module ? aliasesByModule.get(module) : undefined) ?? aliases;
129
+ const scope = moduleAliasScope({ module }, aliases, aliasesByModule);
129
130
  const canonical = scope.resolveKind(kind);
130
131
  return defs.resolve(kind) ?? (canonical ? defs.resolve(canonical) : undefined);
131
132
  };
@@ -193,17 +194,16 @@ function schemaNodeAt(
193
194
  * under-approximating direction the whole pass leans on. `Self.` is a local
194
195
  * name written the long way and does resolve.
195
196
  *
196
- * This deliberately differs from `call-graph`'s `refTargetName`, which answers a
197
- * different question (what an EDGE points at, cross-module included, for a graph
198
- * whose consumers tolerate an unresolved target) hence two functions rather
199
- * than one shared helper.
197
+ * This is this pass's REDUCTION over the shared `refSentinelTarget` parse, not a
198
+ * second reading of the tag: `call-graph`'s answers a different question (what
199
+ * an EDGE points at, cross-module included, for a graph whose consumers tolerate
200
+ * an unresolved target) and states its own reduction the same way.
200
201
  */
201
202
  function refName(value: unknown): string | undefined {
202
- if (isRefSentinel(value)) {
203
- const source = value.source;
204
- const dot = source.indexOf(".");
205
- if (dot <= 0) return source;
206
- return source.slice(0, dot) === "Self" ? source.slice(dot + 1) : undefined;
203
+ const sentinel = refSentinelTarget(value);
204
+ if (sentinel) {
205
+ if (sentinel.alias === undefined) return sentinel.name;
206
+ return sentinel.alias === "Self" ? sentinel.name : undefined;
207
207
  }
208
208
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
209
209
  const v = value as Record<string, unknown>;
@@ -712,7 +712,7 @@ export function zoneDocumentsSignature(manifests: readonly ResourceManifest[]):
712
712
  * the projection derive-only, and keep what reaches an exported instance.
713
713
  */
714
714
  export function deriveLibraryExportRequirements(
715
- docs: ZoneModuleDocuments,
715
+ docs: ModuleDocuments,
716
716
  defs: DefinitionRegistry,
717
717
  aliases: AliasResolver,
718
718
  aliasesByModule: Map<string, AliasResolver>,
@@ -745,7 +745,7 @@ export interface ZoneAnalysisArgs {
745
745
  aliases: AliasResolver;
746
746
  aliasesByModule: Map<string, AliasResolver>;
747
747
  rootModules: ReadonlySet<string>;
748
- moduleDocuments?: readonly ZoneModuleDocuments[];
748
+ moduleDocuments?: readonly ModuleDocuments[];
749
749
  cache?: ZoneExportCache;
750
750
  }
751
751
 
@@ -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) =>
@@ -53,10 +53,42 @@ const UNION_KEYWORDS = new Set(["anyOf", "oneOf"]);
53
53
  * further in. These are what make a branch implausible. */
54
54
  const SHAPE_KEYWORDS = new Set(["required", "type", "additionalProperties", "enum", "const"]);
55
55
 
56
+ /** Keywords that exist to DISCRIMINATE, so a mismatch is positive evidence that
57
+ * the value is not this branch — at any depth, not only at the union node.
58
+ *
59
+ * Depth is otherwise the tiebreak, and it inverts exactly here: a branch that
60
+ * agreed on the discriminator and failed one constraint reported at the union
61
+ * node loses to every branch that disagreed about the discriminator one level
62
+ * in. That is how a `capability: Telo.Service` document declaring a forbidden
63
+ * key was reported as `/capability must be equal to constant` — naming neither
64
+ * the key at fault nor a branch the value could ever have been. */
65
+ const DISCRIMINATOR_KEYWORDS = new Set(["const", "enum"]);
66
+
67
+ /** RFC 6901 escapes: `~1` is a literal `/` in the key, `~0` a literal `~`.
68
+ * Decoded wherever a segment is shown or matched, because encoded it is not the
69
+ * key the manifest holds — a content map (`application/json`) anchors nowhere
70
+ * and reads wrong in the sentence. `~0` is expanded LAST, or `~01` would decode
71
+ * to `/` instead of the literal `~1` it encodes. */
72
+ function unescapeSegment(part: string): string {
73
+ return part.replace(/~1/g, "/").replace(/~0/g, "~");
74
+ }
75
+
76
+ /** The whole pointer, segment by segment, for prose that quotes it verbatim. */
77
+ function unescapePointer(pointer: string): string {
78
+ return pointer
79
+ .split("/")
80
+ .map((segment, i) => (i === 0 ? segment : unescapeSegment(segment)))
81
+ .join("/");
82
+ }
83
+
56
84
  /* ------------------------------------------------------------------ prose */
57
85
 
58
86
  export function formatSingleError(err: AjvErrorLike): string {
59
- const p = err.instancePath || "/";
87
+ // Unescaped for the same reason the path is: a reader acting on this sentence
88
+ // needs the key the manifest holds (`application/json`), not its RFC 6901
89
+ // encoding. Reporting one form in `path` and the other in `message` made one
90
+ // diagnostic disagree with itself.
91
+ const p = unescapePointer(err.instancePath || "") || "/";
60
92
  const params = err.params ?? {};
61
93
  switch (err.keyword) {
62
94
  case "additionalProperties":
@@ -67,6 +99,16 @@ export function formatSingleError(err: AjvErrorLike): string {
67
99
  return `${p} ${err.message ?? "is invalid"} (${(params.allowedValues as unknown[])?.join(" | ")})`;
68
100
  case "type":
69
101
  return `${p} must be ${params.type}${describeActual(err)}`;
102
+ // A `false` schema at a property is how a branch forbids a key it otherwise
103
+ // declares. AJV's own text ("boolean schema is false") describes the schema
104
+ // rather than the value, and the path is the only part a reader can act on.
105
+ case "false schema":
106
+ return `${p} is not allowed here`;
107
+ // `not:` says a forbidden shape matched, and AJV reports nothing about the
108
+ // inner schema — so the honest message says where, and no more. A branch
109
+ // that wants to name the key writes `properties: { <key>: false }` instead.
110
+ case "not":
111
+ return `${p} matches a shape that is not allowed here`;
70
112
  default:
71
113
  return `${p} ${err.message ?? "is invalid"}`;
72
114
  }
@@ -130,11 +172,13 @@ function describeAlternatives(errors: AjvErrorLike[], unionInstancePath: string)
130
172
  }
131
173
 
132
174
  /** Is this branch a plausible reading of the value — does it accept the value's
133
- * shape at the union node itself, and only disagree further in? */
175
+ * shape at the union node itself, and agree with every discriminator it pins? */
134
176
  function isPlausible(errors: AjvErrorLike[], unionInstancePath: string): boolean {
135
- return !errors.some(
136
- (e) => (e.instancePath || "") === unionInstancePath && SHAPE_KEYWORDS.has(e.keyword ?? ""),
137
- );
177
+ return !errors.some((e) => {
178
+ const keyword = e.keyword ?? "";
179
+ if (DISCRIMINATOR_KEYWORDS.has(keyword)) return true;
180
+ return (e.instancePath || "") === unionInstancePath && SHAPE_KEYWORDS.has(keyword);
181
+ });
138
182
  }
139
183
 
140
184
  /**
@@ -392,7 +436,7 @@ export function ajvErrorToPath(err: AjvErrorLike): string {
392
436
  let result = "";
393
437
  for (const part of parts) {
394
438
  if (/^\d+$/.test(part)) result += `[${part}]`;
395
- else result += result ? `.${part}` : part;
439
+ else result += result ? `.${unescapeSegment(part)}` : unescapeSegment(part);
396
440
  }
397
441
  if (err.keyword === "required" && err.params?.missingProperty) {
398
442
  const missing = err.params.missingProperty as string;
@@ -386,7 +386,10 @@ export const TELO_SCHEMA_ANNOTATIONS: Record<
386
386
  },
387
387
  "x-telo-catches-for": {
388
388
  title: "Catches for",
389
- description: "Names the field whose failures this branch list handles.",
389
+ description:
390
+ "Whose failures this catch list renders: a sibling field naming the handler, " +
391
+ "or the EMPTY string for everything this resource drives — a scope-level list, " +
392
+ "which owes coverage of nothing itself and answers for every site it encloses.",
390
393
  type: "string",
391
394
  },
392
395
  };
@@ -9,6 +9,8 @@
9
9
  * scope rule is consumed by the IDE, which must not pull the pass in behind it.
10
10
  */
11
11
  import { MANIFEST_SCHEMA_URI, ManifestRootSchema } from "./manifest-schemas.js";
12
+ import { readRefSlot, type RefSlot } from "./ref-slot.js";
13
+ import { readStepSlot, type StepSlot } from "./step-slot.js";
12
14
 
13
15
  /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
14
16
  * Non-refs and unresolved refs pass through unchanged. */
@@ -142,3 +144,57 @@ export function walkStepArray(
142
144
  }
143
145
  });
144
146
  }
147
+ /** A slot through which a resource drives another. */
148
+ export type DrivenSlot =
149
+ | { kind: "step"; slot: StepSlot; data: unknown[]; path: string }
150
+ | { kind: "ref"; slot: RefSlot; data: unknown; path: string };
151
+
152
+ /**
153
+ * Every slot of one resource through which it drives another, schema and data in
154
+ * tandem.
155
+ *
156
+ * One traversal with two consumers — the inherited union here, and the catch
157
+ * scope enclosure in `validate-throws-coverage.ts` — because both ask the same
158
+ * structural question and two copies would eventually disagree about where the
159
+ * walk stops. It terminates on the manifest's own depth, and it stops AT a step
160
+ * slot (that traversal owns everything below it, `try`/`catch` subtraction
161
+ * included) and AT a reference slot (a resolved ref is a leaf, `{kind, name}`,
162
+ * with nothing beneath it to visit).
163
+ */
164
+ export function forEachDrivenSlot(
165
+ schema: unknown,
166
+ data: unknown,
167
+ visit: (slot: DrivenSlot) => void,
168
+ path = "",
169
+ ): void {
170
+ if (!schema || typeof schema !== "object" || data === undefined || data === null) return;
171
+ const node = schema as Record<string, any>;
172
+
173
+ const stepSlot = readStepSlot(node);
174
+ if (stepSlot) {
175
+ if (Array.isArray(data)) visit({ kind: "step", slot: stepSlot, data, path });
176
+ return;
177
+ }
178
+
179
+ const refSlot = readRefSlot(node);
180
+ if (refSlot) {
181
+ visit({ kind: "ref", slot: refSlot, data, path });
182
+ return;
183
+ }
184
+
185
+ const props = node.properties as Record<string, any> | undefined;
186
+ if (props && typeof data === "object" && !Array.isArray(data)) {
187
+ const obj = data as Record<string, unknown>;
188
+ for (const [key, propSchema] of Object.entries(props)) {
189
+ if (obj[key] === undefined) continue;
190
+ forEachDrivenSlot(propSchema, obj[key], visit, path ? `${path}.${key}` : key);
191
+ }
192
+ }
193
+
194
+ if (node.items && Array.isArray(data)) {
195
+ for (const [i, item] of data.entries()) {
196
+ forEachDrivenSlot(node.items, item, visit, `${path}[${i}]`);
197
+ }
198
+ }
199
+ }
200
+
@@ -6,4 +6,4 @@
6
6
  // written against, and every kernel in every language reports the same scale.
7
7
 
8
8
  /** The surface generation this analyzer implements. */
9
- export const TELO_SURFACE_VERSION = "0.86.0";
9
+ export const TELO_SURFACE_VERSION = "0.88.0";
@@ -2,6 +2,7 @@ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
4
  import type { ModuleScopes } from "./alias-resolver.js";
5
+ import { moduleAliasScope } from "./module-alias-scope.js";
5
6
 
6
7
  /**
7
8
  * A `Telo.Definition`'s `resources:` entries — the bodies a kind writes for
@@ -47,11 +48,7 @@ export function templateBodies(
47
48
  // A nested kind is written through the alias scope of the module that DECLARED
48
49
  // the definition, never the consumer's — the same rule every other kind
49
50
  // resolution in a forwarded manifest follows.
50
- const ownModule = (m.metadata as { module?: string } | undefined)?.module;
51
- const scope =
52
- (ownModule && scopes && !scopes.rootModules.has(ownModule)
53
- ? scopes.aliasesByModule.get(ownModule)
54
- : undefined) ?? aliases;
51
+ const scope = moduleAliasScope(m.metadata, aliases, scopes?.aliasesByModule);
55
52
 
56
53
  const out: TemplateBody[] = [];
57
54
  for (let i = 0; i < bodies.length; i++) {