@telorun/analyzer 0.65.0 → 0.67.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 (142) hide show
  1. package/dist/analyzer.d.ts +1 -1
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +194 -65
  4. package/dist/builtins.d.ts.map +1 -1
  5. package/dist/builtins.js +67 -16
  6. package/dist/call-graph.d.ts.map +1 -1
  7. package/dist/call-graph.js +7 -1
  8. package/dist/cel-environment.d.ts +12 -0
  9. package/dist/cel-environment.d.ts.map +1 -1
  10. package/dist/cel-environment.js +35 -19
  11. package/dist/cel-scope.d.ts +8 -0
  12. package/dist/cel-scope.d.ts.map +1 -1
  13. package/dist/cel-scope.js +89 -11
  14. package/dist/definition-registry.d.ts +41 -0
  15. package/dist/definition-registry.d.ts.map +1 -1
  16. package/dist/definition-registry.js +76 -0
  17. package/dist/dependency-graph.d.ts.map +1 -1
  18. package/dist/dependency-graph.js +65 -0
  19. package/dist/eval-paths.d.ts +68 -0
  20. package/dist/eval-paths.d.ts.map +1 -1
  21. package/dist/eval-paths.js +138 -0
  22. package/dist/flatten-for-analyzer.d.ts +37 -1
  23. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  24. package/dist/flatten-for-analyzer.js +104 -5
  25. package/dist/import-resolution-diagnostics.d.ts +1 -1
  26. package/dist/import-resolution-diagnostics.js +1 -1
  27. package/dist/index.d.ts +12 -8
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +8 -6
  30. package/dist/inline-imports.d.ts.map +1 -1
  31. package/dist/inline-imports.js +1 -0
  32. package/dist/manifest-visitor.d.ts +4 -0
  33. package/dist/manifest-visitor.d.ts.map +1 -1
  34. package/dist/manifest-visitor.js +36 -1
  35. package/dist/migrations/entries/index.d.ts.map +1 -1
  36. package/dist/migrations/entries/index.js +2 -0
  37. package/dist/migrations/entries/schema-prepare-bucket.json +27 -0
  38. package/dist/peer-binding.d.ts +232 -0
  39. package/dist/peer-binding.d.ts.map +1 -0
  40. package/dist/peer-binding.js +418 -0
  41. package/dist/precompile.d.ts.map +1 -1
  42. package/dist/precompile.js +8 -0
  43. package/dist/ref-slot.d.ts +13 -0
  44. package/dist/ref-slot.d.ts.map +1 -1
  45. package/dist/ref-slot.js +26 -0
  46. package/dist/reference-field-map.d.ts +28 -0
  47. package/dist/reference-field-map.d.ts.map +1 -1
  48. package/dist/reference-field-map.js +25 -0
  49. package/dist/referrer-rule.d.ts +17 -0
  50. package/dist/referrer-rule.d.ts.map +1 -1
  51. package/dist/referrer-rule.js +4 -1
  52. package/dist/release/index.d.ts +1 -1
  53. package/dist/release/index.js +1 -1
  54. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  55. package/dist/resolve-ref-sentinels.js +14 -1
  56. package/dist/resource-input.d.ts +75 -0
  57. package/dist/resource-input.d.ts.map +1 -0
  58. package/dist/resource-input.js +90 -0
  59. package/dist/resource-rule.d.ts +40 -27
  60. package/dist/resource-rule.d.ts.map +1 -1
  61. package/dist/resource-rule.js +61 -7
  62. package/dist/rule-condition.d.ts +11 -0
  63. package/dist/rule-condition.d.ts.map +1 -1
  64. package/dist/rule-condition.js +13 -0
  65. package/dist/schema-projection.d.ts +119 -4
  66. package/dist/schema-projection.d.ts.map +1 -1
  67. package/dist/schema-projection.js +213 -18
  68. package/dist/system-kinds.d.ts +7 -2
  69. package/dist/system-kinds.d.ts.map +1 -1
  70. package/dist/system-kinds.js +7 -2
  71. package/dist/telo-version.d.ts +1 -1
  72. package/dist/telo-version.js +1 -1
  73. package/dist/template-body.d.ts +50 -0
  74. package/dist/template-body.d.ts.map +1 -0
  75. package/dist/template-body.js +58 -0
  76. package/dist/validate-cel-context.d.ts +1 -17
  77. package/dist/validate-cel-context.d.ts.map +1 -1
  78. package/dist/validate-cel-context.js +73 -78
  79. package/dist/validate-identifier-names.d.ts.map +1 -1
  80. package/dist/validate-identifier-names.js +17 -2
  81. package/dist/validate-reference-forms.d.ts +10 -0
  82. package/dist/validate-reference-forms.d.ts.map +1 -1
  83. package/dist/validate-reference-forms.js +23 -0
  84. package/dist/validate-references.d.ts +17 -0
  85. package/dist/validate-references.d.ts.map +1 -1
  86. package/dist/validate-references.js +77 -18
  87. package/dist/validate-referrer-rules.d.ts +38 -4
  88. package/dist/validate-referrer-rules.d.ts.map +1 -1
  89. package/dist/validate-referrer-rules.js +139 -31
  90. package/dist/validate-resource-inputs.d.ts +35 -0
  91. package/dist/validate-resource-inputs.d.ts.map +1 -0
  92. package/dist/validate-resource-inputs.js +319 -0
  93. package/dist/validate-resource-rules.d.ts +11 -2
  94. package/dist/validate-resource-rules.d.ts.map +1 -1
  95. package/dist/validate-resource-rules.js +18 -12
  96. package/dist/validate-schema-projection.d.ts.map +1 -1
  97. package/dist/validate-schema-projection.js +33 -2
  98. package/dist/validate-template-dispatch.d.ts +27 -0
  99. package/dist/validate-template-dispatch.d.ts.map +1 -0
  100. package/dist/validate-template-dispatch.js +95 -0
  101. package/dist/with-synthetic-positions.d.ts +1 -1
  102. package/dist/with-synthetic-positions.js +1 -1
  103. package/package.json +3 -3
  104. package/src/analyzer.ts +236 -70
  105. package/src/builtins.ts +69 -16
  106. package/src/call-graph.ts +7 -1
  107. package/src/cel-environment.ts +48 -19
  108. package/src/cel-scope.ts +113 -19
  109. package/src/definition-registry.ts +78 -0
  110. package/src/dependency-graph.ts +66 -0
  111. package/src/eval-paths.ts +152 -0
  112. package/src/flatten-for-analyzer.ts +117 -4
  113. package/src/import-resolution-diagnostics.ts +1 -1
  114. package/src/index.ts +57 -4
  115. package/src/inline-imports.ts +1 -0
  116. package/src/manifest-visitor.ts +41 -1
  117. package/src/migrations/entries/index.ts +2 -0
  118. package/src/migrations/entries/schema-prepare-bucket.json +27 -0
  119. package/src/peer-binding.ts +641 -0
  120. package/src/precompile.ts +8 -0
  121. package/src/ref-slot.ts +36 -0
  122. package/src/reference-field-map.ts +42 -0
  123. package/src/referrer-rule.ts +20 -1
  124. package/src/release/index.ts +1 -1
  125. package/src/resolve-ref-sentinels.ts +12 -1
  126. package/src/resource-input.ts +132 -0
  127. package/src/resource-rule.ts +69 -7
  128. package/src/rule-condition.ts +15 -0
  129. package/src/schema-projection.ts +302 -20
  130. package/src/system-kinds.ts +7 -2
  131. package/src/telo-version.ts +1 -1
  132. package/src/template-body.ts +104 -0
  133. package/src/validate-cel-context.ts +73 -78
  134. package/src/validate-identifier-names.ts +18 -3
  135. package/src/validate-reference-forms.ts +22 -0
  136. package/src/validate-references.ts +85 -16
  137. package/src/validate-referrer-rules.ts +215 -28
  138. package/src/validate-resource-inputs.ts +367 -0
  139. package/src/validate-resource-rules.ts +26 -12
  140. package/src/validate-schema-projection.ts +50 -1
  141. package/src/validate-template-dispatch.ts +99 -0
  142. package/src/with-synthetic-positions.ts +1 -1
