@telorun/analyzer 0.71.0 → 0.73.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 (139) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +92 -15
  3. package/dist/builtins.d.ts.map +1 -1
  4. package/dist/builtins.js +71 -97
  5. package/dist/catch-scope.d.ts +72 -0
  6. package/dist/catch-scope.d.ts.map +1 -0
  7. package/dist/catch-scope.js +102 -0
  8. package/dist/cel-scope-query.d.ts.map +1 -1
  9. package/dist/cel-scope-query.js +32 -11
  10. package/dist/cel-scope.d.ts.map +1 -1
  11. package/dist/cel-scope.js +1 -0
  12. package/dist/deprecation.d.ts +21 -0
  13. package/dist/deprecation.d.ts.map +1 -0
  14. package/dist/deprecation.js +26 -0
  15. package/dist/eval-paths.d.ts +36 -5
  16. package/dist/eval-paths.d.ts.map +1 -1
  17. package/dist/eval-paths.js +47 -6
  18. package/dist/extends-resolution.d.ts +12 -0
  19. package/dist/extends-resolution.d.ts.map +1 -1
  20. package/dist/extends-resolution.js +23 -0
  21. package/dist/index.d.ts +4 -2
  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 +5 -1
  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/nearest-name.d.ts +22 -0
  31. package/dist/nearest-name.d.ts.map +1 -0
  32. package/dist/nearest-name.js +43 -0
  33. package/dist/ref-slot.d.ts +15 -0
  34. package/dist/ref-slot.d.ts.map +1 -1
  35. package/dist/ref-slot.js +7 -0
  36. package/dist/release/destinations.d.ts +59 -0
  37. package/dist/release/destinations.d.ts.map +1 -0
  38. package/dist/release/destinations.js +75 -0
  39. package/dist/release/index.d.ts +6 -2
  40. package/dist/release/index.d.ts.map +1 -1
  41. package/dist/release/index.js +3 -1
  42. package/dist/release/ledger.d.ts +20 -11
  43. package/dist/release/ledger.d.ts.map +1 -1
  44. package/dist/release/ledger.js +43 -14
  45. package/dist/release/release-plan.d.ts +3 -2
  46. package/dist/release/release-plan.d.ts.map +1 -1
  47. package/dist/release/release-plan.js +12 -19
  48. package/dist/release/workspace-config.d.ts +114 -34
  49. package/dist/release/workspace-config.d.ts.map +1 -1
  50. package/dist/release/workspace-config.js +322 -38
  51. package/dist/release/workspace-schema.d.ts +41 -0
  52. package/dist/release/workspace-schema.d.ts.map +1 -0
  53. package/dist/release/workspace-schema.js +77 -0
  54. package/dist/resolve-throws-union.d.ts +29 -1
  55. package/dist/resolve-throws-union.d.ts.map +1 -1
  56. package/dist/resolve-throws-union.js +111 -16
  57. package/dist/schema-compat.d.ts.map +1 -1
  58. package/dist/schema-compat.js +13 -1
  59. package/dist/schema-error-report.d.ts +7 -0
  60. package/dist/schema-error-report.d.ts.map +1 -1
  61. package/dist/schema-error-report.js +57 -8
  62. package/dist/schema-keywords.d.ts.map +1 -1
  63. package/dist/schema-keywords.js +3 -1
  64. package/dist/schema-walk.d.ts +27 -0
  65. package/dist/schema-walk.d.ts.map +1 -1
  66. package/dist/schema-walk.js +44 -0
  67. package/dist/telo-version.d.ts +1 -1
  68. package/dist/telo-version.js +1 -1
  69. package/dist/template-body.d.ts +10 -0
  70. package/dist/template-body.d.ts.map +1 -1
  71. package/dist/template-body.js +25 -0
  72. package/dist/types.d.ts +17 -0
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/types.js +11 -0
  75. package/dist/validate-cel-context.d.ts +23 -8
  76. package/dist/validate-cel-context.d.ts.map +1 -1
  77. package/dist/validate-cel-context.js +48 -16
  78. package/dist/validate-exports.d.ts +28 -0
  79. package/dist/validate-exports.d.ts.map +1 -0
  80. package/dist/validate-exports.js +146 -0
  81. package/dist/validate-extends.d.ts.map +1 -1
  82. package/dist/validate-extends.js +37 -1
  83. package/dist/validate-identifier-names.d.ts +2 -2
  84. package/dist/validate-identifier-names.d.ts.map +1 -1
  85. package/dist/validate-identifier-names.js +22 -7
  86. package/dist/validate-provider-coherence.d.ts +4 -8
  87. package/dist/validate-provider-coherence.d.ts.map +1 -1
  88. package/dist/validate-provider-coherence.js +12 -123
  89. package/dist/validate-ref-slots.d.ts +1 -1
  90. package/dist/validate-ref-slots.d.ts.map +1 -1
  91. package/dist/validate-ref-slots.js +34 -0
  92. package/dist/validate-references.d.ts.map +1 -1
  93. package/dist/validate-references.js +168 -63
  94. package/dist/validate-template-body.d.ts +36 -0
  95. package/dist/validate-template-body.d.ts.map +1 -0
  96. package/dist/validate-template-body.js +246 -0
  97. package/dist/validate-throws-coverage.d.ts.map +1 -1
  98. package/dist/validate-throws-coverage.js +236 -85
  99. package/package.json +3 -2
  100. package/src/analyzer.ts +118 -12
  101. package/src/builtins.ts +85 -97
  102. package/src/catch-scope.ts +157 -0
  103. package/src/cel-scope-query.ts +28 -9
  104. package/src/cel-scope.ts +1 -0
  105. package/src/deprecation.ts +36 -0
  106. package/src/eval-paths.ts +53 -6
  107. package/src/extends-resolution.ts +24 -0
  108. package/src/index.ts +9 -1
  109. package/src/manifest-visitor.ts +19 -2
  110. package/src/migrations/report.ts +5 -1
  111. package/src/nearest-name.ts +47 -0
  112. package/src/ref-slot.ts +19 -0
  113. package/src/release/destinations.ts +105 -0
  114. package/src/release/index.ts +33 -2
  115. package/src/release/ledger.ts +51 -21
  116. package/src/release/release-plan.ts +16 -26
  117. package/src/release/workspace-config.ts +483 -47
  118. package/src/release/workspace-schema.ts +99 -0
  119. package/src/resolve-throws-union.ts +139 -21
  120. package/src/schema-compat.ts +13 -0
  121. package/src/schema-error-report.ts +66 -10
  122. package/src/schema-keywords.ts +4 -1
  123. package/src/schema-walk.ts +56 -0
  124. package/src/telo-version.ts +1 -1
  125. package/src/template-body.ts +25 -0
  126. package/src/types.ts +18 -0
  127. package/src/validate-cel-context.ts +57 -16
  128. package/src/validate-exports.ts +185 -0
  129. package/src/validate-extends.ts +43 -0
  130. package/src/validate-identifier-names.ts +28 -9
  131. package/src/validate-provider-coherence.ts +17 -127
  132. package/src/validate-ref-slots.ts +41 -1
  133. package/src/validate-references.ts +174 -63
  134. package/src/validate-template-body.ts +274 -0
  135. package/src/validate-throws-coverage.ts +333 -92
  136. package/dist/validate-template-dispatch.d.ts +0 -27
  137. package/dist/validate-template-dispatch.d.ts.map +0 -1
  138. package/dist/validate-template-dispatch.js +0 -95
  139. package/src/validate-template-dispatch.ts +0 -99
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The shape of `telo-workspace.yaml`, declared as data.
3
+ *
4
+ * One declaration read by two consumers — the strict half that reports what a
5
+ * marker got wrong, and the editor's completion list. Two hand-maintained key
6
+ * lists drift, and the drift is silent in the worst direction: an editor
7
+ * offering a key the checker rejects.
8
+ *
9
+ * Written as JSON Schema because that is what the shape *is*, and because the
10
+ * `description` a completion item shows and the type a diagnostic names are the
11
+ * same two facts a schema already carries. It is not compiled by AJV — the
12
+ * strict half walks it, which is what lets a wrong value say `'registry' must be
13
+ * the publish destination base, as a string` instead of a keyword trace.
14
+ */
15
+
16
+ export interface WorkspaceKeySchema {
17
+ readonly type: "string" | "string[]" | "entry[]";
18
+ readonly description: string;
19
+ /** Values worth offering at this key when nothing better is known. */
20
+ readonly examples?: readonly string[];
21
+ }
22
+
23
+ export interface WorkspaceBlockSchema {
24
+ readonly description: string;
25
+ readonly properties: Readonly<Record<string, WorkspaceKeySchema>>;
26
+ }
27
+
28
+ /**
29
+ * Every block, and every key in it, is optional — a marker whose whole content
30
+ * is comments is valid, and is what a runner seeds for the cache anchor alone.
31
+ */
32
+ export const WORKSPACE_SCHEMA: Readonly<Record<string, WorkspaceBlockSchema>> = {
33
+ release: {
34
+ description: "How `telo release` behaves: where modules are, where they publish, and which of their paths are not release-relevant.",
35
+ properties: {
36
+ registry: {
37
+ type: "string",
38
+ description:
39
+ "Publish destination base. A module's ref is `<registry>/<its own directory name>`. Omitted: --registry, then TELO_OCI_REGISTRY, then the base each module's own ledger entry recorded.",
40
+ examples: ["oci://ghcr.io/telorun"],
41
+ },
42
+ ignore: {
43
+ type: "string[]",
44
+ description:
45
+ "Paths under a module whose changes are not release-relevant, so no changelog fragment is asked for. Module-relative, gitignore-style. Declaring it replaces the built-in default; [] ignores nothing.",
46
+ examples: ["**/tests/**", "**/docs/**", "**/plans/**", "**/README.md", "**/CHANGELOG.md"],
47
+ },
48
+ modules: {
49
+ type: "entry[]",
50
+ description:
51
+ "The subtrees that may hold modules — a place to look, never a module: what makes a directory a module is its telo.yaml. Workspace-relative, gitignore-style, last match wins. A bare string is an entry with no overrides.",
52
+ },
53
+ },
54
+ },
55
+ env: {
56
+ description: "How `telo run` resolves a manifest's environment.",
57
+ properties: {
58
+ roots: {
59
+ type: "string[]",
60
+ description:
61
+ "How far up the .env walk may climb. Workspace-relative, gitignore-style, matched against each ancestor directory; the nearest match stops the walk. Omitted: the walk stops at this file.",
62
+ },
63
+ files: {
64
+ type: "string[]",
65
+ description:
66
+ "Which files are collected in each directory, later winning within one directory. Filenames only — a / or a glob is an error. Declaring it replaces the default; [] collects none.",
67
+ examples: [".env", ".env.local"],
68
+ },
69
+ },
70
+ },
71
+ };
72
+
73
+ /** Keys a `release.modules` entry may carry in its object form. `path` is the
74
+ * pattern; the rest override the block's own keys, key-wise. */
75
+ export const MODULE_ENTRY_KEYS: Readonly<Record<string, WorkspaceKeySchema>> = {
76
+ path: {
77
+ type: "string",
78
+ description: "The gitignore-style pattern this entry matches modules with.",
79
+ },
80
+ registry: WORKSPACE_SCHEMA.release.properties.registry,
81
+ ignore: WORKSPACE_SCHEMA.release.properties.ignore,
82
+ };
83
+
84
+ export const WORKSPACE_BLOCKS: readonly string[] = Object.keys(WORKSPACE_SCHEMA);
85
+
86
+ /** Built-in when `release.ignore` is absent. Every pattern carries `**\/` — the
87
+ * anchoring at a module root is the defect this replaced, so anchoring some and
88
+ * not others would reproduce it partially. */
89
+ export const DEFAULT_RELEASE_IGNORE: readonly string[] = [
90
+ "**/tests/**",
91
+ "**/docs/**",
92
+ "**/plans/**",
93
+ "**/README.md",
94
+ "**/CHANGELOG.md",
95
+ ];
96
+
97
+ /** Built-in when `env.files` is absent — the pair the walk hardcoded before it
98
+ * was authorable, in the order it applied them. */
99
+ export const DEFAULT_ENV_FILES: readonly string[] = [".env", ".env.local"];
@@ -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) =>
@@ -44,6 +44,13 @@ export interface SchemaIssue {
44
44
  message: string;
45
45
  /** Dotted path to the field (e.g. "config.handler"). Empty string means root. */
46
46
  path: string;
47
+ /** The AJV keyword that produced it, so a consumer can key on WHAT failed
48
+ * rather than on how the sentence reads. Prose is the renderer's to change;
49
+ * a caller matching on it breaks silently when it does, and mis-fires on any
50
+ * other issue whose text happens to quote the same name. */
51
+ keyword?: string;
52
+ /** For `required`, the property that is missing. */
53
+ missingProperty?: string;
47
54
  }
