@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
@@ -38,7 +38,9 @@ export interface RefSlotIssue {
38
38
  | "X_TELO_REF_MISSING_USE"
39
39
  | "X_TELO_REF_MISSING_KIND"
40
40
  | "X_TELO_REF_USE_CONFLICT"
41
- | "X_TELO_REF_DYNAMIC_SELECTOR";
41
+ | "X_TELO_REF_DYNAMIC_SELECTOR"
42
+ | "X_TELO_REF_UNKNOWN_KEY"
43
+ | "X_TELO_REF_INVALID_THROWS_THROUGH";
42
44
  /** The definition (schema issues) or resource (selector issues) at fault. */
43
45
  manifest: ResourceManifest;
44
46
  /** Schema path of the slot (schema issues) or concrete value path of the
@@ -140,9 +142,47 @@ function checkAnnotation(
140
142
  }
141
143
  }
142
144
 
145
+ // `throwsThrough` is read as `=== true`, so anything else is silently absent —
146
+ // and absent means the declaring resource's catch scope stops enclosing what
147
+ // it holds, so every route under it starts reporting UNCOVERED_THROW_CODE with
148
+ // nothing naming the cause. The same failure `X_TELO_REF_INVALID_USE` exists
149
+ // to prevent, one key over.
150
+ if (obj.throwsThrough !== undefined && typeof obj.throwsThrough !== "boolean") {
151
+ issues.push({
152
+ code: "X_TELO_REF_INVALID_THROWS_THROUGH",
153
+ manifest,
154
+ path,
155
+ message:
156
+ `x-telo-ref at '${path}' declares 'throwsThrough: ${JSON.stringify(obj.throwsThrough)}', ` +
157
+ `which is not a boolean. Only 'true' declares that throws from this slot's target ` +
158
+ `surface through the declaring resource; anything else reads as absent, which ` +
159
+ `silently stops its catch list from enclosing what it holds.`,
160
+ });
161
+ }
162
+
163
+ // Closed, for the reason the token sets are: a misspelled key is indexed by
164
+ // nothing and read by nothing, so it validates, ships, and does exactly what
165
+ // omitting it would.
166
+ for (const key of Object.keys(obj)) {
167
+ if (REF_ANNOTATION_KEYS.has(key)) continue;
168
+ issues.push({
169
+ code: "X_TELO_REF_UNKNOWN_KEY",
170
+ manifest,
171
+ path,
172
+ message:
173
+ `x-telo-ref at '${path}' declares unrecognized key '${key}'. Known keys: ` +
174
+ `${[...REF_ANNOTATION_KEYS].sort().join(", ")}. An unrecognized key is read by nothing, ` +
175
+ `so it has exactly the effect of leaving it out.`,
176
+ });
177
+ }
178
+
143
179
  return declaredUses(use);
144
180
  }
145
181
 
182
+ /** Every key the structured `x-telo-ref` form accepts — the write side of
183
+ * `readRefSlot`'s read side. Adding one belongs in both. */
184
+ const REF_ANNOTATION_KEYS = new Set(["kind", "use", "inputs", "throwsThrough"]);
185
+
146
186
  /** True when a node is a reference slot: it carries `x-telo-ref` directly or on
147
187
  * an `anyOf`/`oneOf` branch. */
148
188
  function carriesRefAnnotation(obj: Record<string, unknown>): boolean {
@@ -15,10 +15,141 @@ import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } fro
15
15
  import type { AliasResolver } from "./alias-resolver.js";
16
16
  import type { DefinitionRegistry } from "./definition-registry.js";
17
17
  import { moduleAliasScope } from "./module-alias-scope.js";
18
+ import { isModuleKind } from "./module-kinds.js";
18
19
  import { isInjectedDeclaration } from "./resource-input.js";
19
20
 
20
21
  const SOURCE = "telo-analyzer";
21
22
 
23
+ /** What a name IS on one side of a collision. The three shapes share the
24
+ * kernel's one namespace but read as different things to an author — an alias
25
+ * is not a resource, and a module's own name is not a declaration inside it —
26
+ * so calling all three "resource name" describes two of them wrongly. */
27
+ function describeNamed(m: ResourceManifest, name: string): string {
28
+ if (m.kind === "Telo.Import") return `import alias '${name}'`;
29
+ if (isModuleKind(m.kind)) return `this module's own name (${m.kind} '${name}')`;
30
+ return `resource ${m.kind}/${name}`;
31
+ }
32
+
33
+ /**
34
+ * WHAT SHARES A NAME AT THE KERNEL.
35
+ *
36
+ * `registerManifest` keys on `metadata.name` alone, per module context, for
37
+ * EVERY kind — so an import alias, a kind definition and an ordinary resource
38
+ * are one namespace. This check used to model a narrower one: `Telo.Import` was
39
+ * excluded outright, on the stated grounds that an alias "lives in a separate
40
+ * namespace from resources", and `Telo.Definition` / `Telo.Abstract` fell out
41
+ * through the ref-validation skip set, which answers a different question. All
42
+ * three passed `telo check` and then died at boot with `ERR_DUPLICATE_RESOURCE`
43
+ * — an application named after one of its own imports (`metadata.name: Schedule`
44
+ * beside `Schedule: oci://…/scheduler`) being the shape that reaches an author,
45
+ * since nothing about the two lines looks like one name written twice.
46
+ *
47
+ * Grouped per DECLARING MODULE, which is what makes including imports sound: a
48
+ * library's own `Telo.Import` / `Telo.Definition` docs are forwarded into a
49
+ * consumer's flat set, and two modules sharing an alias (`Console` in an app and
50
+ * again in a library it imports) is ordinary rather than a collision. A module
51
+ * doc carries no `metadata.module` and IS its own scope — that is what puts an
52
+ * application's own name in the same group as its imports.
53
+ *
54
+ * Kept apart from the resolution lookup it used to share a map with: that one
55
+ * must hold resolution targets alone, so the two disagree about membership by
56
+ * design and merging them re-keyed every bare-name reference.
57
+ */
58
+ function duplicateNameDiagnostics(
59
+ resources: ResourceManifest[],
60
+ isForeign: (r: ResourceManifest) => boolean,
61
+ moduleOf: (r: ResourceManifest) => string | undefined,
62
+ ): AnalysisDiagnostic[] {
63
+ const diagnostics: AnalysisDiagnostic[] = [];
64
+
65
+ const moduleDocNames = new Set<string>();
66
+ for (const r of resources) {
67
+ if (isModuleKind(r.kind) && typeof r.metadata?.name === "string") {
68
+ moduleDocNames.add(r.metadata.name);
69
+ }
70
+ }
71
+ // A module doc belongs in the scope its OWN resources are in. Flattened, they
72
+ // carry `metadata.module` equal to its name, so it scopes to itself — which is
73
+ // what puts an application's name beside its imports. Unflattened (a hand-built
74
+ // fixture, a single-file editor analysis) nothing is stamped, so its resources
75
+ // sit in the unnamed scope and the doc has to join them there or a collision
76
+ // with one of them splits across two groups and goes unreported.
77
+ const stampedModules = new Set<string>();
78
+ for (const r of resources) {
79
+ const m = moduleOf(r);
80
+ if (m) stampedModules.add(m);
81
+ }
82
+
83
+ // scope → name → declarations, so no separator has to be safe against a name.
84
+ const byScope = new Map<string, Map<string, ResourceManifest[]>>();
85
+ const seen = new Set<string>();
86
+ for (const r of resources) {
87
+ if (!r.metadata?.name || isForeign(r)) continue;
88
+ const name = r.metadata.name as string;
89
+ const scope = isModuleKind(r.kind)
90
+ ? (stampedModules.has(name) ? name : "")
91
+ : (moduleOf(r) ?? "");
92
+ // A scope with no module doc in this set is an imported library's, whose
93
+ // doc `selectModuleManifestsForAnalysis` drops — its internals are its own
94
+ // author's to fix, and its own `telo check` reports them.
95
+ if (scope !== "" && moduleDocNames.size > 0 && !moduleDocNames.has(scope)) continue;
96
+ // Dedup pipeline echoes — the same physical document emitted twice through
97
+ // an analyzer host's pipeline (telo studio walks a file reachable as both an
98
+ // entry module and an `include:` partial). Keyed on (kind, name, source,
99
+ // sourceLine), so two textually-distinct docs in one file keep separate
100
+ // fingerprints and still trip the diagnostic.
101
+ const meta = r.metadata as unknown as { source?: string; sourceLine?: number };
102
+ const fingerprint = `${r.kind} ${name} ${meta.source} ${meta.sourceLine}`;
103
+ if (seen.has(fingerprint)) continue;
104
+ seen.add(fingerprint);
105
+ let names = byScope.get(scope);
106
+ if (!names) byScope.set(scope, (names = new Map()));
107
+ const existing = names.get(name);
108
+ if (existing) existing.push(r);
109
+ else names.set(name, [r]);
110
+ }
111
+
112
+ for (const names of byScope.values()) {
113
+ for (const [name, list] of names) {
114
+ if (list.length <= 1) continue;
115
+ const [first, ...rest] = list;
116
+ for (const dup of rest) {
117
+ // Two imports sharing an alias is ONE defect with its own diagnostic
118
+ // (`DUPLICATE_IMPORT_ALIAS`), which says what to do about it; reporting
119
+ // it again here would describe one mistake as two.
120
+ if (dup.kind === "Telo.Import" && first.kind === "Telo.Import") continue;
121
+ const dupMeta = dup.metadata as { source?: string; sourceLine?: number } | undefined;
122
+ // The precomputed range matters because editor hosts resolve positions
123
+ // via a `${file}::${kind}::${name}` lookup, which collides on duplicates.
124
+ const range =
125
+ typeof dupMeta?.sourceLine === "number"
126
+ ? {
127
+ start: { line: dupMeta.sourceLine, character: 0 },
128
+ end: { line: dupMeta.sourceLine, character: Number.MAX_SAFE_INTEGER },
129
+ }
130
+ : undefined;
131
+ diagnostics.push({
132
+ severity: DiagnosticSeverity.Error,
133
+ code: "DUPLICATE_RESOURCE_NAME",
134
+ source: SOURCE,
135
+ message:
136
+ `${dup.kind}/${name}: ${describeNamed(dup, name)} collides with ` +
137
+ `${describeNamed(first, name)} declared earlier — the kernel registers both ` +
138
+ `under '${name}' in one namespace, so boot fails with ERR_DUPLICATE_RESOURCE. ` +
139
+ `Rename one of them.`,
140
+ ...(range ? { range } : {}),
141
+ data: {
142
+ resource: { kind: dup.kind, name },
143
+ filePath: dupMeta?.source,
144
+ path: "metadata.name",
145
+ },
146
+ });
147
+ }
148
+ }
149
+ }
150
+ return diagnostics;
151
+ }
152
+
22
153
  /**
23
154
  * Liskov substitutability at a kind constraint: is `resolved` (a canonical
24
155
  * `<module>.<Kind>`) accepted where `targetKind` is required?
@@ -162,40 +293,16 @@ export function validateReferences(
162
293
  const aliasesByModule = context.aliasesByModule;
163
294
  if (!aliases || !registry) return diagnostics;
164
295
 
165
- // Build outer resource lookup by name for resolution check, collecting
166
- // every entry per name so we can surface name collisions as diagnostics
167
- // (the kernel's resource registry shares one namespace across all
168
- // non-system kinds e.g. `Telo.Application HelloApi` and `Http.Api
169
- // HelloApi` collide at boot with `ERR_DUPLICATE_RESOURCE`. Catching it
170
- // statically removes a class of "everything analyzes clean, then the
171
- // kernel refuses to start" surprises.)
172
- //
173
- // Telo.Import is excluded from the duplicate check on top of the
174
- // SYSTEM_KINDS skip: its `metadata.name` is an alias, not a resource
175
- // identity (aliases live in a separate namespace from resources, and
176
- // colliding aliases vs. resource names is benign — the alias is only
177
- // ever read as a kind prefix).
178
- // Group manifests by name to detect collisions. Two subtleties:
296
+ // Build the outer resource lookup by name, for the resolution checks below.
297
+ // WHICH NAMES COLLIDE is a different question over a different namespace and
298
+ // is answered by `duplicateNameDiagnostics`; this map answers only "what does
299
+ // a bare `!ref <name>` resolve to", so it holds resolution TARGETS alone — an
300
+ // import alias and a kind definition are neither, and admitting them here
301
+ // would resolve a reference to something no ref slot can accept.
179
302
  //
180
- // 1. Some analyzer hosts emit the SAME physical document twice through
181
- // their pipeline e.g. telo studio's `toAnalysisManifests` walks
182
- // each workspace module's documents independently, and a file
183
- // reachable from two angles (entry module + `include:` partial)
184
- // shows up twice. The fingerprint includes `sourceLine` so identical
185
- // docs (same kind, name, source, AND source line) collapse to one,
186
- // while two textually-separate documents in the same file (different
187
- // source lines) keep separate fingerprints and trip the diagnostic.
188
- // 2. The diagnostic carries a precomputed `range` pointing at the
189
- // duplicate's source line — editor hosts that resolve diagnostic
190
- // positions via a `${file}::${kind}::${name}` lookup would otherwise
191
- // collide on duplicates (Map.set overwrites) and place the squiggle
192
- // ambiguously. The explicit `range` short-circuits that lookup.
193
- // Dedup pipeline echoes — the same physical document emitted twice
194
- // through an analyzer host's pipeline. Keyed on (kind, name, source,
195
- // sourceLine), so two textually-distinct docs in the same file (same
196
- // source, different sourceLine) keep separate fingerprints and still
197
- // trip the diagnostic. `analyze()` enforces that every non-system
198
- // manifest carries both positional fields — no defensive guard needed.
303
+ // The list per name survives because resolution falls back to the FIRST
304
+ // occurrence when a collision exists, which keeps the rest of the pass
305
+ // behaving as it did before duplicates were reported at all.
199
306
  // Forwarded foreign exports (an imported library's exported instances, carrying a
200
307
  // metadata.module that isn't a root module) are resolution TARGETS only: excluded from
201
308
  // duplicate detection and local name resolution, and never walked as ref sources.
@@ -246,33 +353,7 @@ export function validateReferences(
246
353
  if (existing) existing.push(r);
247
354
  else byNameAll.set(name, [r]);
248
355
  }
249
- for (const [name, list] of byNameAll) {
250
- if (list.length <= 1) continue;
251
- const [first, ...rest] = list;
252
- const firstLabel = `${first.kind}/${name}`;
253
- for (const dup of rest) {
254
- const dupMeta = dup.metadata as { source?: string; sourceLine?: number } | undefined;
255
- const range =
256
- typeof dupMeta?.sourceLine === "number"
257
- ? {
258
- start: { line: dupMeta.sourceLine, character: 0 },
259
- end: { line: dupMeta.sourceLine, character: Number.MAX_SAFE_INTEGER },
260
- }
261
- : undefined;
262
- diagnostics.push({
263
- severity: DiagnosticSeverity.Error,
264
- code: "DUPLICATE_RESOURCE_NAME",
265
- source: SOURCE,
266
- message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
267
- ...(range ? { range } : {}),
268
- data: {
269
- resource: { kind: dup.kind, name },
270
- filePath: dupMeta?.source,
271
- path: "metadata.name",
272
- },
273
- });
274
- }
275
- }
356
+ diagnostics.push(...duplicateNameDiagnostics(resources, isForeign, moduleOf));
276
357
  // The dot rule that used to live here is now the strictest special case of
277
358
  // the identifier grammar in `validate-identifier-names.ts` — a dot is one of
278
359
  // several characters that make a name unreferenceable, and checking one of
@@ -562,14 +643,44 @@ export function validateReferences(
562
643
 
563
644
  for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
564
645
  if (fieldValue == null) continue;
565
- const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
646
+ // CEL leaves become schema-shaped placeholders first, exactly as the
647
+ // sibling-ref branch below does and for the same reason: a slot
648
+ // anchored at a shared value-shape is overwhelmingly written as
649
+ // expressions, so validating it raw reports every one of them as a
650
+ // type error and the check fires only on the literal case nobody
651
+ // writes. Omitting it here made one annotation mean two different
652
+ // things depending on which branch resolved it — a `when:` typed
653
+ // `boolean` accepted a `!cel` at a route's inline slot and rejected
654
+ // the identical expression at a slot anchored on the carrier that
655
+ // declares that very shape.
656
+ const substituted = substituteCelFields(
657
+ fieldValue,
658
+ subSchema as Record<string, any>,
659
+ );
660
+ // Anchored at the offending node INSIDE the value, not at the slot:
661
+ // a `returns:` list is an array of entries, and reporting every one
662
+ // of its issues on the `returns:` line puts three diagnostics on one
663
+ // line and none on the entry that is wrong.
664
+ const issues = registry.validateResourceConfig(
665
+ substituted,
666
+ subSchema as Record<string, any>,
667
+ );
566
668
  for (const issue of issues) {
567
669
  diagnostics.push({
568
670
  severity: DiagnosticSeverity.Error,
569
671
  code: "DEPENDENT_SCHEMA_MISMATCH",
570
672
  source: SOURCE,
571
- message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
572
- data: { resource: resourceData, filePath, path: concretePath },
673
+ message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue.message}`,
674
+ data: {
675
+ resource: resourceData,
676
+ filePath,
677
+ // An index-first sub-path (`[0].content`) joins with no dot.
678
+ path: !issue.path
679
+ ? concretePath
680
+ : issue.path.startsWith("[")
681
+ ? `${concretePath}${issue.path}`
682
+ : `${concretePath}.${issue.path}`,
683
+ },
573
684
  });
574
685
  }
575
686
  }
@@ -0,0 +1,274 @@
1
+ import { nearestName } from "./nearest-name.js";
2
+ import type { ResourceManifest } from "@telorun/sdk";
3
+ import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
4
+ import type { AliasResolver, ModuleScopes } from "./alias-resolver.js";
5
+ import type { DefinitionRegistry } from "./definition-registry.js";
6
+ import { refSentinelTarget } from "./ref-sentinel-target.js";
7
+ import { isRefEntry, resolveFieldEntries, satisfiesValueBranch } from "./reference-field-map.js";
8
+ import { templateBodies } from "./template-body.js";
9
+ import { DiagnosticSeverity, DiagnosticTag, type AnalysisDiagnostic } from "./types.js";
10
+
11
+ const SOURCE = "telo-analyzer";
12
+
13
+ /** The four slots a `Telo.Definition` names its dispatch target in.
14
+ *
15
+ * WHAT THE TARGET'S CAPABILITY IS IS DELIBERATELY NOT CHECKED. The kernel tests
16
+ * METHOD PRESENCE at dispatch (`entry.instance?.invoke`), so a declared
17
+ * capability says nothing about whether a dispatch works — the same structural
18
+ * -versus-nominal split that keeps `use` uncross-constrained against a slot's
19
+ * target. A check here read `Run.Sequence`'s `capability: Telo.Runnable` and
20
+ * rejected `Ai.Buffered`, a shipping module whose sequence implements `invoke()`
21
+ * over an inputs/outputs contract and dispatches correctly. Trimming the refused
22
+ * set would only move that: any controller may expose a method its capability
23
+ * does not name, and a template body is exactly where that is done on purpose. */
24
+ const DISPATCH_SLOTS = ["invoke", "run", "provide", "mount"] as const;
25
+
26
+ /**
27
+ * A TEMPLATE BODY IS WRITTEN LIKE EVERY OTHER MANIFEST, AND CHECKED LIKE ONE.
28
+ *
29
+ * A `Telo.Definition` is in both reference skip sets — its body holds
30
+ * declarations of OTHER kinds, and its dispatch slots carry no `x-telo-ref` — so
31
+ * no reference pass reached it, and each rule about it was added per construct:
32
+ * `provide:` and `mount:` had a target check for the `{ kind, name }` object
33
+ * form, `!ref` had one that switched itself off for any definition with a
34
+ * CEL-named entry, and `invoke:` / `run:` had none. What was tagged was caught
35
+ * and what was a plain name was resolved by the kernel alone. This pass is the
36
+ * one reader of a body's reference surface, and it rests on one spelling:
37
+ *
38
+ * - every `resources:` entry is named by a LITERAL (`TEMPLATE_ENTRY_NAME_DYNAMIC`).
39
+ * Each instance of a template owns its children in a child context of its
40
+ * own, so a per-instance suffix (`self.name + '-query'`) buys nothing — and a
41
+ * `!ref` is looked up verbatim, so a CEL-named sibling is one nothing can name;
42
+ * - a dispatch slot is `!ref <entry>` naming an entry
43
+ * (`TEMPLATE_DISPATCH_UNKNOWN`); the legacy spellings are deprecated rather
44
+ * than refused, since published artifacts carry them. What the target's
45
+ * CAPABILITY is stays unchecked — see `DISPATCH_SLOTS`;
46
+ * - a reference slot INSIDE an entry follows the rule every other slot does —
47
+ * `!ref` or an inline declaration, never `{ kind, name }` or a bare string
48
+ * (`INVALID_REFERENCE_FORM`) — and a bare `!ref` names a sibling or a
49
+ * resource of the defining module (`TEMPLATE_REF_UNKNOWN`). Slots are found
50
+ * through the nested kind's own field map, resolved in the DEFINING module's
51
+ * alias scope, since that is where the body's kinds are written.
52
+ *
53
+ * Entry-module-scoped, like every other declaration check: a published
54
+ * dependency's template body is not the consumer's to fix. Browser-safe.
55
+ */
56
+ export function validateTemplateBody(
57
+ manifests: ResourceManifest[],
58
+ registry: DefinitionRegistry,
59
+ aliases: AliasResolver,
60
+ aliasesByModule: Map<string, AliasResolver>,
61
+ rootModules: ReadonlySet<string>,
62
+ ): AnalysisDiagnostic[] {
63
+ const out: AnalysisDiagnostic[] = [];
64
+ const scopes: ModuleScopes = { aliasesByModule, rootModules };
65
+
66
+ // A definition forwarded from an imported library is that library's to fix;
67
+ // the imports name them (the `validate-extends` rule).
68
+ const importedModules = new Set<string>();
69
+ // Every named instance, by declaring module — what a body's bare `!ref` may
70
+ // reach beside its siblings. A manifest with no stamp belongs to the entry.
71
+ const instancesByModule = new Map<string | undefined, Set<string>>();
72
+ for (const m of manifests) {
73
+ const name = m.metadata?.name;
74
+ if (typeof name !== "string") continue;
75
+ if (m.kind === "Telo.Import") {
76
+ const resolved = (m.metadata as { resolvedModuleName?: string }).resolvedModuleName;
77
+ if (resolved) importedModules.add(resolved);
78
+ continue;
79
+ }
80
+ if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract") continue;
81
+ if (m.kind === "Telo.Library" || m.kind === "Telo.Application") continue;
82
+ const mod = (m.metadata as { module?: string } | undefined)?.module;
83
+ let set = instancesByModule.get(mod);
84
+ if (!set) instancesByModule.set(mod, (set = new Set()));
85
+ set.add(name);
86
+ }
87
+
88
+ for (const m of manifests) {
89
+ if (m.kind !== "Telo.Definition") continue;
90
+ const meta = m.metadata as { name?: string; module?: string; source?: string } | undefined;
91
+ const name = meta?.name;
92
+ if (!name) continue;
93
+ if (meta?.module && importedModules.has(meta.module)) continue;
94
+
95
+ const bodies = (m as Record<string, unknown>).resources;
96
+ const resourceRef = { kind: m.kind, name };
97
+ const filePath = meta?.source;
98
+ const label = `${m.kind}/${name}`;
99
+ const report = (code: string, path: string, message: string, fix?: string) =>
100
+ out.push({
101
+ severity: DiagnosticSeverity.Error,
102
+ code,
103
+ source: SOURCE,
104
+ message: `${label}: ${message}`,
105
+ data: {
106
+ resource: resourceRef,
107
+ filePath,
108
+ path,
109
+ ...(fix ? { fix: { replacement: fix } } : {}),
110
+ },
111
+ });
112
+
113
+ /** A spelling the kernel still READS. Warning-grade because refusing it
114
+ * would break every app pinning a version that carries it, and tagged so an
115
+ * editor strikes it through rather than merely colouring it — the
116
+ * `DEPRECATED_KIND` shape, for the same reason. */
117
+ const deprecate = (code: string, path: string, message: string) =>
118
+ out.push({
119
+ severity: DiagnosticSeverity.Warning,
120
+ code,
121
+ source: SOURCE,
122
+ message: `${label}: ${message}`,
123
+ tags: [DiagnosticTag.Deprecated],
124
+ data: { resource: resourceRef, filePath, path },
125
+ });
126
+
127
+ const entries = Array.isArray(bodies) ? bodies : [];
128
+ const siblings: string[] = [];
129
+ let anyDynamic = false;
130
+ entries.forEach((entry, i) => {
131
+ const entryName = (entry as { metadata?: { name?: unknown } } | undefined)?.metadata?.name;
132
+ if (typeof entryName === "string" && !entryName.includes("${{")) {
133
+ siblings.push(entryName);
134
+ return;
135
+ }
136
+ if (entryName === undefined) return;
137
+ anyDynamic = true;
138
+ deprecate(
139
+ "DEPRECATED_TEMPLATE_ENTRY_NAME",
140
+ `resources[${i}].metadata.name`,
141
+ `a 'resources:' entry named by ${describeDynamic(entryName)} is deprecated — name it ` +
142
+ `with a literal and dispatch to it with '!ref'. Each instance of a template owns its ` +
143
+ `children in a child context of its own, so a per-instance suffix buys nothing, and a ` +
144
+ `'!ref' is looked up verbatim, so nothing can name a computed entry. While one is ` +
145
+ `present the target checks below are switched off for this kind.`,
146
+ );
147
+ });
148
+
149
+ // Siblings PLUS the declaring module's own resources, because that is what
150
+ // the runtime resolves at a reference slot: a body's `!ref` is stamped
151
+ // `{kind, name}` with an empty kind for a non-sibling, and Phase-5 injection
152
+ // dispatches by NAME and recovers the kind from the instance it finds — so
153
+ // `client: !ref moduleClient` in a body reaches the module-level client and
154
+ // works. (A step's `invoke:` is a different path that does NOT: see the
155
+ // `resources` note in `validate-template-body`'s own tests.)
156
+ const reachable = new Set([
157
+ ...siblings,
158
+ ...(instancesByModule.get(meta?.module) ?? []),
159
+ ]);
160
+
161
+ // --- dispatch slots ---
162
+ for (const slot of DISPATCH_SLOTS) {
163
+ const value = (m as Record<string, unknown>)[slot];
164
+ if (value == null) continue;
165
+ if (!isRefSentinel(value)) {
166
+ deprecate(
167
+ "DEPRECATED_TEMPLATE_DISPATCH_FORM",
168
+ slot,
169
+ `'${slot}:' written as ${describeDispatchValue(value)} is deprecated — ` +
170
+ `write '!ref <entry>', the spelling every other reference uses. ` +
171
+ `Available: ${siblings.join(", ") || "(none)"}.`,
172
+ );
173
+ continue;
174
+ }
175
+ const source = value.source;
176
+ const target = source.startsWith("Self.") ? source.slice("Self.".length) : source;
177
+ if (siblings.includes(target)) continue;
178
+ // A dynamic sibling has already been reported, and might be the one
179
+ // meant — a second diagnostic about the same line would be noise.
180
+ if (anyDynamic) continue;
181
+ const suggestion = nearestName(target, siblings);
182
+ report(
183
+ "TEMPLATE_DISPATCH_UNKNOWN",
184
+ slot,
185
+ `'${slot}: !ref ${source}' names no entry in 'resources:'. ` +
186
+ `Available: ${siblings.join(", ") || "(none)"}.` +
187
+ (suggestion ? ` Did you mean '${suggestion}'?` : ""),
188
+ suggestion,
189
+ );
190
+ }
191
+
192
+ // --- reference slots inside each entry ---
193
+ for (const body of templateBodies(m, registry, aliases, scopes)) {
194
+ const kind = body.manifest.kind;
195
+ const fieldMap = registry.expandedFieldMapForResource(
196
+ { ...body.manifest, metadata: { ...(body.manifest.metadata ?? {}), module: meta?.module } },
197
+ aliases,
198
+ aliasesByModule,
199
+ );
200
+ if (!fieldMap) continue;
201
+ for (const [fieldPath, entry] of fieldMap) {
202
+ if (!isRefEntry(entry)) continue;
203
+ for (const site of resolveFieldEntries(body.manifest, fieldPath)) {
204
+ const value = site.value;
205
+ if (value == null) continue;
206
+ const path = `${body.prefix}.${site.path}`;
207
+ if (isTaggedSentinel(value)) {
208
+ const target = refSentinelTarget(value);
209
+ if (!target) continue;
210
+ const { alias, name: refName } = target;
211
+ if (alias && alias !== "Self") continue;
212
+ if (reachable.has(refName)) continue;
213
+ const suggestion = nearestName(refName, [...reachable]);
214
+ report(
215
+ "TEMPLATE_REF_UNKNOWN",
216
+ path,
217
+ `'${site.path}: !ref ${value.source}' on the ${kind} entry names no sibling ` +
218
+ `'resources:' entry and no resource of this module. ` +
219
+ `Siblings: ${siblings.filter((s) => s !== body.manifest.metadata?.name).join(", ") || "(none)"}.` +
220
+ (suggestion ? ` Did you mean '${suggestion}'?` : ""),
221
+ suggestion,
222
+ );
223
+ continue;
224
+ }
225
+ const hasValueBranch = (entry.valueBranches?.length ?? 0) > 0;
226
+ if (hasValueBranch && typeof value !== "object") continue;
227
+ if (satisfiesValueBranch(value, entry.valueBranches, registry)) continue;
228
+ if (typeof value === "string") {
229
+ if (value.includes("${{")) continue;
230
+ report(
231
+ "INVALID_REFERENCE_FORM",
232
+ path,
233
+ `string reference at '${site.path}' on the ${kind} entry → '${value}' is not ` +
234
+ `supported; write it as '!ref ${value}'`,
235
+ );
236
+ continue;
237
+ }
238
+ if (value && typeof value === "object" && !Array.isArray(value)) {
239
+ const obj = value as Record<string, unknown>;
240
+ if (typeof obj.kind === "string" && obj.name !== undefined) {
241
+ report(
242
+ "INVALID_REFERENCE_FORM",
243
+ path,
244
+ `object reference '{ kind, name }' at '${site.path}' on the ${kind} entry is ` +
245
+ `not supported; write it as '!ref ${describeRefName(obj.name)}'`,
246
+ );
247
+ }
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ return out;
255
+ }
256
+
257
+ function describeDynamic(name: unknown): string {
258
+ if (isTaggedSentinel(name)) return `the expression '${name.source}'`;
259
+ if (typeof name === "string") return `the template '${name}'`;
260
+ return `a ${typeof name}`;
261
+ }
262
+
263
+ function describeDispatchValue(value: unknown): string {
264
+ if (isTaggedSentinel(value)) return `a '!${value.engine}' expression`;
265
+ if (typeof value === "string") return `the string '${value}'`;
266
+ if (value && typeof value === "object" && "name" in value) return "the '{ kind, name }' object form";
267
+ return `a ${typeof value}`;
268
+ }
269
+
270
+ function describeRefName(name: unknown): string {
271
+ if (typeof name === "string") return name;
272
+ if (isTaggedSentinel(name)) return "<entry>";
273
+ return String(name);
274
+ }