@telorun/analyzer 0.62.0 → 0.63.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 (146) hide show
  1. package/dist/analysis-registry.d.ts.map +1 -1
  2. package/dist/analysis-registry.js +6 -0
  3. package/dist/analyzer.d.ts +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +343 -13
  6. package/dist/call-graph.d.ts.map +1 -1
  7. package/dist/call-graph.js +2 -7
  8. package/dist/flatten-for-analyzer.js +1 -1
  9. package/dist/import-resolution-diagnostics.d.ts +9 -2
  10. package/dist/import-resolution-diagnostics.d.ts.map +1 -1
  11. package/dist/import-resolution-diagnostics.js +26 -7
  12. package/dist/index.d.ts +15 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +13 -1
  15. package/dist/invocation-contract.d.ts +6 -1
  16. package/dist/invocation-contract.d.ts.map +1 -1
  17. package/dist/invocation-contract.js +27 -4
  18. package/dist/loaded-types.d.ts +12 -3
  19. package/dist/loaded-types.d.ts.map +1 -1
  20. package/dist/manifest-loader.d.ts +0 -1
  21. package/dist/manifest-loader.d.ts.map +1 -1
  22. package/dist/manifest-loader.js +62 -41
  23. package/dist/manifest-schemas.d.ts +699 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -1
  25. package/dist/manifest-schemas.js +306 -14
  26. package/dist/module-compatibility.d.ts +40 -0
  27. package/dist/module-compatibility.d.ts.map +1 -0
  28. package/dist/module-compatibility.js +52 -0
  29. package/dist/ref-slot-reading.d.ts +30 -0
  30. package/dist/ref-slot-reading.d.ts.map +1 -0
  31. package/dist/ref-slot-reading.js +139 -0
  32. package/dist/referrer-rule.d.ts +67 -0
  33. package/dist/referrer-rule.d.ts.map +1 -0
  34. package/dist/referrer-rule.js +96 -0
  35. package/dist/release/index.d.ts +1 -1
  36. package/dist/release/index.d.ts.map +1 -1
  37. package/dist/release/index.js +1 -1
  38. package/dist/release/payload-digest.d.ts +7 -3
  39. package/dist/release/payload-digest.d.ts.map +1 -1
  40. package/dist/release/payload-digest.js +7 -3
  41. package/dist/release/version-stamp.d.ts +16 -0
  42. package/dist/release/version-stamp.d.ts.map +1 -1
  43. package/dist/release/version-stamp.js +52 -0
  44. package/dist/release/workspace-config.d.ts +10 -6
  45. package/dist/release/workspace-config.d.ts.map +1 -1
  46. package/dist/release/workspace-config.js +10 -6
  47. package/dist/resolve-schema-ref-kinds.d.ts +4 -3
  48. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  49. package/dist/resolve-schema-ref-kinds.js +16 -0
  50. package/dist/resolve-throws-union.d.ts.map +1 -1
  51. package/dist/resolve-throws-union.js +4 -3
  52. package/dist/resolve-zone-containment.d.ts +99 -0
  53. package/dist/resolve-zone-containment.d.ts.map +1 -0
  54. package/dist/resolve-zone-containment.js +212 -0
  55. package/dist/resolve-zone-requirements.d.ts +5 -0
  56. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  57. package/dist/resolve-zone-requirements.js +48 -7
  58. package/dist/resource-rule.d.ts +106 -0
  59. package/dist/resource-rule.d.ts.map +1 -0
  60. package/dist/resource-rule.js +236 -0
  61. package/dist/rule-condition.d.ts +50 -0
  62. package/dist/rule-condition.d.ts.map +1 -0
  63. package/dist/rule-condition.js +108 -0
  64. package/dist/schema-keywords.d.ts.map +1 -1
  65. package/dist/schema-keywords.js +20 -0
  66. package/dist/schema-projection.d.ts +171 -0
  67. package/dist/schema-projection.d.ts.map +1 -0
  68. package/dist/schema-projection.js +317 -0
  69. package/dist/step-slot.d.ts +39 -0
  70. package/dist/step-slot.d.ts.map +1 -0
  71. package/dist/step-slot.js +49 -0
  72. package/dist/telo-version.d.ts +1 -1
  73. package/dist/telo-version.js +1 -1
  74. package/dist/validate-cel-context.d.ts.map +1 -1
  75. package/dist/validate-cel-context.js +17 -3
  76. package/dist/validate-durable-regions.d.ts +15 -0
  77. package/dist/validate-durable-regions.d.ts.map +1 -0
  78. package/dist/validate-durable-regions.js +336 -0
  79. package/dist/validate-references.d.ts.map +1 -1
  80. package/dist/validate-references.js +44 -2
  81. package/dist/validate-referrer-rules.d.ts +120 -0
  82. package/dist/validate-referrer-rules.d.ts.map +1 -0
  83. package/dist/validate-referrer-rules.js +255 -0
  84. package/dist/validate-resource-rules.d.ts +114 -0
  85. package/dist/validate-resource-rules.d.ts.map +1 -0
  86. package/dist/validate-resource-rules.js +283 -0
  87. package/dist/validate-schema-projection.d.ts +32 -0
  88. package/dist/validate-schema-projection.d.ts.map +1 -0
  89. package/dist/validate-schema-projection.js +110 -0
  90. package/dist/validate-step-inputs.d.ts +2 -1
  91. package/dist/validate-step-inputs.d.ts.map +1 -1
  92. package/dist/validate-step-inputs.js +5 -3
  93. package/dist/validate-throws-coverage.d.ts.map +1 -1
  94. package/dist/validate-throws-coverage.js +11 -5
  95. package/dist/validate-zone-slots.d.ts +2 -2
  96. package/dist/validate-zone-slots.d.ts.map +1 -1
  97. package/dist/validate-zone-slots.js +202 -6
  98. package/dist/validate-zone-violations.d.ts +12 -0
  99. package/dist/validate-zone-violations.d.ts.map +1 -0
  100. package/dist/validate-zone-violations.js +169 -0
  101. package/dist/value-type-keyword.d.ts +1 -1
  102. package/dist/value-type-keyword.d.ts.map +1 -1
  103. package/dist/value-type-keyword.js +4 -0
  104. package/dist/zone-slot.d.ts +61 -0
  105. package/dist/zone-slot.d.ts.map +1 -1
  106. package/dist/zone-slot.js +86 -5
  107. package/package.json +2 -2
  108. package/src/analysis-registry.ts +6 -0
  109. package/src/analyzer.ts +395 -14
  110. package/src/call-graph.ts +2 -8
  111. package/src/flatten-for-analyzer.ts +1 -1
  112. package/src/import-resolution-diagnostics.ts +25 -7
  113. package/src/index.ts +73 -0
  114. package/src/invocation-contract.ts +45 -2
  115. package/src/loaded-types.ts +12 -3
  116. package/src/manifest-loader.ts +87 -55
  117. package/src/manifest-schemas.ts +328 -14
  118. package/src/module-compatibility.ts +72 -0
  119. package/src/ref-slot-reading.ts +156 -0
  120. package/src/referrer-rule.ts +116 -0
  121. package/src/release/index.ts +1 -0
  122. package/src/release/payload-digest.ts +7 -3
  123. package/src/release/version-stamp.ts +58 -0
  124. package/src/release/workspace-config.ts +10 -6
  125. package/src/resolve-schema-ref-kinds.ts +28 -4
  126. package/src/resolve-throws-union.ts +4 -3
  127. package/src/resolve-zone-containment.ts +329 -0
  128. package/src/resolve-zone-requirements.ts +67 -7
  129. package/src/resource-rule.ts +252 -0
  130. package/src/rule-condition.ts +126 -0
  131. package/src/schema-keywords.ts +24 -0
  132. package/src/schema-projection.ts +403 -0
  133. package/src/step-slot.ts +64 -0
  134. package/src/telo-version.ts +1 -1
  135. package/src/validate-cel-context.ts +17 -3
  136. package/src/validate-durable-regions.ts +401 -0
  137. package/src/validate-references.ts +57 -2
  138. package/src/validate-referrer-rules.ts +373 -0
  139. package/src/validate-resource-rules.ts +401 -0
  140. package/src/validate-schema-projection.ts +184 -0
  141. package/src/validate-step-inputs.ts +5 -3
  142. package/src/validate-throws-coverage.ts +12 -5
  143. package/src/validate-zone-slots.ts +192 -8
  144. package/src/validate-zone-violations.ts +190 -0
  145. package/src/value-type-keyword.ts +4 -0
  146. package/src/zone-slot.ts +114 -5