48
55
 
49
56
  const UNION_KEYWORDS = new Set(["anyOf", "oneOf"]);
@@ -53,10 +60,42 @@ const UNION_KEYWORDS = new Set(["anyOf", "oneOf"]);
53
60
  * further in. These are what make a branch implausible. */
54
61
  const SHAPE_KEYWORDS = new Set(["required", "type", "additionalProperties", "enum", "const"]);
55
62
 
63
+ /** Keywords that exist to DISCRIMINATE, so a mismatch is positive evidence that
64
+ * the value is not this branch — at any depth, not only at the union node.
65
+ *
66
+ * Depth is otherwise the tiebreak, and it inverts exactly here: a branch that
67
+ * agreed on the discriminator and failed one constraint reported at the union
68
+ * node loses to every branch that disagreed about the discriminator one level
69
+ * in. That is how a `capability: Telo.Service` document declaring a forbidden
70
+ * key was reported as `/capability must be equal to constant` — naming neither
71
+ * the key at fault nor a branch the value could ever have been. */
72
+ const DISCRIMINATOR_KEYWORDS = new Set(["const", "enum"]);
73
+
74
+ /** RFC 6901 escapes: `~1` is a literal `/` in the key, `~0` a literal `~`.
75
+ * Decoded wherever a segment is shown or matched, because encoded it is not the
76
+ * key the manifest holds — a content map (`application/json`) anchors nowhere
77
+ * and reads wrong in the sentence. `~0` is expanded LAST, or `~01` would decode
78
+ * to `/` instead of the literal `~1` it encodes. */
79
+ function unescapeSegment(part: string): string {
80
+ return part.replace(/~1/g, "/").replace(/~0/g, "~");
81
+ }
82
+
83
+ /** The whole pointer, segment by segment, for prose that quotes it verbatim. */
84
+ function unescapePointer(pointer: string): string {
85
+ return pointer
86
+ .split("/")
87
+ .map((segment, i) => (i === 0 ? segment : unescapeSegment(segment)))
88
+ .join("/");
89
+ }
90
+
56
91
  /* ------------------------------------------------------------------ prose */
