@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
package/src/analyzer.ts CHANGED
@@ -24,7 +24,14 @@ import {
24
24
  type CelHandlers,
25
25
  } from "./cel-environment.js";
26
26
  import { DefinitionRegistry } from "./definition-registry.js";
27
- import { type ContractDirection, effectiveAuthorSchema } from "./extends-resolution.js";
27
+ import { readDeprecation } from "./deprecation.js";
28
+ import {
29
+ type ContractDirection,
30
+ type DefResolver,
31
+ effectiveAuthorSchema,
32
+ inheritedCapability,
33
+ inheritedRequiredFields,
34
+ } from "./extends-resolution.js";
28
35
  import {
29
36
  analyzerContractScope,
30
37
  type ContractScope,
@@ -125,7 +132,12 @@ import {
125
132
  type SchemaIssue,
126
133
  } from "./schema-compat.js";
127
134
  import { collectValueSchemaIssues } from "./validate-value-schema.js";
128
- import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
135
+ import {
136
+ DiagnosticSeverity,
137
+ DiagnosticTag,
138
+ type AnalysisDiagnostic,
139
+ type AnalysisOptions,
140
+ } from "./types.js";
129
141
  import {
130
142
  extractAccessChains,
131
143
  extractContextsFromSchema,
@@ -136,6 +148,7 @@ import {
136
148
  import {
137
149
  celEvalModeAt,
138
150
  celEvalSites,
151
+ implicitEvalSites,
139
152
  mergeCelEvalSites,
140
153
  NO_CEL_EVAL_SITES,
141
154
  type CelEvalSites,
@@ -166,7 +179,8 @@ import { kindSatisfies, validateReferences } from "./validate-references.js";
166
179
  import { validateReferenceForms } from "./validate-reference-forms.js";
167
180
  import { isInjectedDeclaration } from "./resource-input.js";
168
181
  import { validateResourceInputs } from "./validate-resource-inputs.js";
169
- import { validateTemplateDispatch } from "./validate-template-dispatch.js";
182
+ import { validateExports } from "./validate-exports.js";
183
+ import { validateTemplateBody } from "./validate-template-body.js";
170
184
  import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
171
185
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
172
186
  import { readStepSlot } from "./step-slot.js";
@@ -1535,6 +1549,7 @@ export class StaticAnalyzer {
1535
1549
  aliases,
1536
1550
  rootModules,
1537
1551
  getCallGraph(),
1552
+ { aliasesByModule, rootModules },
1538
1553
  ),
1539
1554
  );
1540
1555
  // A file embed resolves at resource creation, so one written on a doc that
@@ -1911,6 +1926,52 @@ export class StaticAnalyzer {
1911
1926
  continue;
1912
1927
  }
1913
1928
 
1929
+ // A kind its own author marked `metadata.deprecated` still works exactly as
1930
+ // it did — the manifest is valid and keeps running — so this is a WARNING,
1931
+ // reported at the declaration because `kind:` is the line that has to
1932
+ // change. Emitted here rather than in a pass of its own so it reads the
1933
+ // kind THIS walk resolved: the resolution is alias- and gate-aware and
1934
+ // scope-dependent, and a second implementation of it would eventually
1935
+ // disagree about which definition a name means.
1936
+ //
1937
+ // Entry-module-scoped, like every other "not the consumer's to fix" check:
1938
+ // a library's internal use of a kind its own author deprecated is that
1939
+ // author's concern, and reporting it floods a consumer with lines they
1940
+ // cannot act on.
1941
+ //
1942
+ // No `DiagnosticFix`. `replacedBy` names a kind through the DECLARING
1943
+ // module's aliases; writing it into the consumer's file would produce a
1944
+ // prefix that resolves to nothing there — and a kind swap is not a
1945
+ // whole-value replacement anyway, since the successor needs its own import
1946
+ // and usually a different configuration.
1947
+ const deprecation = readDeprecation(definition.metadata);
1948
+ if (deprecation && (!ownModule || rootModules.has(ownModule))) {
1949
+ // Quoted CANONICALLY, not verbatim: `Self.Thing` is how the declaring
1950
+ // library names its own kind and means nothing where the warning lands.
1951
+ const declaringScope =
1952
+ scopeResolverForModule(
1953
+ (definition.metadata as { module?: string } | undefined)?.module,
1954
+ rootModules,
1955
+ aliasesByModule,
1956
+ ) ?? aliases;
1957
+ const replacement = deprecation.replacedBy
1958
+ ? (declaringScope.resolveKind(deprecation.replacedBy) ?? deprecation.replacedBy)
1959
+ : undefined;
1960
+ diagnostics.push({
1961
+ severity: DiagnosticSeverity.Warning,
1962
+ code: "DEPRECATED_KIND",
1963
+ source: SOURCE,
1964
+ // Warning-grade AND a deprecation: the severity says it must
1965
+ // eventually be dealt with, the tag says what it is, and an editor
1966
+ // strikes the kind through on the strength of the second.
1967
+ tags: [DiagnosticTag.Deprecated],
1968
+ message:
1969
+ `Kind '${m.kind}' is deprecated: ${deprecation.reason}` +
1970
+ (replacement ? ` Use '${replacement}' instead.` : ""),
1971
+ data: { resource, filePath, path: "kind" },
1972
+ });
1973
+ }
1974
+
1914
1975
  // Validate resource config against the definition's AUTHOR-FACING schema —
1915
1976
  // inheritance-resolved, with `kind` / `metadata` injected. See
1916
1977
  // `validationSchemaFor`, which is where both derivations and the reason
@@ -1976,12 +2037,41 @@ export class StaticAnalyzer {
1976
2037
  allManifests as Record<string, any>[],
1977
2038
  );
1978
2039
  const issues = [...ajvIssues, ...valueSchemaIssues];
2040
+ // WHY A FIELD THIS KIND EXISTS TO SUPPLY IS STILL REQUIRED OF ITS
2041
+ // CONSUMER. A child that `extends` and declares no `base:` is authored
2042
+ // against merge(parent, own), so the parent's `required` stays on the
2043
+ // CHILD's surface — and a kind written to wire that field internally
2044
+ // then demands it from the consumer anyway. The `base:` mapping is what
2045
+ // narrows (with it the surface is the child's own schema alone), and
2046
+ // nothing in "is missing required property" points there, which is what
2047
+ // turns one mistake into a three-step dead end.
2048
+ //
2049
+ // Matched against names DERIVED from the parent, never by parsing the
2050
+ // message: which fields are inherited is a fact about the definition,
2051
+ // and a check that reads a validator's prose breaks when the prose does.
2052
+ const inheritedRequired = inheritedRequiredFields(definition, (k) =>
2053
+ defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k),
2054
+ );
2055
+ const parentKind = (definition as { extends?: string } | undefined)?.extends;
1979
2056
  for (const issue of issues) {
2057
+ // Keyed on the STRUCTURED failure, never on the sentence: the field
2058
+ // names are derived from the parent, and matching them against the
2059
+ // validator's prose would break when the prose changes and mis-fire on
2060
+ // any other issue quoting the same name (an `additionalProperties`
2061
+ // rejection, an `enum` listing).
2062
+ const inherited =
2063
+ issue.keyword === "required" && issue.missingProperty !== undefined
2064
+ ? inheritedRequired.filter((f) => f === issue.missingProperty)
2065
+ : [];
2066
+ const hint =
2067
+ inherited.length > 0 && parentKind
2068
+ ? ` — ${inherited.map((f) => `'${f}'`).join(", ")} ${inherited.length > 1 ? "come" : "comes"} from '${parentKind}', which this kind extends without a 'base:' mapping, so the parent's required fields stay on this kind's author surface. Add 'base:' to set them internally; that also narrows the surface to this kind's own schema.`
2069
+ : "";
1980
2070
  diagnostics.push({
1981
2071
  severity: DiagnosticSeverity.Error,
1982
2072
  code: "SCHEMA_VIOLATION",
1983
2073
  source: SOURCE,
1984
- message: `${m.kind}/${resource.name}: ${issue.message}`,
2074
+ message: `${m.kind}/${resource.name}: ${issue.message}${hint}`,
1985
2075
  data: { resource, filePath, path: issue.path },
1986
2076
  });
1987
2077
  }
@@ -2419,8 +2509,15 @@ export class StaticAnalyzer {
2419
2509
 
2420
2510
  // The non-eval-field check only applies to runtime resource instances:
2421
2511
  // structural / templating kinds (capability `Telo.Template`, or no
2422
- // definition) carry CEL the kernel evaluates by other rules.
2423
- const capability = e.definition?.capability;
2512
+ // definition) carry CEL the kernel evaluates by other rules. The
2513
+ // capability is INHERITED along `extends` — an inheritance kind
2514
+ // writes none of its own — and reading the declared one left the
2515
+ // rule off for every such kind, and its expressions untyped.
2516
+ const resolveDef: DefResolver = (k) =>
2517
+ defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k);
2518
+ const capability = e.definition
2519
+ ? inheritedCapability(e.definition as unknown as ResourceDefinition, resolveDef)
2520
+ : undefined;
2424
2521
  celRuleApplies =
2425
2522
  !!e.definition?.schema && capability !== undefined && capability !== "Telo.Template";
2426
2523
  if (celRuleApplies) {
@@ -2434,15 +2531,18 @@ export class StaticAnalyzer {
2434
2531
  // disagreeing about what the manifest means.
2435
2532
  const ownSchema = effectiveAuthorSchema(
2436
2533
  e.definition as unknown as ResourceDefinition,
2437
- (k) => defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k),
2534
+ resolveDef,
2438
2535
  ) as Record<string, any>;
2439
2536
  const capabilityDef = capability ? defs.resolve(capability) : undefined;
2440
2537
  // A `Telo.Provider`'s fields are implicitly compile-eval — the
2441
2538
  // capability abstract carries the root annotation — so its reads are
2442
- // covered here without the provider restating anything.
2539
+ // covered here without the provider restating anything. A base-form
2540
+ // child's own fields are too (`implicitEvalSites`): `base:` reads
2541
+ // them once at create().
2443
2542
  celSites = mergeCelEvalSites(
2444
2543
  celEvalSites(ownSchema),
2445
2544
  celEvalSites(capabilityDef?.schema as Record<string, any> | undefined),
2545
+ implicitEvalSites(e.definition as { base?: unknown }),
2446
2546
  );
2447
2547
  } else {
2448
2548
  celSites = NO_CEL_EVAL_SITES;
@@ -2748,10 +2848,16 @@ export class StaticAnalyzer {
2748
2848
  // Warn about declared variables / secrets / ports that no CEL references.
2749
2849
  diagnostics.push(...validateUnusedDeclarations(allManifests, this.celEnv));
2750
2850
 
2751
- // A `!ref` at a definition's dispatch slot must name a sibling `resources:`
2752
- // entry — the slot no reference pass reaches, so the tag would otherwise
2753
- // advertise a resolution nothing performs.
2754
- diagnostics.push(...validateTemplateDispatch(allManifests, rootModules));
2851
+ // A template body's reference surface its entry names, its dispatch slots
2852
+ // and the ref slots inside each entry — which no reference pass reaches,
2853
+ // since a `Telo.Definition` is in both skip sets.
2854
+ diagnostics.push(
2855
+ ...validateTemplateBody(allManifests, defs, aliases, aliasesByModule, rootModules),
2856
+ );
2857
+
2858
+ // A library's export list, resolved against what it declares — otherwise a
2859
+ // listed name that exists nowhere fails in the consumer's file.
2860
+ diagnostics.push(...validateExports(allManifests, defs, aliases, rootModules));
2755
2861
 
2756
2862
  // A library's declared resource inputs, and every import that supplies them.
2757
2863
  diagnostics.push(
package/src/builtins.ts CHANGED
@@ -23,6 +23,62 @@ const observedStateSlot = {
23
23
  "What a resource of this kind reports while running, published at `resources.<name>.status.<field>`.",
24
24
  $ref: manifestFragmentRef("JsonSchema7"),
25
25
  };
26
+
27
+ /** The `self`-only CEL scope a dispatch slot's name template is written in. */
28
+ const dispatchSelfContext = {
29
+ type: "object",
30
+ additionalProperties: false,
31
+ properties: { self: { "x-telo-context-from-root": "schema" } },
32
+ };
33
+
34
+ /**
35
+ * A `Telo.Definition` dispatch slot: which `resources:` entry receives the call.
36
+ *
37
+ * Three shapes, and the union is what keeps the slot CONSTRAINED for manifests
38
+ * no pass walks — `validate-template-body` resolves the target and its
39
+ * capability, but it is entry-scoped, so a dependency's definition would
40
+ * otherwise have nothing checking this slot at all. An array or a number fails
41
+ * every branch here, which is the floor AJV used to provide.
42
+ *
43
+ * `anyOf`, never `oneOf`: a `!ref` sentinel is an object, so it would match both
44
+ * the sentinel branch and any permissive object branch and then fail for
45
+ * matching twice. That is also how the sentinel used to pass — the legacy object
46
+ * branch required nothing, so it accepted one by accident; here it is a branch
47
+ * of its own that says so.
48
+ */
49
+ function dispatchSlot(title: string, description: string): Record<string, unknown> {
50
+ return {
51
+ title,
52
+ description,
53
+ anyOf: [
54
+ // `!ref <entry>` — the spelling to write.
55
+ {
56
+ type: "object",
57
+ required: ["__tagged", "engine", "source"],
58
+ properties: {
59
+ __tagged: { const: true },
60
+ engine: { const: "ref" },
61
+ source: { type: "string", minLength: 1 },
62
+ },
63
+ additionalProperties: false,
64
+ },
65
+ // Legacy: a name template expanded against `self`. Read forever, because
66
+ // published artifacts carry it; `DEPRECATED_TEMPLATE_DISPATCH_FORM` is
67
+ // what moves an author off it.
68
+ { type: "string", "x-telo-context": dispatchSelfContext },
69
+ // Legacy: `{ kind?, name }`, where `name` is that same template.
70
+ {
71
+ type: "object",
72
+ required: ["name"],
73
+ properties: {
74
+ kind: { type: "string" },
75
+ name: { type: "string", "x-telo-context": dispatchSelfContext },
76
+ },
77
+ additionalProperties: true,
78
+ },
79
+ ],
80
+ };
81
+ }
26
82
  import type { ResourceDefinition } from "@telorun/sdk";
27
83
 
28
84
  /** Descriptive provenance a module declares about itself, shared by
@@ -480,99 +536,31 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
480
536
  },
481
537
  },
482
538
  },
483
- invoke: {
484
- oneOf: [
485
- {
486
- type: "string",
487
- "x-telo-context": {
488
- type: "object",
489
- additionalProperties: false,
490
- properties: {
491
- self: { "x-telo-context-from-root": "schema" },
492
- },
493
- },
494
- },
495
- {
496
- type: "object",
497
- additionalProperties: true,
498
- properties: {
499
- kind: { type: "string" },
500
- name: {
501
- type: "string",
502
- "x-telo-context": {
503
- type: "object",
504
- additionalProperties: false,
505
- properties: {
506
- self: { "x-telo-context-from-root": "schema" },
507
- },
508
- },
509
- },
510
- },
511
- },
512
- ],
513
- },
514
- provide: {
515
- type: "object",
516
- additionalProperties: true,
517
- properties: {
518
- kind: { type: "string" },
519
- name: {
520
- type: "string",
521
- "x-telo-context": {
522
- type: "object",
523
- additionalProperties: false,
524
- properties: {
525
- self: { "x-telo-context-from-root": "schema" },
526
- },
527
- },
528
- },
529
- },
530
- },
531
- run: {
532
- type: "string",
533
- "x-telo-context": {
534
- type: "object",
535
- additionalProperties: false,
536
- properties: {
537
- self: { "x-telo-context-from-root": "schema" },
538
- },
539
- },
540
- },
541
- // Mount dispatch: names the `resources:` entry (a Telo.Mount, e.g. an
542
- // Http.Api) whose `register()` this definition delegates to. Same
543
- // string / { kind, name } grammar as `invoke:`. The named child stays
544
- // persistent so the produced mount's routes can `!ref` its siblings.
545
- mount: {
546
- oneOf: [
547
- {
548
- type: "string",
549
- "x-telo-context": {
550
- type: "object",
551
- additionalProperties: false,
552
- properties: {
553
- self: { "x-telo-context-from-root": "schema" },
554
- },
555
- },
556
- },
557
- {
558
- type: "object",
559
- additionalProperties: true,
560
- properties: {
561
- kind: { type: "string" },
562
- name: {
563
- type: "string",
564
- "x-telo-context": {
565
- type: "object",
566
- additionalProperties: false,
567
- properties: {
568
- self: { "x-telo-context-from-root": "schema" },
569
- },
570
- },
571
- },
572
- },
573
- },
574
- ],
575
- },
539
+ // A dispatch slot names the `resources:` entry that receives the call,
540
+ // as `!ref <entry>` — the one spelling every reference in Telo has. The
541
+ // string and `{ kind, name }` forms it used to admit (a CEL name
542
+ // template matched against CEL-named entries) were the removed
543
+ // reference object surviving in the one place nothing resolved it;
544
+ // `validate-template-body` is what resolves this slot, so the schema
545
+ // stays open here rather than reporting the same defect twice.
546
+ invoke: dispatchSlot(
547
+ "Invoke target",
548
+ "The `resources:` entry whose `invoke()` this kind dispatches to, as `!ref <entry>`.",
549
+ ),
550
+ provide: dispatchSlot(
551
+ "Provide target",
552
+ "The `resources:` entry whose `invoke()` produces this provider's value, as `!ref <entry>`.",
553
+ ),
554
+ run: dispatchSlot(
555
+ "Run target",
556
+ "The `resources:` entry whose `run()` this kind dispatches to, as `!ref <entry>`.",
557
+ ),
558
+ // The named child stays persistent so the produced mount's routes can
559
+ // `!ref` its siblings.
560
+ mount: dispatchSlot(
561
+ "Mount target",
562
+ "The `resources:` entry (a Telo.Mount, e.g. an Http.Api) whose `register()` this kind delegates to, as `!ref <entry>`.",
563
+ ),
576
564
  inputs: {
577
565
  type: "object",
578
566
  additionalProperties: true,
@@ -593,11 +581,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
593
581
  additionalProperties: false,
594
582
  properties: {
595
583
  self: { "x-telo-context-from-root": "schema" },
584
+ // Typed from the dispatch target's declared output: the slot
585
+ // holds a `!ref` to a `resources:` entry, resolved to that
586
+ // entry's kind (or its own `outputType`, where it narrows one).
596
587
  result: {
597
- "x-telo-context-from-ref-kind": [
598
- "provide/kind#outputType",
599
- "invoke/kind#outputType",
600
- ],
588
+ "x-telo-context-from-ref-kind": ["provide#outputType", "invoke#outputType"],
601
589
  },
602
590
  },
603
591
  },
@@ -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
+ }
@@ -21,6 +21,7 @@ import { buildKernelGlobalsIndex } from "./kernel-globals.js";
21
21
  import { moduleAliasScope } from "./module-alias-scope.js";
22
22
  import { isModuleKind } from "./module-kinds.js";
23
23
  import { navigateConcretePath } from "./manifest-path.js";
24
+ import { kindAtPath } from "./validate-cel-context.js";
24
25
  import { findManifest } from "./find-manifest.js";
25
26
  import { resolveLocalRef, walkStepArray } from "./schema-walk.js";
26
27
  import { readStepSlot } from "./step-slot.js";
@@ -269,14 +270,27 @@ export class CelScopeQuery {
269
270
  }
270
271
 
271
272
  // A kind's own declaration: the target is the `Telo.Definition` document,
272
- // which is an ordinary manifest in the set.
273
+ // which is an ordinary manifest in the set. The slot holds a `!ref` to a
274
+ // `resources:` entry; the entry's own field wins where it narrows one, else
275
+ // the declaration is the entry's KIND. The first slot that resolves to an
276
+ // entry is the one the type resolver reads, so it is the one navigated.
273
277
  const fromRefKind = annotated["x-telo-context-from-ref-kind"];
274
- const first = Array.isArray(fromRefKind) ? fromRefKind[0] : fromRefKind;
275
- if (typeof first === "string") {
276
- const hash = first.indexOf("#");
277
- if (hash <= 0) return undefined;
278
- const kindValue = navigateConcretePath(root, first.slice(0, hash).split("/").join("."));
279
- if (typeof kindValue !== "string") return undefined;
278
+ const slots = Array.isArray(fromRefKind) ? fromRefKind : [fromRefKind];
279
+ for (const slotSpec of slots) {
280
+ if (typeof slotSpec !== "string") continue;
281
+ const hash = slotSpec.indexOf("#");
282
+ // `continue`, never `return`: a malformed spec is one entry of a list the
283
+ // type resolver walks to the end, and aborting here made the two answer
284
+ // differently for the same annotation.
285
+ if (hash <= 0) continue;
286
+ const field = slotSpec.slice(hash + 1);
287
+ const namedKind = kindAtPath(root, slotSpec.slice(0, hash));
288
+ if (!namedKind) continue;
289
+ if (namedKind.entry?.[field] !== undefined) {
290
+ const index = (root.resources as unknown[]).indexOf(namedKind.entry);
291
+ return { manifest: resource, path: `resources[${index}].${field}`, propertyMap: false };
292
+ }
293
+ const kindValue = namedKind.kind;
280
294
  // The kind was read off THIS resource, so it is spelled in the alias scope
281
295
  // of the module that declared it — not the entry's.
282
296
  const scope = moduleAliasScope(resource.metadata, this.ctx.aliases, this.ctx.aliasesByModule);
@@ -300,9 +314,14 @@ export class CelScopeQuery {
300
314
  (owningModule
301
315
  ? named.find((m) => (m.metadata as { module?: string } | undefined)?.module === owningModule)
302
316
  : undefined) ?? (owningModule && named.length > 1 ? undefined : named[0]);
303
- if (!target) return undefined;
304
- return { manifest: target, path: first.slice(hash + 1), propertyMap: false };
317
+ // Fall through to the next spec when this one names no kind, or names one
318
+ // that does not declare the field the same order the TYPE resolver
319
+ // takes, so go-to-declaration lands where the type came from instead of on
320
+ // a field the target never had.
321
+ if (!target || target[field] === undefined) continue;
322
+ return { manifest: target, path: field, propertyMap: false };
305
323
  }
324
+ if (slots.some((s) => typeof s === "string")) return undefined;
306
325
 
307
326
  // `x-telo-context-element-from` / `-collection-from` type a binding from an
308
327
  // EXPRESSION, so there is no declaration to navigate to.
package/src/cel-scope.ts CHANGED
@@ -651,6 +651,7 @@ export class CelScopeResolver {
651
651
  manifestRoot: rootForResolver,
652
652
  defs,
653
653
  aliases,
654
+ aliasesByModule: scopes?.aliasesByModule,
654
655
  allManifests: allManifests as Record<string, any>[],
655
656
  });
656
657
  return mergeKernelGlobalsIntoContext(