@@ -0,0 +1,401 @@
1
+ /**
2
+ * The strict half of the `x-telo-resource-rules` accessor split
3
+ * (`validate-ref-slots.ts` / `validate-zone-slots.ts` precedent), plus the
4
+ * evaluation pass that runs the rules against each resource.
5
+ *
6
+ * Both halves live here because they fail in opposite directions and must agree
7
+ * about what a rule MEANS: a declaration the reader cannot parse is silently
8
+ * unenforced — the check reads as passing when it never ran — while a rule that
9
+ * throws would otherwise be reported against the consumer's manifest, blaming an
10
+ * author for a defect in someone else's kind.
11
+ *
12
+ * Scoping follows `X_TELO_REF_UNRESOLVED`, and it splits by WHOSE defect it is.
13
+ * A declaration defect belongs to the kind, so it is reported only for
14
+ * definitions in the entry's own modules. A VIOLATION belongs to the data, so it
15
+ * is reported wherever the offending resource is the entry's — the opposite
16
+ * direction, for the same reason. A rule that THROWS or runs out of budget is a
17
+ * defect in the rule found while checking someone else's data: the caller
18
+ * anchors it on the declaring definition when that is ours, and downgrades it to
19
+ * a warning when it is a published dependency's, so it is never an error on a
20
+ * line the author cannot change.
21
+ *
22
+ * Browser-safe: no Node built-ins.
23
+ */
24
+ import type { ResourceManifest } from "@telorun/sdk";
25
+ import { RULE_BUDGET_MS, compileRuleCondition, conditionRefusals } from "./rule-condition.js";
26
+ import {
27
+ RESOURCE_RULES_ANNOTATION,
28
+ celSourceOf,
29
+ findDynamicLeaf,
30
+ pointerSegments,
31
+ readRawResourceRules,
32
+ readResourceRules,
33
+ readNodes,
34
+ resolveRuleSubjects,
35
+ type ResourceRule,
36
+ } from "./resource-rule.js";
37
+
38
+ /** Published name for the shared budget — see `rule-condition.ts`. */
39
+ export const RESOURCE_RULE_BUDGET_MS = RULE_BUDGET_MS;
40
+
41
+ export interface ResourceRuleIssue {
42
+ code: "RESOURCE_RULE_INVALID";
43
+ manifest: ResourceManifest;
44
+ path: string;
45
+ message: string;
46
+ }
47
+
48
+ /** One rule's verdict on one resource. */
49
+ export type ResourceRuleFinding =
50
+ | {
51
+ kind: "violation";
52
+ rule: ResourceRule;
53
+ /** Path of the offending element, or "" for a rule with no `in:`. */
54
+ path: string;
55
+ message: string;
56
+ }
57
+ | { kind: "skipped"; rule: ResourceRule; path: string; dynamicAt: string }
58
+ | { kind: "failed"; rule: ResourceRule; path: string; reason: string }
59
+ | { kind: "over-budget"; rule: ResourceRule; path: string; elapsedMs: number };
60
+
61
+ function isObject(value: unknown): value is Record<string, unknown> {
62
+ return !!value && typeof value === "object" && !Array.isArray(value);
63
+ }
64
+
65
+ /** 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 {
68
+ const segments = pointerSegments(pointer);
69
+ if (!segments) return undefined;
70
+ let node: unknown = schema;
71
+ for (const segment of segments) {
72
+ if (!isObject(node)) return undefined;
73
+ const properties = isObject(node.properties) ? node.properties : undefined;
74
+ const next =
75
+ properties?.[segment] ??
76
+ (node.type === "array" ? node.items : undefined) ??
77
+ (isObject(node.additionalProperties) ? node.additionalProperties : undefined);
78
+ if (next === undefined) return undefined;
79
+ node = next;
80
+ }
81
+ return node;
82
+ }
83
+
84
+ /** True when a schema node describes something a rule can iterate. Unknown or
85
+ * absent `type` passes: an open schema is not evidence of a defect. */
86
+ function isIterableSchema(node: unknown): boolean {
87
+ if (!isObject(node)) return true;
88
+ const type = node.type;
89
+ if (type === undefined) return true;
90
+ const types = Array.isArray(type) ? type : [type];
91
+ return types.some((t) => t === "array" || t === "object");
92
+ }
93
+
94
+ /**
95
+ * Report every way a kind's rule declarations are malformed. Runs on the
96
+ * `Telo.Definition` / `Telo.Abstract` doc, so a defect lands on the line the
97
+ * kind's author wrote rather than on a consumer's resource.
98
+ */
99
+ export function validateResourceRuleDeclarations(
100
+ manifest: ResourceManifest,
101
+ /**
102
+ * The schema an `in:` pointer is checked against — the SAME merged schema the
103
+ * evaluation pass reads, so the two halves cannot disagree about which fields
104
+ * a kind has. Reading the doc's own `schema:` here was correct only for a leaf
105
+ * kind: a rule declared on a parent and evaluated against a child's field
106
+ * would be reported invalid at the parent, and a child's rule naming an
107
+ * INHERITED field would be told the kind does not declare it. Both arrive as
108
+ * soon as rules are declared on an abstract, which is where a rule shared by
109
+ * every backend belongs.
110
+ */
111
+ effectiveSchema?: unknown,
112
+ ): ResourceRuleIssue[] {
113
+ const own = (manifest as unknown as Record<string, unknown>).schema;
114
+ const schema = effectiveSchema ?? own;
115
+ const raw = readRawResourceRules(own);
116
+ if (raw === undefined) return [];
117
+
118
+ const base = `schema.${RESOURCE_RULES_ANNOTATION}`;
119
+ const issues: ResourceRuleIssue[] = [];
120
+ const issue = (path: string, message: string): void => {
121
+ issues.push({ code: "RESOURCE_RULE_INVALID", manifest, path, message });
122
+ };
123
+
124
+ if (!Array.isArray(raw)) {
125
+ issue(base, `'${RESOURCE_RULES_ANNOTATION}' must be an array of rules.`);
126
+ return issues;
127
+ }
128
+
129
+ const seen = new Map<string, number>();
130
+ raw.forEach((entry, index) => {
131
+ const at = `${base}[${index}]`;
132
+ if (!isObject(entry)) {
133
+ issue(at, "A rule must be an object with 'condition', 'code' and 'message'.");
134
+ return;
135
+ }
136
+
137
+ const condition = celSourceOf(entry.condition);
138
+ if (condition === undefined || condition.length === 0) {
139
+ issue(
140
+ `${at}.condition`,
141
+ "A rule needs a 'condition' — a CEL expression that is TRUE when the rule holds " +
142
+ "(the polarity Telo.JsonSchema rules use). Write it with the !cel tag.",
143
+ );
144
+ }
145
+ if (typeof entry.code !== "string" || entry.code.length === 0) {
146
+ issue(
147
+ `${at}.code`,
148
+ "A rule needs a 'code' naming it. It is reported in the diagnostic's data.rule, " +
149
+ "not as a diagnostic code — every violation reports under RESOURCE_RULE_VIOLATED.",
150
+ );
151
+ } else {
152
+ const first = seen.get(entry.code);
153
+ if (first !== undefined) {
154
+ issue(
155
+ `${at}.code`,
156
+ `Rule code '${entry.code}' is already used by rule ${first}. A code names one rule, ` +
157
+ "so two rules sharing it are indistinguishable in data.rule.",
158
+ );
159
+ } else {
160
+ seen.set(entry.code, index);
161
+ }
162
+ }
163
+ if (typeof entry.message !== "string" || entry.message.length === 0) {
164
+ issue(
165
+ `${at}.message`,
166
+ "A rule needs a 'message' saying what the relationship means — only the kind's " +
167
+ "author knows that, and the analyzer supplies only where and what.",
168
+ );
169
+ }
170
+ if (
171
+ entry.severity !== undefined &&
172
+ entry.severity !== "error" &&
173
+ entry.severity !== "warning"
174
+ ) {
175
+ issue(`${at}.severity`, "'severity' must be 'error' or 'warning'.");
176
+ }
177
+
178
+ if (entry.in !== undefined) {
179
+ if (typeof entry.in !== "string") {
180
+ issue(`${at}.in`, "'in' must be a JSON Pointer to the collection the rule iterates.");
181
+ } else if (!pointerSegments(entry.in)) {
182
+ issue(`${at}.in`, `'in' must be a JSON Pointer starting with '/', got '${entry.in}'.`);
183
+ } else {
184
+ const node = schemaAtPointer(schema, entry.in);
185
+ if (node === undefined) {
186
+ issue(
187
+ `${at}.in`,
188
+ `'in' points at '${entry.in}', which this kind's schema does not declare. ` +
189
+ "The pointer is the diagnostic's anchor, so it must name a field of this kind.",
190
+ );
191
+ } else if (!isIterableSchema(node)) {
192
+ issue(
193
+ `${at}.in`,
194
+ `'in' points at '${entry.in}', which is not a collection. A rule iterates an ` +
195
+ "array or a map; omit 'in' for a rule about the resource as a whole.",
196
+ );
197
+ }
198
+ }
199
+ }
200
+
201
+ if (condition) {
202
+ for (const refusal of conditionRefusals(condition)) issue(`${at}.condition`, refusal);
203
+ }
204
+ });
205
+
206
+ return issues;
207
+ }
208
+
209
+ /**
210
+ * Run a kind's rules against one resource.
211
+ *
212
+ * `self` binds the resource, `this` the element under test — the two coexist in
213
+ * cel-js, which is what lets a rule correlate an element against the whole
214
+ * (`c in self.columns`) with no path language.
215
+ */
216
+ export function evaluateResourceRules(
217
+ manifest: ResourceManifest,
218
+ definitionSchema: unknown,
219
+ ): ResourceRuleFinding[] {
220
+ const rules = readResourceRules(definitionSchema);
221
+ if (rules.length === 0) return [];
222
+
223
+ const self = manifest as unknown as Record<string, unknown>;
224
+ const findings: ResourceRuleFinding[] = [];
225
+
226
+ for (const rule of rules) {
227
+ const subjects =
228
+ rule.in === undefined ? [{ path: "", value: self }] : resolveRuleSubjects(self, rule.in);
229
+ // `undefined` means the pointer resolved to a scalar — a declaration defect
230
+ // the strict half reports at the kind. Evaluating anyway would report it
231
+ // against the consumer instead.
232
+ if (subjects === undefined) continue;
233
+
234
+ const compiled = compileRuleCondition(rule.condition, ["self", "this"]);
235
+ if ("reason" in compiled) {
236
+ findings.push({ kind: "failed", rule, path: "", reason: compiled.reason });
237
+ continue;
238
+ }
239
+ const { parsed, chains } = compiled;
240
+
241
+ const started = Date.now();
242
+ for (const subject of subjects) {
243
+ // Only the nodes this condition READS decide whether it can run — see
244
+ // `readNodes`. Scanning the whole subject would disable every
245
+ // resource-wide rule on any manifest containing one unrelated expression.
246
+ let dynamicAt: string | undefined;
247
+ for (const node of readNodes(chains, { self, this: subject.value })) {
248
+ dynamicAt = findDynamicLeaf(node);
249
+ if (dynamicAt !== undefined) break;
250
+ }
251
+ if (dynamicAt !== undefined) {
252
+ findings.push({ kind: "skipped", rule, path: subject.path, dynamicAt });
253
+ continue;
254
+ }
255
+ let held: unknown;
256
+ try {
257
+ held = parsed({ self, this: subject.value, key: subject.key ?? null });
258
+ } catch (err) {
259
+ findings.push({
260
+ kind: "failed",
261
+ rule,
262
+ path: subject.path,
263
+ reason: err instanceof Error ? err.message : String(err),
264
+ });
265
+ break;
266
+ }
267
+ if (held !== true) {
268
+ findings.push({ kind: "violation", rule, path: subject.path, message: rule.message });
269
+ }
270
+ const elapsed = Date.now() - started;
271
+ if (elapsed > RESOURCE_RULE_BUDGET_MS) {
272
+ findings.push({ kind: "over-budget", rule, path: subject.path, elapsedMs: elapsed });
273
+ break;
274
+ }
275
+ }
276
+ }
277
+
278
+ return findings;
279
+ }
280
+
281
+ /** Whether a rule found anything to iterate on this resource — the input to the
282
+ * never-exercised report, which is the second way coverage varies invisibly. */
283
+ export function ruleExercised(manifest: ResourceManifest, rule: ResourceRule): boolean {
284
+ if (rule.in === undefined) return true;
285
+ const subjects = resolveRuleSubjects(manifest as unknown as Record<string, unknown>, rule.in);
286
+ return subjects !== undefined && subjects.length > 0;
287
+ }
288
+
289
+ /** Where a rule finding is reported, and how loudly. Plain data, so the caller
290
+ * pushes it exactly as it does for `zoneSlotIssues` / `refSlotIssues` rather
291
+ * than composing severity, ownership and prose inline in the resource loop. */
292
+ export interface ResourceRuleDiagnostic {
293
+ code:
294
+ | "RESOURCE_RULE_VIOLATED"
295
+ | "RESOURCE_RULE_SKIPPED"
296
+ | "RESOURCE_RULE_INVALID"
297
+ | "RESOURCE_RULE_UNEXERCISED";
298
+ severity: "error" | "warning" | "information";
299
+ message: string;
300
+ /** The resource the finding is reported ON — the offending one for a
301
+ * violation, the DECLARING definition for a defect in the rule itself. */
302
+ manifest: ResourceManifest;
303
+ path?: string;
304
+ rule: string;
305
+ }
306
+
307
+ /**
308
+ * Map one resource's findings to what should be reported.
309
+ *
310
+ * The split by whose defect it is lives here, with the finding vocabulary,
311
+ * rather than in the analyzer's resource loop: a violation belongs to the DATA
312
+ * and is reported on the resource, while a rule that throws or exhausts its
313
+ * budget is a defect in the RULE and belongs on the definition that declared it
314
+ * — downgraded to a warning when that definition is a published dependency's,
315
+ * since an error there blocks `telo check` on a line the consumer cannot change.
316
+ */
317
+ export function reportResourceRules(
318
+ manifest: ResourceManifest,
319
+ definition: ResourceManifest | undefined,
320
+ findings: readonly ResourceRuleFinding[],
321
+ /** Whether the DECLARING definition is one of the entry's own modules. */
322
+ declarationIsOurs: boolean,
323
+ ): ResourceRuleDiagnostic[] {
324
+ const name = (manifest.metadata?.name as string | undefined) ?? "<unnamed>";
325
+ const out: ResourceRuleDiagnostic[] = [];
326
+
327
+ for (const finding of findings) {
328
+ const at = finding.path === "" ? undefined : finding.path;
329
+ if (finding.kind === "violation") {
330
+ out.push({
331
+ // One analyzer-owned envelope: surfaces branch on `code`, so a published
332
+ // module free to emit any string could shadow machinery that never
333
+ // expected a third party in that space. The rule's own name rides in
334
+ // `data.rule`.
335
+ code: "RESOURCE_RULE_VIOLATED",
336
+ severity: finding.rule.severity,
337
+ message: `${manifest.kind}/${name}${at ? ` at '${at}'` : ""}: ${finding.message}`,
338
+ manifest,
339
+ path: at,
340
+ rule: finding.rule.code,
341
+ });
342
+ continue;
343
+ }
344
+ if (finding.kind === "skipped") {
345
+ out.push({
346
+ code: "RESOURCE_RULE_SKIPPED",
347
+ severity: "information",
348
+ message:
349
+ `${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. ` +
352
+ "Reported rather than dropped: a check whose coverage varies invisibly reads as passing.",
353
+ manifest,
354
+ path: at,
355
+ rule: finding.rule.code,
356
+ });
357
+ continue;
358
+ }
359
+
360
+ const because =
361
+ finding.kind === "failed"
362
+ ? `failed to evaluate: ${finding.reason}. Guard an optional field with \`in\` or \`.?\`.`
363
+ : `exceeded its evaluation budget (${finding.elapsedMs}ms) and was stopped, so ` +
364
+ "coverage from here on is incomplete. Simplify the condition.";
365
+ out.push({
366
+ code: "RESOURCE_RULE_INVALID",
367
+ severity: declarationIsOurs ? "error" : "warning",
368
+ message:
369
+ `Rule '${finding.rule.code}' on kind '${manifest.kind}' ${because} ` +
370
+ `This is a defect in the rule, not in ${name}` +
371
+ (declarationIsOurs ? "." : " — it is declared by a module this workspace does not own."),
372
+ manifest: declarationIsOurs && definition ? definition : manifest,
373
+ path:
374
+ declarationIsOurs && definition
375
+ ? `schema.${RESOURCE_RULES_ANNOTATION}[${finding.rule.index}]`
376
+ : at,
377
+ rule: finding.rule.code,
378
+ });
379
+ }
380
+
381
+ return out;
382
+ }
383
+
384
+ /** The report for a rule nothing ever exercised — the second way coverage varies
385
+ * invisibly, beside the dynamic-leaf skip. */
386
+ export function reportUnexercisedRule(
387
+ definition: ResourceManifest,
388
+ rule: ResourceRule,
389
+ ): ResourceRuleDiagnostic {
390
+ return {
391
+ code: "RESOURCE_RULE_UNEXERCISED",
392
+ severity: "information",
393
+ message:
394
+ `Rule '${rule.code}' never ran: '${rule.in}' was empty on every resource of this kind, ` +
395
+ "so nothing has proven the condition. A nested typo in the condition is caught only at " +
396
+ "evaluation.",
397
+ manifest: definition,
398
+ path: `schema.${RESOURCE_RULES_ANNOTATION}[${rule.index}]`,
399
+ rule: rule.code,
400
+ };
401
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Static validation of the schema-projection annotations themselves — the
3
+ * strict half of the accessor split, mirroring `validate-ref-slots.ts` and
4
+ * `validate-zone-slots.ts`.
5
+ *
6
+ * `readSchemaProjection` / `readSchemaMap` are deliberately lenient: anything
7
+ * they cannot read reads as absent. Without this pass that leniency is silent in
8
+ * the direction that matters — a projection nothing can read does not fail, it
9
+ * simply stops typing the consumers that were counting on it, so a misspelled
10
+ * column reaches the database instead of `telo check`. The whole point of the
11
+ * projection is to move that failure earlier; an unreported malformed
12
+ * declaration puts it back.
13
+ *
14
+ * A map that is merely INCOMPLETE is the same failure per value: a declared type
15
+ * with no entry projects to nothing, so a column of that type silently vanishes
16
+ * from every consumer's view of the row.
17
+ *
18
+ * Scoping follows `X_TELO_REF_UNRESOLVED`: reported only for definitions in the
19
+ * entry's own modules — a published dependency's annotation is not the
20
+ * consumer's to fix.
21
+ *
22
+ * Browser-safe: no Node built-ins.
23
+ */
24
+ import type { ResourceManifest } from "@telorun/sdk";
25
+ import {
26
+ projectionKeyMap,
27
+ rawSchemaProjection,
28
+ readSchemaProjection,
29
+ schemaProjectionIsMisplaced,
30
+ } from "./schema-projection.js";
31
+
32
+ export interface SchemaProjectionIssue {
33
+ code: "SCHEMA_PROJECTION_INVALID" | "SCHEMA_MAP_INCOMPLETE";
34
+ manifest: ResourceManifest;
35
+ path: string;
36
+ message: string;
37
+ }
38
+
39
+ const PROJECTION = "x-telo-schema-projection";
40
+
41
+ function isObject(value: unknown): value is Record<string, unknown> {
42
+ return !!value && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+
45
+ export function validateSchemaProjection(manifest: ResourceManifest): SchemaProjectionIssue[] {
46
+ const doc = manifest as unknown as Record<string, unknown>;
47
+ const raw = rawSchemaProjection(doc);
48
+ if (raw === undefined) return [];
49
+
50
+ const misplaced = schemaProjectionIsMisplaced(doc);
51
+ const base = misplaced ? `schema.${PROJECTION}` : PROJECTION;
52
+ const issue = (
53
+ code: SchemaProjectionIssue["code"],
54
+ path: string,
55
+ message: string,
56
+ ): SchemaProjectionIssue => ({ code, manifest, path, message });
57
+
58
+ const issues: SchemaProjectionIssue[] = [];
59
+ // Read from both positions so a misplaced annotation still WORKS, and
60
+ // reported so it does not stay misplaced. The alternative — reading only the
61
+ // document — makes the inner spelling silently inert, and the resulting
62
+ // failure surfaces on a consumer's slot as "this kind declares no projection",
63
+ // which blames an author who wrote the annotation correctly enough to mean it.
64
+ if (misplaced) {
65
+ issues.push(
66
+ issue(
67
+ "SCHEMA_PROJECTION_INVALID",
68
+ base,
69
+ `'${PROJECTION}' belongs on the kind DOCUMENT, beside 'schema:', not inside it — ` +
70
+ `it describes the whole declaration rather than one field. It is read from here ` +
71
+ `too, so nothing is broken; move it up a level.`,
72
+ ),
73
+ );
74
+ }
75
+
76
+ if (!isObject(raw)) {
77
+ return [
78
+ ...issues,
79
+ issue(
80
+ "SCHEMA_PROJECTION_INVALID",
81
+ base,
82
+ `'${PROJECTION}' must be an object naming the entry collection and the field that keys it.`,
83
+ ),
84
+ ];
85
+ }
86
+ const missing = ["entries", "key"].filter((field) => typeof raw[field] !== "string");
87
+ if (missing.length > 0) {
88
+ return [
89
+ ...issues,
90
+ issue(
91
+ "SCHEMA_PROJECTION_INVALID",
92
+ base,
93
+ `'${PROJECTION}' is missing ${missing.map((f) => `'${f}'`).join(" and ")}. ` +
94
+ `'entries' is a JSON Pointer to the collection, 'key' the entry field whose value ` +
95
+ `selects an 'x-telo-schema-map' entry.`,
96
+ ),
97
+ ];
98
+ }
99
+
100
+ const projection = readSchemaProjection(doc)!;
101
+ const schema = doc.schema;
102
+
103
+ // An ARRAY-shaped collection has no key to name its entries by, so without
104
+ // `name` every entry projects to nothing and the whole projection is an object
105
+ // schema with no properties and `additionalProperties: false` — one that
106
+ // rejects every value. A keyed map needs no `name`: the map key IS the
107
+ // identity.
108
+ if (collectionIsArray(schema, projection.entries) && !projection.nameField) {
109
+ return [
110
+ ...issues,
111
+ issue(
112
+ "SCHEMA_PROJECTION_INVALID",
113
+ base,
114
+ `'${PROJECTION}' names '${projection.entries}', which is an array, but declares no ` +
115
+ `'name'. An array's entries have no key, so 'name' must say which field holds an ` +
116
+ `entry's identity — without it the projection yields a schema that rejects every value.`,
117
+ ),
118
+ ];
119
+ }
120
+
121
+ const map = projectionKeyMap(schema, projection);
122
+ if (!map) {
123
+ return [
124
+ ...issues,
125
+ issue(
126
+ "SCHEMA_PROJECTION_INVALID",
127
+ base,
128
+ `'${PROJECTION}' names '${projection.entries}' keyed on '${projection.key}', but that ` +
129
+ `field carries no 'x-telo-schema-map'. The map declares the JSON Schema each of its ` +
130
+ `values means, and without it the projection types nothing.`,
131
+ ),
132
+ ];
133
+ }
134
+
135
+ // An `enum` on the keyed field is the kind's own closed vocabulary, so every
136
+ // member of it is a value the map has to answer for. Only an enum is checked:
137
+ // an open string field has no set to be complete against.
138
+ const keyField = keySchema(schema, projection.entries, projection.key);
139
+ const values = Array.isArray(keyField?.enum) ? (keyField!.enum as unknown[]) : [];
140
+ const unmapped = values.filter((value) => typeof value === "string" && !(value in map));
141
+ if (unmapped.length === 0) return issues;
142
+ return [
143
+ ...issues,
144
+ issue(
145
+ "SCHEMA_MAP_INCOMPLETE",
146
+ base,
147
+ `'x-telo-schema-map' has no entry for ${unmapped.map((v) => `'${v}'`).join(", ")}. ` +
148
+ `An entry declaring an unmapped value projects to nothing, so it disappears from every ` +
149
+ `consumer's view of the shape.`,
150
+ ),
151
+ ];
152
+ }
153
+
154
+ function collectionIsArray(schema: unknown, entries: string): boolean {
155
+ let node: unknown = schema;
156
+ for (const segment of entries.split("/")) {
157
+ if (segment === "") continue;
158
+ if (!isObject(node) || !isObject(node.properties)) return false;
159
+ node = node.properties[segment];
160
+ }
161
+ return isObject(node) && isObject(node.items);
162
+ }
163
+
164
+ function keySchema(
165
+ schema: unknown,
166
+ entries: string,
167
+ key: string,
168
+ ): Record<string, unknown> | undefined {
169
+ let node: unknown = schema;
170
+ for (const segment of entries.split("/")) {
171
+ if (segment === "") continue;
172
+ if (!isObject(node) || !isObject(node.properties)) return undefined;
173
+ node = node.properties[segment];
174
+ }
175
+ if (!isObject(node)) return undefined;
176
+ const entry = isObject(node.additionalProperties)
177
+ ? node.additionalProperties
178
+ : isObject(node.items)
179
+ ? node.items
180
+ : undefined;
181
+ if (!isObject(entry) || !isObject(entry.properties)) return undefined;
182
+ const field = entry.properties[key];
183
+ return isObject(field) ? field : undefined;
184
+ }
@@ -19,6 +19,7 @@ import {
19
19
  resolveLocalRef,
20
20
  walkStepArray,
21
21
  } from "./analyzer.js";
22
+ import { readStepSlot } from "./step-slot.js";
22
23
 
23
24
  export interface StepInputIssue {
24
25
  path: string;
@@ -42,7 +43,8 @@ export interface StepInputIssue {
42
43
  * CEL leaves are replaced by schema-shaped placeholders first (`substituteCelFields`),
43
44
  * so an expression is never a false positive — only structural disagreement is
44
45
  * reported. Nothing is hardcoded about `Run.Sequence`: the invoke field comes
45
- * from `x-telo-step-context`, and the paired inputs field from whichever sibling
46
+ * from the step slot (the shared grammar, or the legacy `x-telo-step-context`),
47
+ * and the paired inputs field from whichever sibling
46
48
  * property carries `x-telo-topology-role: inputs`.
47
49
  */
48
50
  export function collectStepInputIssues(
@@ -65,8 +67,8 @@ export function collectStepInputIssues(
65
67
  const readingModule = (manifest.metadata as { module?: string } | undefined)?.module;
66
68
 
67
69
  for (const [fieldName, fieldSchema] of Object.entries(props)) {
68
- const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
69
- if (!stepCtx?.invoke) continue;
70
+ const stepCtx = readStepSlot(fieldSchema);
71
+ if (!stepCtx) continue;
70
72
  const steps = manifest[fieldName];
71
73
  if (!Array.isArray(steps)) continue;
72
74
 
@@ -15,6 +15,7 @@ import {
15
15
  } from "./resolve-throws-union.js";
16
16
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
17
17
  import { extractAccessChains, validateChainAgainstSchema } from "./validate-cel-context.js";
18
+ import { isStepSlot } from "./step-slot.js";
18
19
 
19
20
  const SOURCE = "telo-analyzer";
20
21
  const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
@@ -457,9 +458,11 @@ function checkCelChainAgainstDataSchema(
457
458
  }
458
459
 
459
460
  /** Rule 8 extension: `inherit: true` only makes sense on a definition whose
460
- * schema contains at least one `x-telo-step-context` array — the annotation
461
- * that drives the resolver's generic step traversal. A definition with
462
- * `inherit: true` and no such array has no invocables to inherit from. */
461
+ * schema declares at least one STEP BODYan array whose items point at the
462
+ * shared grammar, or, for a module published before that fragment existed, one
463
+ * carrying the legacy `x-telo-step-context` annotation. That is what drives the
464
+ * resolver's generic step traversal; a definition with `inherit: true` and no
465
+ * such array has no invocables to inherit from. */
463
466
  function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
464
467
  const diagnostics: AnalysisDiagnostic[] = [];
465
468
  for (const m of manifests) {
@@ -475,7 +478,11 @@ function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiag
475
478
  severity: DiagnosticSeverity.Error,
476
479
  code: "INHERIT_WITHOUT_STEP_CONTEXT",
477
480
  source: SOURCE,
478
- message: `Telo.Definition '${name}' declares throws.inherit: true but its schema has no field annotated with x-telo-step-context. inherit is only meaningful on definitions that drive invocables via step arrays.`,
481
+ message:
482
+ `Telo.Definition '${name}' declares throws.inherit: true but its schema declares no step ` +
483
+ `body. inherit is only meaningful on a definition that drives invocables through steps — ` +
484
+ `give an array field 'items: { $ref: "telo://manifest#/$defs/Step" }' (the legacy ` +
485
+ `x-telo-step-context annotation is also recognised).`,
479
486
  data: { resource: { kind: m.kind, name }, filePath, path: "throws.inherit" },
480
487
  });
481
488
  }
@@ -486,7 +493,7 @@ function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiag
486
493
 
487
494
  function schemaHasStepContext(schema: Record<string, any> | undefined): boolean {
488
495
  if (!schema || typeof schema !== "object") return false;
489
- if ("x-telo-step-context" in schema) return true;
496
+ if (isStepSlot(schema)) return true;
490
497
  const props = schema.properties;
491
498
  if (props && typeof props === "object") {
492
499
  for (const v of Object.values(props as Record<string, any>)) {