57
92
 
58
93
  export function formatSingleError(err: AjvErrorLike): string {
59
- const p = err.instancePath || "/";
94
+ // Unescaped for the same reason the path is: a reader acting on this sentence
95
+ // needs the key the manifest holds (`application/json`), not its RFC 6901
96
+ // encoding. Reporting one form in `path` and the other in `message` made one
97
+ // diagnostic disagree with itself.
98
+ const p = unescapePointer(err.instancePath || "") || "/";
60
99
  const params = err.params ?? {};
61
100
  switch (err.keyword) {
62
101
  case "additionalProperties":
@@ -67,6 +106,16 @@ export function formatSingleError(err: AjvErrorLike): string {
67
106
  return `${p} ${err.message ?? "is invalid"} (${(params.allowedValues as unknown[])?.join(" | ")})`;
68
107
  case "type":
69
108
  return `${p} must be ${params.type}${describeActual(err)}`;
109
+ // A `false` schema at a property is how a branch forbids a key it otherwise
110
+ // declares. AJV's own text ("boolean schema is false") describes the schema
111
+ // rather than the value, and the path is the only part a reader can act on.
112
+ case "false schema":
113
+ return `${p} is not allowed here`;
114
+ // `not:` says a forbidden shape matched, and AJV reports nothing about the
115
+ // inner schema — so the honest message says where, and no more. A branch
116
+ // that wants to name the key writes `properties: { <key>: false }` instead.
117
+ case "not":
118
+ return `${p} matches a shape that is not allowed here`;
70
119
  default:
71
120
  return `${p} ${err.message ?? "is invalid"}`;
72
121
  }
@@ -130,11 +179,13 @@ function describeAlternatives(errors: AjvErrorLike[], unionInstancePath: string)
130
179
  }
131
180
 
132
181
  /** 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? */
182
+ * shape at the union node itself, and agree with every discriminator it pins? */
134
183
  function isPlausible(errors: AjvErrorLike[], unionInstancePath: string): boolean {
135
- return !errors.some(
136
- (e) => (e.instancePath || "") === unionInstancePath && SHAPE_KEYWORDS.has(e.keyword ?? ""),
137
- );
184
+ return !errors.some((e) => {
185
+ const keyword = e.keyword ?? "";
186
+ if (DISCRIMINATOR_KEYWORDS.has(keyword)) return true;
187
+ return (e.instancePath || "") === unionInstancePath && SHAPE_KEYWORDS.has(keyword);
188
+ });
138
189
  }
139
190
 
140
191
  /**
@@ -392,7 +443,7 @@ export function ajvErrorToPath(err: AjvErrorLike): string {
392
443
  let result = "";
393
444
  for (const part of parts) {
394
445
  if (/^\d+$/.test(part)) result += `[${part}]`;
395
- else result += result ? `.${part}` : part;
446
+ else result += result ? `.${unescapeSegment(part)}` : unescapeSegment(part);
396
447
  }
397
448
  if (err.keyword === "required" && err.params?.missingProperty) {
398
449
  const missing = err.params.missingProperty as string;
@@ -403,10 +454,15 @@ export function ajvErrorToPath(err: AjvErrorLike): string {
403
454
 
404
455
  /** Reduced, path-anchored issues — what a diagnostic list is built from. */
405
456
  export function schemaIssues(errors: AjvErrorLike[] | null | undefined): SchemaIssue[] {
406
- return reduceSchemaErrors(errors).map((err) => ({
407
- message: formatSingleError(err),
408
- path: ajvErrorToPath(err),
409
- }));
457
+ return reduceSchemaErrors(errors).map((err) => {
458
+ const missing = (err.params ?? {}).missingProperty;
459
+ return {
460
+ message: formatSingleError(err),
461
+ path: ajvErrorToPath(err),
462
+ ...(err.keyword ? { keyword: err.keyword } : {}),
463
+ ...(typeof missing === "string" ? { missingProperty: missing } : {}),
464
+ };
465
+ });
410
466
  }
411
467
 
412
468
  /** Reduced, rendered as one sentence — what a thrown runtime error carries. */
@@ -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.87.0";
9
+ export const TELO_SURFACE_VERSION = "0.89.0";
@@ -1,4 +1,5 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
+ import { isRefSentinel } from "@telorun/templating";
2
3
  import type { AliasResolver } from "./alias-resolver.js";
3
4
  import type { DefinitionRegistry } from "./definition-registry.js";
4
5
  import type { ModuleScopes } from "./alias-resolver.js";
@@ -68,6 +69,30 @@ export function templateBodies(
68
69
  return out;
69
70
  }
70
71
 
72
+ /** What a definition's dispatch slot names: the `resources:` entry a `!ref` at
73
+ * `slot` (`invoke` / `provide` / `run` / `mount`) resolves to, with its kind.
74
+ * The entry is matched by its LITERAL `metadata.name`; a slot holding anything
75
+ * but a `!ref`, or naming no entry, resolves to nothing — `validate-template-body`
76
+ * is what reports either. `Self.<entry>` is the explicit self-qualifier. */
77
+ export function dispatchTargetOf(
78
+ definition: Record<string, any>,
79
+ slot: string,
80
+ ): { entry: Record<string, any> | undefined; kind: string; name: string } | undefined {
81
+ const value = definition[slot];
82
+ if (!isRefSentinel(value)) return undefined;
83
+ const source = value.source;
84
+ const name = source.startsWith("Self.") ? source.slice("Self.".length) : source;
85
+ const bodies = definition.resources;
86
+ if (!Array.isArray(bodies)) return undefined;
87
+ for (const body of bodies) {
88
+ if (!body || typeof body !== "object") continue;
89
+ const entry = body as Record<string, any>;
90
+ if (entry.metadata?.name !== name || typeof entry.kind !== "string") continue;
91
+ return { entry, kind: entry.kind, name };
92
+ }
93
+ return undefined;
94
+ }
95
+
71
96
  /** True when a CEL path lies at or inside a body's own subtree. */
72
97
  export function pathInBody(path: string, prefix: string): boolean {
73
98
  return (
package/src/types.ts CHANGED
@@ -11,6 +11,19 @@ export const DiagnosticSeverity = {
11
11
  } as const;
12
12
  export type DiagnosticSeverity = (typeof DiagnosticSeverity)[keyof typeof DiagnosticSeverity];
13
13
 
14
+ /** Matches LSP DiagnosticTag values exactly.
15
+ * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticTag
16
+ *
17
+ * Declared whole rather than trimmed to what Telo emits today, for the reason
18
+ * the severity ladder above is: it is someone else's closed vocabulary, and a
19
+ * partial copy of one is what drifts. `Unnecessary` (rendered faded) has an
20
+ * obvious future consumer in the unused-declaration checks. */
21
+ export const DiagnosticTag = {
22
+ Unnecessary: 1,
23
+ Deprecated: 2,
24
+ } as const;
25
+ export type DiagnosticTag = (typeof DiagnosticTag)[keyof typeof DiagnosticTag];
26
+
14
27
  /** Default entry-point filename when a directory is given instead of a file. */
15
28
  export const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
16
29
 
@@ -70,6 +83,11 @@ export interface AnalysisDiagnostic {
70
83
  /** e.g. "telo-analyzer" */
71
84
  source?: string;
72
85
  message: string;
86
+ /** What KIND of thing this is, orthogonal to how loudly it asks to be dealt
87
+ * with. A deprecation is warning-grade *and* a deprecation; severity alone
88
+ * can only say the first, which is why an editor renders a deprecated symbol
89
+ * struck through rather than merely yellow. */
90
+ tags?: DiagnosticTag[];
73
91
  /** Telo-specific extras such as { resource: { kind, name }, path } */
74
92
  data?: unknown;
75
93
  }