@@ -0,0 +1,367 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isRefSentinel } from "@telorun/templating";
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { findDynamicLeaf } from "./resource-rule.js";
6
+ import { readResourceInputs, readSuppliedResources } from "./resource-input.js";
7
+ import { type AnalysisDiagnostic, DiagnosticSeverity } from "./types.js";
8
+
9
+ const SOURCE = "telo-analyzer";
10
+
11
+ /** A target library's declared `resources:` block, canonicalized in that
12
+ * library's own alias scope and stamped onto the `Telo.Import` by
13
+ * `stampRequiredResources`: entry name → canonical kind. */
14
+ type RequiredResources = Record<string, string>;
15
+
16
+ /**
17
+ * The strict half of {@link readResourceInputs} — the `validate-ref-slots.ts`
18
+ * split applied to the resource-input boundary, and not optional for the same
19
+ * reason: the two sides fail in OPPOSITE directions. An unreadable DECLARATION
20
+ * leaves the library referencing a name nothing stands behind; an unchecked
21
+ * INJECTION hands a library an instance of a kind it never asked for, which
22
+ * surfaces as a method-missing failure inside someone else's module.
23
+ *
24
+ * Two surfaces, scoped the same way every other declaration check is — to the
25
+ * entry's own modules, since a published dependency's block is not the
26
+ * consumer's to fix:
27
+ *
28
+ * - **The library's own declaration.** Each entry's alias-qualified `kind:` must
29
+ * resolve in the DECLARING module's scope (`RESOURCE_INPUT_KIND_UNRESOLVED`).
30
+ * A constraint that resolves to nothing accepts anything, which is the same
31
+ * hole `X_TELO_REF_UNRESOLVED` exists to close one level down.
32
+ * - **The injection site.** Every declared entry must be supplied
33
+ * (`RESOURCE_INPUT_MISSING` — the same failure as a missing required
34
+ * variable), nothing beyond them may be (`RESOURCE_INPUT_UNKNOWN`), the value
35
+ * must name a resource that exists (`RESOURCE_INPUT_UNRESOLVED`), and its
36
+ * kind must satisfy the declared constraint transitively
37
+ * (`RESOURCE_INPUT_KIND_MISMATCH`).
38
+ *
39
+ * Browser-safe.
40
+ */
41
+ export function validateResourceInputs(
42
+ manifests: ResourceManifest[],
43
+ registry: DefinitionRegistry,
44
+ aliases: AliasResolver,
45
+ rootModules: Set<string>,
46
+ /** Kind acceptance, transitively — the `checkKind` rule `validate-references`
47
+ * applies at an ordinary ref slot, passed in rather than re-derived so the
48
+ * two cannot disagree about what satisfies a constraint. */
49
+ acceptsKind: (suppliedKind: string, requiredKind: string) => boolean,
50
+ ): AnalysisDiagnostic[] {
51
+ const out: AnalysisDiagnostic[] = [];
52
+
53
+ const isOwn = (module: string | undefined): boolean => !module || rootModules.has(module);
54
+
55
+ // --- the library's own declaration -------------------------------------
56
+ for (const m of manifests) {
57
+ if (m.kind !== "Telo.Library") continue;
58
+ const moduleName = m.metadata?.name as string | undefined;
59
+ // Only the entry's own modules, so the declaring scope is always the root
60
+ // alias table — a library analyzed here IS a root module.
61
+ if (!isOwn(moduleName)) continue;
62
+ const scope = aliases;
63
+ const exported = new Set(
64
+ (((m as Record<string, any>).exports?.resources ?? []) as unknown[]).filter(
65
+ (e): e is string => typeof e === "string",
66
+ ),
67
+ );
68
+ for (const input of readResourceInputs(m)) {
69
+ // An input is the IMPORTER's instance, borrowed. Exporting it back out
70
+ // would forward a kind-only stand-in into the consumer's flattened set as
71
+ // a resource this library declares — a phantom the consumer can `!ref`
72
+ // and whose kind constraint is all there is behind it. Handing an
73
+ // instance straight back to whoever supplied it is also a relation
74
+ // nothing needs; if it ever does, it should be a decision rather than a
75
+ // name collision nobody noticed.
76
+ if (exported.has(input.name)) {
77
+ out.push({
78
+ severity: DiagnosticSeverity.Error,
79
+ code: "RESOURCE_INPUT_EXPORTED",
80
+ source: SOURCE,
81
+ message:
82
+ `Resource input '${input.name}' is also listed in 'exports.resources'. An input is ` +
83
+ `an instance the importer supplies, not one this library declares, so there is ` +
84
+ `nothing to export — remove it from 'exports.resources', or rename the input.`,
85
+ data: {
86
+ resource: { kind: m.kind, name: moduleName as string },
87
+ filePath: (m.metadata as { source?: string } | undefined)?.source,
88
+ path: `resources.${input.name}`,
89
+ },
90
+ });
91
+ }
92
+ if (registry.resolve(input.kind) ?? registry.resolve(scope.resolveKind(input.kind) ?? "")) {
93
+ continue;
94
+ }
95
+ out.push({
96
+ severity: DiagnosticSeverity.Error,
97
+ code: "RESOURCE_INPUT_KIND_UNRESOLVED",
98
+ source: SOURCE,
99
+ message:
100
+ `Resource input '${input.name}' is constrained to kind '${input.kind}', which does not ` +
101
+ `resolve in this library's scope. Write it alias-qualified — '<Alias>.<Kind>' for an ` +
102
+ `import declared in this file, 'Self.<Kind>' for a kind this library owns, or ` +
103
+ `'Telo.<Kind>' for a built-in. An unresolvable constraint accepts anything.`,
104
+ data: {
105
+ resource: { kind: m.kind, name: moduleName as string },
106
+ filePath: (m.metadata as { source?: string } | undefined)?.source,
107
+ path: `resources.${input.name}.kind`,
108
+ },
109
+ });
110
+ }
111
+ }
112
+
113
+ // --- the injection site --------------------------------------------------
114
+ for (const m of manifests) {
115
+ if (m.kind !== "Telo.Import") continue;
116
+ const meta = m.metadata as
117
+ | {
118
+ name?: string;
119
+ module?: string;
120
+ source?: string;
121
+ resolvedModuleName?: string;
122
+ requiredResources?: RequiredResources;
123
+ sharedLibrary?: boolean;
124
+ resolvedSource?: string;
125
+ }
126
+ | undefined;
127
+ const alias = meta?.name;
128
+ if (!alias || !isOwn(meta?.module)) continue;
129
+ // An import whose target identity was never established registers nothing —
130
+ // reporting a missing input against it would blame the author for a
131
+ // dependency the loader has already said it could not obtain.
132
+ if (!meta?.resolvedModuleName) continue;
133
+
134
+ // A singleton has no room for a per-import override: `logging:` scopes a
135
+ // subtree that is no longer this import's subtree, and `runtime:` selects a
136
+ // controller backend for a library that is instantiated once. Whichever
137
+ // import happened to be created first would decide, so the field is
138
+ // rejected rather than silently applied to everyone or silently dropped.
139
+ if (meta.sharedLibrary === true) {
140
+ for (const field of ["logging", "runtime"] as const) {
141
+ if ((m as Record<string, unknown>)[field] === undefined) continue;
142
+ out.push({
143
+ severity: DiagnosticSeverity.Error,
144
+ code: "SHARED_LIBRARY_OVERRIDE",
145
+ source: SOURCE,
146
+ message:
147
+ `Import '${alias}' declares '${field}:', but module ` +
148
+ `'${meta.resolvedModuleName}' is 'lifecycle: shared' — one instantiation for the ` +
149
+ `whole application, so a per-import override cannot apply to it. Remove it, or ` +
150
+ `make the library 'lifecycle: isolated'.`,
151
+ data: {
152
+ resource: { kind: m.kind, name: alias },
153
+ filePath: meta.source,
154
+ path: field,
155
+ },
156
+ });
157
+ }
158
+ }
159
+
160
+ const required = Object.entries(meta.requiredResources ?? {});
161
+ const supplied = readSuppliedResources(m);
162
+ const declared = new Map(required);
163
+ const resource = { kind: m.kind, name: alias };
164
+ const filePath = meta.source;
165
+
166
+ for (const [entryName, entryKind] of required) {
167
+ if (entryName in supplied) continue;
168
+ out.push({
169
+ severity: DiagnosticSeverity.Error,
170
+ code: "RESOURCE_INPUT_MISSING",
171
+ source: SOURCE,
172
+ message:
173
+ `Import '${alias}' does not supply the resource input '${entryName}', which module ` +
174
+ `'${meta.resolvedModuleName}' requires (kind '${entryKind}'). Add ` +
175
+ `\`resources: { ${entryName}: !ref <name> }\` to the import.`,
176
+ data: { resource, filePath, path: "resources" },
177
+ });
178
+ }
179
+
180
+ for (const [name, value] of Object.entries(supplied)) {
181
+ const path = `resources.${name}`;
182
+ const entry = declared.get(name);
183
+ if (!entry) {
184
+ const known = required.map(([n]) => n).join(", ") || "(none)";
185
+ out.push({
186
+ severity: DiagnosticSeverity.Error,
187
+ code: "RESOURCE_INPUT_UNKNOWN",
188
+ source: SOURCE,
189
+ message:
190
+ `Import '${alias}' supplies a resource input '${name}', which module ` +
191
+ `'${meta.resolvedModuleName}' does not declare. Declared inputs: ${known}.`,
192
+ data: { resource, filePath, path },
193
+ });
194
+ continue;
195
+ }
196
+ // Phase 2.5 rewrote a resolvable `!ref` to `{kind, name}`; a sentinel that
197
+ // survived names nothing the importer can reach.
198
+ if (isRefSentinel(value)) {
199
+ out.push({
200
+ severity: DiagnosticSeverity.Error,
201
+ code: "RESOURCE_INPUT_UNRESOLVED",
202
+ source: SOURCE,
203
+ message: `Import '${alias}': resource input '${name}' → resource '${value.source}' not found`,
204
+ data: { resource, filePath, path },
205
+ });
206
+ continue;
207
+ }
208
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
209
+ out.push({
210
+ severity: DiagnosticSeverity.Error,
211
+ code: "RESOURCE_INPUT_UNRESOLVED",
212
+ source: SOURCE,
213
+ message:
214
+ `Import '${alias}': resource input '${name}' must be a '!ref' to a resource this ` +
215
+ `module declares.`,
216
+ data: { resource, filePath, path },
217
+ });
218
+ continue;
219
+ }
220
+ const suppliedKind = (value as { kind?: unknown }).kind;
221
+ if (typeof suppliedKind !== "string") continue;
222
+ const canonical = aliases.resolveKind(suppliedKind) ?? suppliedKind;
223
+ if (acceptsKind(canonical, entry)) continue;
224
+ out.push({
225
+ severity: DiagnosticSeverity.Error,
226
+ code: "RESOURCE_INPUT_KIND_MISMATCH",
227
+ source: SOURCE,
228
+ message:
229
+ `Import '${alias}': resource input '${name}' is '${suppliedKind}' (resolved: ` +
230
+ `'${canonical}'), which does not satisfy the declared constraint '${entry}'.`,
231
+ data: { resource, filePath, path },
232
+ });
233
+ }
234
+ }
235
+
236
+ out.push(...sharedLibraryConflicts(manifests, isOwn));
237
+
238
+ return out;
239
+ }
240
+
241
+ /** One import's supplied configuration, as the conflict check compares it. */
242
+ interface SuppliedConfig {
243
+ readonly alias: string;
244
+ readonly module: string | undefined;
245
+ readonly filePath: string | undefined;
246
+ readonly blocks: ReadonlyMap<string, Record<string, unknown>>;
247
+ }
248
+
249
+ const SUPPLIED_BLOCKS = ["variables", "secrets", "resources"] as const;
250
+
251
+ /**
252
+ * Two imports of ONE `lifecycle: shared` library supplying different values.
253
+ *
254
+ * The runtime half (`ERR_SHARED_LIBRARY_CONFLICT`) is authoritative — it holds
255
+ * resolved values and live instances, and a library reached through a
256
+ * programmatic load never passed `telo check`. But the overwhelmingly common
257
+ * shape is two literal scalars in one flattened manifest set, which is decidable
258
+ * here, and a conflict is a hard BOOT failure: finding it at `telo check` is the
259
+ * difference between a squiggle and a deployment that will not start.
260
+ *
261
+ * Only what is DECIDABLE is compared, and the two ways it is not are skipped
262
+ * rather than guessed:
263
+ *
264
+ * - A value holding a `!cel` (at any depth) is known only at load. Two
265
+ * different expressions may evaluate equal, and identical text in two
266
+ * different modules may not.
267
+ * - A `resources:` entry names a resource by name, which means the same
268
+ * instance only when both imports were DECLARED in the same module — so a
269
+ * cross-module pair is left to the runtime.
270
+ *
271
+ * Grouped by the target's RESOLVED SOURCE, the identity the kernel keys its
272
+ * singleton registry on; `resolvedModuleName` would collapse two versions of one
273
+ * module, which are two libraries.
274
+ */
275
+ function sharedLibraryConflicts(
276
+ manifests: ResourceManifest[],
277
+ isOwn: (module: string | undefined) => boolean,
278
+ ): AnalysisDiagnostic[] {
279
+ const out: AnalysisDiagnostic[] = [];
280
+ const byTarget = new Map<string, SuppliedConfig[]>();
281
+
282
+ for (const m of manifests) {
283
+ if (m.kind !== "Telo.Import") continue;
284
+ const meta = m.metadata as
285
+ | { name?: string; module?: string; source?: string; sharedLibrary?: boolean; resolvedSource?: string }
286
+ | undefined;
287
+ if (meta?.sharedLibrary !== true || !meta.name || !meta.resolvedSource) continue;
288
+ const blocks = new Map<string, Record<string, unknown>>();
289
+ for (const block of SUPPLIED_BLOCKS) {
290
+ const value = (m as Record<string, unknown>)[block];
291
+ blocks.set(
292
+ block,
293
+ value && typeof value === "object" && !Array.isArray(value)
294
+ ? (value as Record<string, unknown>)
295
+ : {},
296
+ );
297
+ }
298
+ const bucket = byTarget.get(meta.resolvedSource);
299
+ const entry: SuppliedConfig = {
300
+ alias: meta.name,
301
+ module: meta.module,
302
+ filePath: meta.source,
303
+ blocks,
304
+ };
305
+ if (bucket) bucket.push(entry);
306
+ else byTarget.set(meta.resolvedSource, [entry]);
307
+ }
308
+
309
+ for (const group of byTarget.values()) {
310
+ if (group.length < 2) continue;
311
+ const [first, ...rest] = group;
312
+ for (const later of rest) {
313
+ for (const block of SUPPLIED_BLOCKS) {
314
+ // A reference means the same instance only within one module's scope.
315
+ if (block === "resources" && first!.module !== later.module) continue;
316
+ const a = first!.blocks.get(block)!;
317
+ const b = later.blocks.get(block)!;
318
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
319
+ if (findDynamicLeaf(a[key]) || findDynamicLeaf(b[key])) continue;
320
+ if (sameSuppliedValue(a[key], b[key])) continue;
321
+ out.push({
322
+ severity: DiagnosticSeverity.Error,
323
+ code: "SHARED_LIBRARY_CONFLICT",
324
+ source: SOURCE,
325
+ message:
326
+ `Import '${later.alias}' and import '${first!.alias}' both reach a ` +
327
+ `'lifecycle: shared' library — one instantiation for the whole application — but ` +
328
+ `they supply different values for ${block}.${key}. Make the two imports agree, or ` +
329
+ `make the library 'lifecycle: isolated'.`,
330
+ data: isOwn(later.module)
331
+ ? {
332
+ resource: { kind: "Telo.Import", name: later.alias },
333
+ filePath: later.filePath,
334
+ path: `${block}.${key}`,
335
+ }
336
+ : {
337
+ resource: { kind: "Telo.Import", name: first!.alias },
338
+ filePath: first!.filePath,
339
+ path: `${block}.${key}`,
340
+ },
341
+ });
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ return out;
348
+ }
349
+
350
+ /** Structural equality, key-order insensitive — the analyzer's half of the rule
351
+ * the kernel applies to resolved values. A secret's VALUE is compared but never
352
+ * printed: the key is what the author has to look at. */
353
+ function sameSuppliedValue(a: unknown, b: unknown): boolean {
354
+ if (a === b) return true;
355
+ if (a === undefined || b === undefined || a === null || b === null) return false;
356
+ if (Array.isArray(a) || Array.isArray(b)) {
357
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
358
+ return a.every((item, i) => sameSuppliedValue(item, b[i]));
359
+ }
360
+ if (typeof a === "object" && typeof b === "object") {
361
+ const left = a as Record<string, unknown>;
362
+ const right = b as Record<string, unknown>;
363
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
364
+ return [...keys].every((key) => sameSuppliedValue(left[key], right[key]));
365
+ }
366
+ return false;
367
+ }
@@ -22,11 +22,18 @@
22
22
  * Browser-safe: no Node built-ins.
23
23
  */
24
24
  import type { ResourceManifest } from "@telorun/sdk";
25
- import { RULE_BUDGET_MS, compileRuleCondition, conditionRefusals } from "./rule-condition.js";
25
+ import {
26
+ RULE_BUDGET_MS,
27
+ UNTAGGED_CONDITION,
28
+ compileRuleCondition,
29
+ conditionRefusals,
30
+ } from "./rule-condition.js";
26
31
  import {
27
32
  RESOURCE_RULES_ANNOTATION,
28
33
  celSourceOf,
29
34
  findDynamicLeaf,
35
+ type DynamicLeaf,
36
+ isTaggedCondition,
30
37
  pointerSegments,
31
38
  readRawResourceRules,
32
39
  readResourceRules,
@@ -54,7 +61,7 @@ export type ResourceRuleFinding =
54
61
  path: string;
55
62
  message: string;
56
63
  }
57
- | { kind: "skipped"; rule: ResourceRule; path: string; dynamicAt: string }
64
+ | { kind: "skipped"; rule: ResourceRule; path: string; dynamic: DynamicLeaf }
58
65
  | { kind: "failed"; rule: ResourceRule; path: string; reason: string }
59
66
  | { kind: "over-budget"; rule: ResourceRule; path: string; elapsedMs: number };
60
67
 
@@ -63,8 +70,11 @@ function isObject(value: unknown): value is Record<string, unknown> {
63
70
  }
64
71
 
65
72
  /** Navigate a kind's own schema to the node describing what a pointer names, so
66
- * an `in:` naming a field the kind does not declare is caught at the kind. */
67
- function schemaAtPointer(schema: unknown, pointer: string): unknown {
73
+ * an `in:` naming a field the kind does not declare is caught at the kind.
74
+ * Exported because the peer-rule strict half asks the same question of the
75
+ * REFERRER's schema, and two navigators would eventually disagree about what a
76
+ * pointer names. */
77
+ export function schemaAtPointer(schema: unknown, pointer: string): unknown {
68
78
  const segments = pointerSegments(pointer);
69
79
  if (!segments) return undefined;
70
80
  let node: unknown = schema;
@@ -83,7 +93,7 @@ function schemaAtPointer(schema: unknown, pointer: string): unknown {
83
93
 
84
94
  /** True when a schema node describes something a rule can iterate. Unknown or
85
95
  * absent `type` passes: an open schema is not evidence of a defect. */
86
- function isIterableSchema(node: unknown): boolean {
96
+ export function isIterableSchema(node: unknown): boolean {
87
97
  if (!isObject(node)) return true;
88
98
  const type = node.type;
89
99
  if (type === undefined) return true;
@@ -198,6 +208,10 @@ export function validateResourceRuleDeclarations(
198
208
  }
199
209
  }
200
210
 
211
+ if (condition !== undefined && condition.length > 0 && !isTaggedCondition(entry.condition)) {
212
+ issue(`${at}.condition`, UNTAGGED_CONDITION);
213
+ }
214
+
201
215
  if (condition) {
202
216
  for (const refusal of conditionRefusals(condition)) issue(`${at}.condition`, refusal);
203
217
  }
@@ -243,13 +257,13 @@ export function evaluateResourceRules(
243
257
  // Only the nodes this condition READS decide whether it can run — see
244
258
  // `readNodes`. Scanning the whole subject would disable every
245
259
  // resource-wide rule on any manifest containing one unrelated expression.
246
- let dynamicAt: string | undefined;
260
+ let dynamic: DynamicLeaf | undefined;
247
261
  for (const node of readNodes(chains, { self, this: subject.value })) {
248
- dynamicAt = findDynamicLeaf(node);
249
- if (dynamicAt !== undefined) break;
262
+ dynamic = findDynamicLeaf(node);
263
+ if (dynamic !== undefined) break;
250
264
  }
251
- if (dynamicAt !== undefined) {
252
- findings.push({ kind: "skipped", rule, path: subject.path, dynamicAt });
265
+ if (dynamic !== undefined) {
266
+ findings.push({ kind: "skipped", rule, path: subject.path, dynamic });
253
267
  continue;
254
268
  }
255
269
  let held: unknown;
@@ -347,8 +361,8 @@ export function reportResourceRules(
347
361
  severity: "information",
348
362
  message:
349
363
  `${manifest.kind}/${name}: rule '${finding.rule.code}' did not run` +
350
- `${at ? ` at '${at}'` : ""} — the value holds a CEL expression at ` +
351
- `'${finding.dynamicAt}', which is not known until the resource is created. ` +
364
+ `${at ? ` at '${at}'` : ""} — the value holds ${finding.dynamic.what} at ` +
365
+ `'${finding.dynamic.path}', which is not known until the resource is created. ` +
352
366
  "Reported rather than dropped: a check whose coverage varies invisibly reads as passing.",
353
367
  manifest,
354
368
  path: at,
@@ -26,6 +26,7 @@ import {
26
26
  projectionKeyMap,
27
27
  rawSchemaProjection,
28
28
  readSchemaProjection,
29
+ schemaMapBranch,
29
30
  schemaProjectionIsMisplaced,
30
31
  } from "./schema-projection.js";
31
32
 
@@ -118,6 +119,48 @@ export function validateSchemaProjection(manifest: ResourceManifest): SchemaProj
118
119
  ];
119
120
  }
120
121
 
122
+ const reference = raw.reference;
123
+ if (reference !== undefined) {
124
+ if (!isObject(reference)) {
125
+ issues.push(
126
+ issue(
127
+ "SCHEMA_PROJECTION_INVALID",
128
+ `${base}.reference`,
129
+ `'reference' says how an entry whose '${projection.key}' holds a REFERENCE projects. ` +
130
+ `It is an object naming 'from' (the target field to read), 'keyword' (the schema ` +
131
+ `keyword its values become) and one of 'base' / 'baseFrom'.`,
132
+ ),
133
+ );
134
+ } else {
135
+ const absent = ["from", "keyword"].filter((f) => typeof reference[f] !== "string");
136
+ if (absent.length > 0) {
137
+ issues.push(
138
+ issue(
139
+ "SCHEMA_PROJECTION_INVALID",
140
+ `${base}.reference`,
141
+ `'reference' is missing ${absent.map((f) => `'${f}'`).join(" and ")}. Without them a ` +
142
+ `referenced entry projects to nothing, so it vanishes from every consumer's view.`,
143
+ ),
144
+ );
145
+ }
146
+ const bases = ["base", "baseFrom"].filter((f) => reference[f] !== undefined);
147
+ if (bases.length !== 1) {
148
+ issues.push(
149
+ issue(
150
+ "SCHEMA_PROJECTION_INVALID",
151
+ `${base}.reference`,
152
+ bases.length === 0
153
+ ? `'reference' declares neither 'base' nor 'baseFrom', so the projected node has no ` +
154
+ `type. Write 'base' where the named type IS its own base type, or 'baseFrom' ` +
155
+ `naming the target field that holds a value of this kind's own map.`
156
+ : `'reference' declares both 'base' and 'baseFrom'. They are two answers to one ` +
157
+ `question — where the projected node's type comes from — so declare exactly one.`,
158
+ ),
159
+ );
160
+ }
161
+ }
162
+ }
163
+
121
164
  const map = projectionKeyMap(schema, projection);
122
165
  if (!map) {
123
166
  return [
@@ -180,5 +223,11 @@ function keySchema(
180
223
  : undefined;
181
224
  if (!isObject(entry) || !isObject(entry.properties)) return undefined;
182
225
  const field = entry.properties[key];
183
- return isObject(field) ? field : undefined;
226
+ if (!isObject(field)) return undefined;
227
+ // The vocabulary may sit on a BRANCH — a slot unioning a closed value set with
228
+ // a reference — and the branch carrying the map is the one whose `enum` the map
229
+ // has to answer for. Reading the union node instead finds no `enum` at all, so
230
+ // the completeness check would silently stop running exactly where the two
231
+ // halves can disagree. Asked through the single reader, never re-derived here.
232
+ return schemaMapBranch(field) ?? field;
184
233
  }
@@ -0,0 +1,99 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isRefSentinel } from "@telorun/templating";
3
+ import { distance } from "./levenshtein.js";
4
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
5
+
6
+ const SOURCE = "telo-analyzer";
7
+
8
+ /** The four slots a `Telo.Definition` names its dispatch target in. Each takes
9
+ * the same grammar; which one is legal for a given capability is
10
+ * `validate-provider-coherence`'s question, not this one's. */
11
+ const DISPATCH_FIELDS = ["invoke", "run", "provide", "mount"] as const;
12
+
13
+ /**
14
+ * A `!ref` at a definition's dispatch slot must name a sibling `resources:`
15
+ * entry.
16
+ *
17
+ * `!ref` is the one spelling an author — and the editor's rename, completion and
18
+ * go-to-definition — expects to be RESOLVED. But a `Telo.Definition` is in both
19
+ * `REF_VALIDATION_SKIP_KINDS` and `REF_RESOLUTION_SKIP_KINDS`, and the dispatch
20
+ * slots carry no `x-telo-ref` (the accepted targets are the definition's own
21
+ * template-internal entries, not resources of any module), so no reference pass
22
+ * reaches them. Introducing the tag at a slot nothing resolves would be worse
23
+ * than the string form it replaces: a typo that used to be an obvious runtime
24
+ * miss becomes a typo in a construct that advertises static resolution.
25
+ *
26
+ * Decidable only when every sibling name is LITERAL. A template routinely names
27
+ * its entries with CEL (`name: !cel "self.name + '-query'"`), and an expression
28
+ * could expand to the referenced name — so one dynamic sibling switches the
29
+ * check off for that definition rather than inventing a miss.
30
+ *
31
+ * Entry-module-scoped, like every other declaration check: a published
32
+ * dependency's template body is not the consumer's to fix.
33
+ *
34
+ * Browser-safe.
35
+ */
36
+ export function validateTemplateDispatch(
37
+ manifests: ResourceManifest[],
38
+ rootModules: ReadonlySet<string>,
39
+ ): AnalysisDiagnostic[] {
40
+ const out: AnalysisDiagnostic[] = [];
41
+
42
+ for (const m of manifests) {
43
+ if (m.kind !== "Telo.Definition") continue;
44
+ const meta = m.metadata as { name?: string; module?: string; source?: string } | undefined;
45
+ const name = meta?.name;
46
+ if (!name) continue;
47
+ if (meta?.module && !rootModules.has(meta.module)) continue;
48
+
49
+ const bodies = (m as Record<string, unknown>).resources;
50
+ if (!Array.isArray(bodies)) continue;
51
+
52
+ const siblings: string[] = [];
53
+ let anyDynamic = false;
54
+ for (const entry of bodies) {
55
+ const entryName = (entry as { metadata?: { name?: unknown } } | undefined)?.metadata?.name;
56
+ if (typeof entryName === "string" && !entryName.includes("${{")) siblings.push(entryName);
57
+ else if (entryName !== undefined) anyDynamic = true;
58
+ }
59
+ if (anyDynamic) continue;
60
+
61
+ for (const field of DISPATCH_FIELDS) {
62
+ const value = (m as Record<string, unknown>)[field];
63
+ if (!isRefSentinel(value)) continue;
64
+ const source = value.source;
65
+ const target = source.startsWith("Self.") ? source.slice("Self.".length) : source;
66
+ if (siblings.includes(target)) continue;
67
+ const suggestion = nearest(target, siblings);
68
+ out.push({
69
+ severity: DiagnosticSeverity.Error,
70
+ code: "TEMPLATE_DISPATCH_UNKNOWN",
71
+ source: SOURCE,
72
+ message:
73
+ `${m.kind}/${name}: '${field}: !ref ${source}' names no entry in 'resources:'. ` +
74
+ `Available: ${siblings.join(", ") || "(none)"}.` +
75
+ (suggestion ? ` Did you mean '${suggestion}'?` : ""),
76
+ data: {
77
+ resource: { kind: m.kind, name },
78
+ filePath: meta?.source,
79
+ path: field,
80
+ ...(suggestion ? { fix: { replacement: suggestion } } : {}),
81
+ },
82
+ });
83
+ }
84
+ }
85
+
86
+ return out;
87
+ }
88
+
89
+ /** The closest sibling name, when one is close enough to be a typo rather than a
90
+ * different name. */
91
+ function nearest(target: string, candidates: readonly string[]): string | undefined {
92
+ let best: { name: string; d: number } | undefined;
93
+ for (const candidate of candidates) {
94
+ const d = distance(target, candidate);
95
+ if (!best || d < best.d) best = { name: candidate, d };
96
+ }
97
+ const limit = Math.max(1, Math.floor(target.length / 3));
98
+ return best && best.d <= limit ? best.name : undefined;
99
+ }
@@ -10,7 +10,7 @@ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
10
10
  * manifest (the dedup that backs `DUPLICATE_RESOURCE_NAME` reads
11
11
  * `(source, sourceLine)` to distinguish pipeline echoes from real
12
12
  * collisions). Production callers — the `Loader`, `flattenForAnalyzer`,
13
- * the telo-editor's `emitDocsFor`, the VSCode extension — all stamp
13
+ * telo studio's `emitDocsFor`, the VSCode extension — all stamp
14
14
  * positions already. This helper is the escape hatch for **programmatic
15
15
  * callers** (tests, ad-hoc scripts) that construct `ResourceManifest`
16
16
  * literals without going through a loader: it gives every otherwise-naked