@telorun/analyzer 0.62.1 → 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 (142) 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/version-stamp.d.ts +16 -0
  39. package/dist/release/version-stamp.d.ts.map +1 -1
  40. package/dist/release/version-stamp.js +52 -0
  41. package/dist/release/workspace-config.d.ts +10 -6
  42. package/dist/release/workspace-config.d.ts.map +1 -1
  43. package/dist/release/workspace-config.js +10 -6
  44. package/dist/resolve-schema-ref-kinds.d.ts +4 -3
  45. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  46. package/dist/resolve-schema-ref-kinds.js +16 -0
  47. package/dist/resolve-throws-union.d.ts.map +1 -1
  48. package/dist/resolve-throws-union.js +4 -3
  49. package/dist/resolve-zone-containment.d.ts +99 -0
  50. package/dist/resolve-zone-containment.d.ts.map +1 -0
  51. package/dist/resolve-zone-containment.js +212 -0
  52. package/dist/resolve-zone-requirements.d.ts +5 -0
  53. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  54. package/dist/resolve-zone-requirements.js +48 -7
  55. package/dist/resource-rule.d.ts +106 -0
  56. package/dist/resource-rule.d.ts.map +1 -0
  57. package/dist/resource-rule.js +236 -0
  58. package/dist/rule-condition.d.ts +50 -0
  59. package/dist/rule-condition.d.ts.map +1 -0
  60. package/dist/rule-condition.js +108 -0
  61. package/dist/schema-keywords.d.ts.map +1 -1
  62. package/dist/schema-keywords.js +20 -0
  63. package/dist/schema-projection.d.ts +171 -0
  64. package/dist/schema-projection.d.ts.map +1 -0
  65. package/dist/schema-projection.js +317 -0
  66. package/dist/step-slot.d.ts +39 -0
  67. package/dist/step-slot.d.ts.map +1 -0
  68. package/dist/step-slot.js +49 -0
  69. package/dist/telo-version.d.ts +1 -1
  70. package/dist/telo-version.js +1 -1
  71. package/dist/validate-cel-context.d.ts.map +1 -1
  72. package/dist/validate-cel-context.js +17 -3
  73. package/dist/validate-durable-regions.d.ts +15 -0
  74. package/dist/validate-durable-regions.d.ts.map +1 -0
  75. package/dist/validate-durable-regions.js +336 -0
  76. package/dist/validate-references.d.ts.map +1 -1
  77. package/dist/validate-references.js +44 -2
  78. package/dist/validate-referrer-rules.d.ts +120 -0
  79. package/dist/validate-referrer-rules.d.ts.map +1 -0
  80. package/dist/validate-referrer-rules.js +255 -0
  81. package/dist/validate-resource-rules.d.ts +114 -0
  82. package/dist/validate-resource-rules.d.ts.map +1 -0
  83. package/dist/validate-resource-rules.js +283 -0
  84. package/dist/validate-schema-projection.d.ts +32 -0
  85. package/dist/validate-schema-projection.d.ts.map +1 -0
  86. package/dist/validate-schema-projection.js +110 -0
  87. package/dist/validate-step-inputs.d.ts +2 -1
  88. package/dist/validate-step-inputs.d.ts.map +1 -1
  89. package/dist/validate-step-inputs.js +5 -3
  90. package/dist/validate-throws-coverage.d.ts.map +1 -1
  91. package/dist/validate-throws-coverage.js +11 -5
  92. package/dist/validate-zone-slots.d.ts +2 -2
  93. package/dist/validate-zone-slots.d.ts.map +1 -1
  94. package/dist/validate-zone-slots.js +202 -6
  95. package/dist/validate-zone-violations.d.ts +12 -0
  96. package/dist/validate-zone-violations.d.ts.map +1 -0
  97. package/dist/validate-zone-violations.js +169 -0
  98. package/dist/value-type-keyword.d.ts +1 -1
  99. package/dist/value-type-keyword.d.ts.map +1 -1
  100. package/dist/value-type-keyword.js +4 -0
  101. package/dist/zone-slot.d.ts +61 -0
  102. package/dist/zone-slot.d.ts.map +1 -1
  103. package/dist/zone-slot.js +86 -5
  104. package/package.json +2 -2
  105. package/src/analysis-registry.ts +6 -0
  106. package/src/analyzer.ts +395 -14
  107. package/src/call-graph.ts +2 -8
  108. package/src/flatten-for-analyzer.ts +1 -1
  109. package/src/import-resolution-diagnostics.ts +25 -7
  110. package/src/index.ts +73 -0
  111. package/src/invocation-contract.ts +45 -2
  112. package/src/loaded-types.ts +12 -3
  113. package/src/manifest-loader.ts +87 -55
  114. package/src/manifest-schemas.ts +328 -14
  115. package/src/module-compatibility.ts +72 -0
  116. package/src/ref-slot-reading.ts +156 -0
  117. package/src/referrer-rule.ts +116 -0
  118. package/src/release/index.ts +1 -0
  119. package/src/release/version-stamp.ts +58 -0
  120. package/src/release/workspace-config.ts +10 -6
  121. package/src/resolve-schema-ref-kinds.ts +28 -4
  122. package/src/resolve-throws-union.ts +4 -3
  123. package/src/resolve-zone-containment.ts +329 -0
  124. package/src/resolve-zone-requirements.ts +67 -7
  125. package/src/resource-rule.ts +252 -0
  126. package/src/rule-condition.ts +126 -0
  127. package/src/schema-keywords.ts +24 -0
  128. package/src/schema-projection.ts +403 -0
  129. package/src/step-slot.ts +64 -0
  130. package/src/telo-version.ts +1 -1
  131. package/src/validate-cel-context.ts +17 -3
  132. package/src/validate-durable-regions.ts +401 -0
  133. package/src/validate-references.ts +57 -2
  134. package/src/validate-referrer-rules.ts +373 -0
  135. package/src/validate-resource-rules.ts +401 -0
  136. package/src/validate-schema-projection.ts +184 -0
  137. package/src/validate-step-inputs.ts +5 -3
  138. package/src/validate-throws-coverage.ts +12 -5
  139. package/src/validate-zone-slots.ts +192 -8
  140. package/src/validate-zone-violations.ts +190 -0
  141. package/src/value-type-keyword.ts +4 -0
  142. package/src/zone-slot.ts +114 -5
@@ -26,10 +26,11 @@
26
26
  *
27
27
  * Browser-safe: no Node built-ins.
28
28
  */
29
- import type { ResourceManifest } from "@telorun/sdk";
29
+ import { ZONE_ATTRIBUTES, zoneAttributeNames, type ResourceManifest } from "@telorun/sdk";
30
+ import { distance } from "./levenshtein.js";
30
31
 
31
32
  export interface ZoneSlotIssue {
32
- code: "ZONE_ANNOTATION_INVALID";
33
+ code: "ZONE_ANNOTATION_INVALID" | "ZONE_ATTRIBUTE_UNKNOWN" | "ZONE_ATTRIBUTE_INCOMPLETE";
33
34
  manifest: ResourceManifest;
34
35
  /** Schema path of the annotated slot. */
35
36
  path: string;
@@ -38,6 +39,7 @@ export interface ZoneSlotIssue {
38
39
 
39
40
  const PROVIDES = "x-telo-provides-zone";
40
41
  const REQUIRES = "x-telo-requires-zone";
42
+ const VIOLATES = "x-telo-violates-zone";
41
43
 
42
44
  /** A self-relative JSON Pointer, the only correlation-key spelling both halves
43
45
  * read identically. `""` (whole document) is meaningless as a key, so a
@@ -53,6 +55,107 @@ function describe(value: unknown): string {
53
55
  return typeof value;
54
56
  }
55
57
 
58
+ /** The closest declared attribute name within an edit-distance threshold, or
59
+ * undefined. Mirrors `suggestValueType`: case-sensitive, and silent on a tie,
60
+ * because a coin-flip suggestion is worse than none. */
61
+ function suggestAttribute(name: string): string | undefined {
62
+ if (!name) return undefined;
63
+ const threshold = Math.min(3, Math.floor(name.length / 3));
64
+ if (threshold < 1) return undefined;
65
+ let best: string | undefined;
66
+ let bestDist = threshold + 1;
67
+ let tied = false;
68
+ for (const candidate of ZONE_ATTRIBUTES.keys()) {
69
+ const d = distance(name, candidate);
70
+ if (d < bestDist) {
71
+ best = candidate;
72
+ bestDist = d;
73
+ tied = false;
74
+ } else if (d === bestDist) {
75
+ tied = true;
76
+ }
77
+ }
78
+ return !best || bestDist > threshold || tied ? undefined : best;
79
+ }
80
+
81
+ /**
82
+ * The attributes half of the object form.
83
+ *
84
+ * The composed `additionalProperties: false` schema would already reject an
85
+ * unknown name; the dedicated code earns its place by NAMING the valid ones and
86
+ * suggesting a spelling, instead of reporting a schema violation on a key the
87
+ * author believed was real — the same standing `X_TELO_TYPE_UNKNOWN` has over
88
+ * the value-type vocabulary it is modelled on.
89
+ */
90
+ function checkAttributes(
91
+ obj: Record<string, unknown>,
92
+ definition: ResourceManifest,
93
+ path: string,
94
+ issues: ZoneSlotIssue[],
95
+ annotation: string = PROVIDES,
96
+ ): void {
97
+ const declared = new Set<string>();
98
+
99
+ for (const [name, value] of Object.entries(obj)) {
100
+ if (name === "key") continue;
101
+ const entry = ZONE_ATTRIBUTES.get(name);
102
+ if (!entry) {
103
+ const suggestion = suggestAttribute(name);
104
+ issues.push({
105
+ code: "ZONE_ATTRIBUTE_UNKNOWN",
106
+ manifest: definition,
107
+ path,
108
+ message:
109
+ `${annotation} at '${path}' declares '${name}', which is not a zone attribute. ` +
110
+ (suggestion ? `Did you mean '${suggestion}'? ` : "") +
111
+ `The vocabulary is closed: ${zoneAttributeNames().join(", ")}. An attribute ` +
112
+ `states a property of everything executed inside this zone, and every reader ` +
113
+ `of one is core — a name outside the set would be read by nothing.`,
114
+ });
115
+ continue;
116
+ }
117
+ // Each value is the REASON, and it is required by being the value itself
118
+ // rather than a sibling of a boolean. That is also what makes a type check
119
+ // possible at all: there is no `true` to accept, so `atomic: true` is caught
120
+ // here rather than reading as a valid declaration with nothing to say.
121
+ if (typeof value !== "string" || !value) {
122
+ issues.push({
123
+ code: "ZONE_ANNOTATION_INVALID",
124
+ manifest: definition,
125
+ path,
126
+ message:
127
+ `${annotation} at '${path}' declares '${name}' as ${describe(value)}. Every zone ` +
128
+ `attribute's value is the author's REASON — a non-empty sentence, quoted verbatim ` +
129
+ `by the diagnostics that enforce it (${entry.description}).`,
130
+ });
131
+ continue;
132
+ }
133
+ declared.add(name);
134
+ }
135
+
136
+ // The completeness rule is about what a region PROMISES: declaring `atomic`
137
+ // without `noSuspend` is an incomplete promise. A VIOLATION is not a promise —
138
+ // breaking one guarantee implies nothing about breaking another — so the rule
139
+ // does not apply there.
140
+ if (annotation !== PROVIDES) return;
141
+ // `requires:` lives in the vocabulary entry rather than as a hardcoded pair of
142
+ // names here, so the completeness rule sits beside the thing it constrains.
143
+ for (const name of declared) {
144
+ for (const dependency of ZONE_ATTRIBUTES.get(name)!.requires) {
145
+ if (declared.has(dependency)) continue;
146
+ issues.push({
147
+ code: "ZONE_ATTRIBUTE_INCOMPLETE",
148
+ manifest: definition,
149
+ path,
150
+ message:
151
+ `${PROVIDES} at '${path}' declares '${name}' without '${dependency}', which it ` +
152
+ `requires. ${ZONE_ATTRIBUTES.get(dependency)!.description} Declare it with its own ` +
153
+ `reason — a generic message is exactly what the required reason exists to prevent.`,
154
+ });
155
+ }
156
+ }
157
+ }
158
+
56
159
  function checkProvides(
57
160
  raw: unknown,
58
161
  definition: ResourceManifest,
@@ -61,16 +164,38 @@ function checkProvides(
61
164
  ): void {
62
165
  if (raw === true) return;
63
166
  if (isPointer(raw)) return;
167
+
168
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
169
+ const obj = raw as Record<string, unknown>;
170
+ // `key` keeps the meaning it has in the scalar spelling; only its absence is
171
+ // legitimate here (an uncorrelated zone that still declares attributes).
172
+ if (obj.key !== undefined && !isPointer(obj.key)) {
173
+ issues.push({
174
+ code: "ZONE_ANNOTATION_INVALID",
175
+ manifest: definition,
176
+ path,
177
+ message:
178
+ `${PROVIDES} at '${path}' declares the correlation key ${describe(obj.key)}, which ` +
179
+ `is not a self-relative JSON Pointer. Write '/connection'. A bare field name is ` +
180
+ `read as a pointer by the runtime but skipped by the checker, so the two halves ` +
181
+ `would disagree about what this manifest means.`,
182
+ });
183
+ }
184
+ checkAttributes(obj, definition, path, issues);
185
+ return;
186
+ }
187
+
64
188
  issues.push({
65
189
  code: "ZONE_ANNOTATION_INVALID",
66
190
  manifest: definition,
67
191
  path,
68
192
  message:
69
193
  `${PROVIDES} at '${path}' is ${describe(raw)}. It takes 'true' (the zone is ` +
70
- `uncorrelated) or a self-relative JSON Pointer naming this kind's own field ` +
194
+ `uncorrelated), a self-relative JSON Pointer naming this kind's own field ` +
71
195
  `whose resolved reference the zone carries as its correlation payload ` +
72
- `(e.g. '/connection'). It never names the zone the zone a slot provides ` +
73
- `is always the declaring kind.`,
196
+ `(e.g. '/connection'), or an object carrying that pointer as 'key' beside the ` +
197
+ `zone attributes this region declares (${zoneAttributeNames().join(", ")}). It ` +
198
+ `never names the zone — the zone a slot provides is always the declaring kind.`,
74
199
  });
75
200
  }
76
201
 
@@ -129,17 +254,74 @@ function checkRequires(
129
254
  fail(`${REQUIRES} at '${path}' declares a non-string 'reason'.`);
130
255
  }
131
256
 
257
+ // `attributes` is the GUARANTEE half — what the satisfying zone must declare,
258
+ // as opposed to which kind it is. It is a list of NAMES with no reasons: the
259
+ // reason belongs to whoever makes the promise, and a requirer restating it
260
+ // would be a second sentence to keep in step with the one that is enforced.
261
+ if (obj.attributes !== undefined) {
262
+ if (!Array.isArray(obj.attributes) || obj.attributes.length === 0) {
263
+ fail(
264
+ `${REQUIRES} at '${path}' declares 'attributes' as ${describe(obj.attributes)}. It ` +
265
+ `takes a non-empty list of zone attribute names; omit it to require the kind alone.`,
266
+ );
267
+ } else {
268
+ for (const name of obj.attributes) {
269
+ if (typeof name === "string" && ZONE_ATTRIBUTES.has(name)) continue;
270
+ const suggestion = typeof name === "string" ? suggestAttribute(name) : undefined;
271
+ issues.push({
272
+ code: "ZONE_ATTRIBUTE_UNKNOWN",
273
+ manifest: definition,
274
+ path,
275
+ message:
276
+ `${REQUIRES} at '${path}' requires '${String(name)}', which is not a zone ` +
277
+ `attribute. ` +
278
+ (suggestion ? `Did you mean '${suggestion}'? ` : "") +
279
+ `The vocabulary is closed: ${zoneAttributeNames().join(", ")}.`,
280
+ });
281
+ }
282
+ }
283
+ }
284
+
132
285
  for (const key of Object.keys(obj)) {
133
- if (key === "zone" || key === "key" || key === "reason") {
286
+ if (key === "zone" || key === "key" || key === "reason" || key === "attributes") {
134
287
  continue;
135
288
  }
136
289
  fail(
137
290
  `${REQUIRES} at '${path}' declares an unknown property '${key}'. The object form takes ` +
138
- `'zone', 'key' and 'reason'.`,
291
+ `'zone', 'key', 'reason' and 'attributes'.`,
139
292
  );
140
293
  }
141
294
  }
142
295
 
296
+ /**
297
+ * The third annotation: what this kind cannot honour.
298
+ *
299
+ * Only the object form, because a violation without a reason is exactly the
300
+ * shape the vocabulary refuses everywhere else — the diagnostic that fires on it
301
+ * prints the region's promise and this resource's rebuttal side by side, and one
302
+ * of the two would otherwise be a generated sentence.
303
+ */
304
+ function checkViolates(
305
+ raw: unknown,
306
+ definition: ResourceManifest,
307
+ path: string,
308
+ issues: ZoneSlotIssue[],
309
+ ): void {
310
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
311
+ issues.push({
312
+ code: "ZONE_ANNOTATION_INVALID",
313
+ manifest: definition,
314
+ path,
315
+ message:
316
+ `${VIOLATES} at '${path}' is ${describe(raw)}. It takes a map of zone attribute ` +
317
+ `names to the reason this kind cannot honour each — ` +
318
+ `\`noSuspend: this waits for a delivery that may be days away\`.`,
319
+ });
320
+ return;
321
+ }
322
+ checkAttributes(raw as Record<string, unknown>, definition, path, issues, VIOLATES);
323
+ }
324
+
143
325
  /** Walk a definition schema, reporting every zone annotation it cannot read.
144
326
  * Pure-schema walk, so it needs a visited guard for cyclic `$defs`. */
145
327
  function walkSchema(
@@ -159,8 +341,10 @@ function walkSchema(
159
341
  const obj = node as Record<string, unknown>;
160
342
  if (obj[PROVIDES] !== undefined) checkProvides(obj[PROVIDES], definition, path, issues);
161
343
  if (obj[REQUIRES] !== undefined) checkRequires(obj[REQUIRES], definition, path, issues);
344
+ if (obj[VIOLATES] !== undefined) checkViolates(obj[VIOLATES], definition, path, issues);
162
345
  for (const [key, value] of Object.entries(obj)) {
163
- if (key === PROVIDES || key === REQUIRES || key === "examples" || key === "default") continue;
346
+ if (key === PROVIDES || key === REQUIRES || key === VIOLATES) continue;
347
+ if (key === "examples" || key === "default") continue;
164
348
  walkSchema(value, path ? `${path}.${key}` : key, visited, definition, issues);
165
349
  }
166
350
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `ZONE_ATTRIBUTE_VIOLATED` — a resource inside a region whose guarantee it
3
+ * declares it cannot honour.
4
+ *
5
+ * The third leg of the zone-attribute mechanism, and the one that makes the
6
+ * other two enforceable. `x-telo-provides-zone` says what a region guarantees
7
+ * about its contents and `x-telo-requires-zone` says what a resource needs of
8
+ * the region around it — but neither says *this resource breaks that
9
+ * guarantee*, so until now an attribute could be declared, resolved and read
10
+ * with nothing able to report the one thing it exists to prevent.
11
+ *
12
+ * **Generic over the whole vocabulary, and it names no kind.** The rule is one
13
+ * sentence — *a region declaring attribute A must not contain a resource
14
+ * declaring it violates A* — and it is run once per attribute in
15
+ * `sdk/zone-attributes/`. So `noSuspend` (a parking kind inside a lease) and any
16
+ * attribute added later are covered by the same traversal, and `modules/durable`
17
+ * stays outside the analyzer's surface exactly as the topology-driven constraint
18
+ * requires.
19
+ *
20
+ * **Both sentences are their authors' own.** The diagnostic prints the region's
21
+ * declared reason and the violating kind's declared reason side by side, because
22
+ * the useful message is the collision between two written claims — "this
23
+ * transaction holds a connection a parked run would lose" against "this waits
24
+ * for a delivery that may be days away" — and neither half is anything the
25
+ * analyzer could generate.
26
+ *
27
+ * **Enforced at runtime, warned early**, like every other containment rule: the
28
+ * walk under-approximates (an edge the call graph cannot see is invisible here),
29
+ * and the runtime check in the violating controller is the real enforcement. It
30
+ * is an ERROR rather than a warning nonetheless: where the walk *can* see the
31
+ * path, the placement is decidably wrong — the region's promise and the
32
+ * resource's rebuttal are both declarations, so nothing about the runtime can
33
+ * reconcile them.
34
+ *
35
+ * Browser-safe: no Node built-ins.
36
+ */
37
+ import {
38
+ SUSPENDING_BACKOFF_MS,
39
+ ZONE_ATTRIBUTES,
40
+ retryBackoffMs,
41
+ type ResourceManifest,
42
+ } from "@telorun/sdk";
43
+ import type { CallGraph } from "./call-graph.js";
44
+ import { findZoneRegions, type DefinitionLookup } from "./resolve-zone-containment.js";
45
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
46
+ import { readViolatesZone } from "./zone-slot.js";
47
+
48
+ const SOURCE = "telo";
49
+
50
+ export interface ZoneViolationArgs {
51
+ graph: CallGraph;
52
+ resolveDef: DefinitionLookup;
53
+ /** Only report against modules the entry owns — a published dependency's
54
+ * placement is not the consumer's to fix. */
55
+ reportModules: ReadonlySet<string>;
56
+ }
57
+
58
+ export function validateZoneViolations(args: ZoneViolationArgs): AnalysisDiagnostic[] {
59
+ const { graph, resolveDef, reportModules } = args;
60
+ const diagnostics: AnalysisDiagnostic[] = [];
61
+ const reported = new Set<string>();
62
+
63
+ // What each kind in the graph declares it cannot honour, resolved once. A
64
+ // graph holds many instances of few kinds, and this is a schema read.
65
+ const violationsOf = new Map<string, Record<string, string>>();
66
+ const violations = (kind: string, module?: string): Record<string, string> => {
67
+ const key = `${module ?? ""}\0${kind}`;
68
+ let found = violationsOf.get(key);
69
+ if (!found) {
70
+ const def = resolveDef(kind, module);
71
+ found = readViolatesZone(def?.schema as Record<string, any> | undefined);
72
+ violationsOf.set(key, found);
73
+ }
74
+ return found;
75
+ };
76
+
77
+ for (const attribute of ZONE_ATTRIBUTES.keys()) {
78
+ for (const region of findZoneRegions(graph, resolveDef, attribute)) {
79
+ for (const contained of region.contents.values()) {
80
+ // The one step-level rule, and it is separate because it is a property
81
+ // of the STEP rather than of its target: a retry whose backoff is long
82
+ // enough to park suspends the run from inside a region that promised
83
+ // nothing in it would. Only `noSuspend` has such a rule, because only
84
+ // `noSuspend` is broken by waiting.
85
+ if (attribute === "noSuspend" && contained.node.type === "step") {
86
+ const ownerNode = graph.nodes.get(contained.node.owner);
87
+ const owner = ownerNode?.type === "resource" ? ownerNode : undefined;
88
+ if (
89
+ owner &&
90
+ owned(owner.manifest, reportModules) &&
91
+ suspendingBackoff(contained.node.step.retry)
92
+ ) {
93
+ const dedupe = `retry\0${contained.node.id}`;
94
+ if (!reported.has(dedupe)) {
95
+ reported.add(dedupe);
96
+ diagnostics.push({
97
+ severity: DiagnosticSeverity.Error,
98
+ code: "ZONE_ATTRIBUTE_VIOLATED",
99
+ source: SOURCE,
100
+ message:
101
+ `Step '${contained.node.name ?? contained.node.path}' declares a retry ` +
102
+ `whose last backoff reaches ${SUSPENDING_BACKOFF_MS}ms, at which point a ` +
103
+ `re-attempt waits by PARKING the run rather than by sleeping — but it is ` +
104
+ `inside a ${region.provider.kind} ` +
105
+ `'${region.provider.name}' region that declares 'noSuspend' ` +
106
+ `(${region.reason}). Shorten the backoff, or move the retry outside the ` +
107
+ `region so it re-attempts the region as a whole.`,
108
+ data: {
109
+ resource: { kind: owner.kind, name: owner.name },
110
+ filePath: (owner.manifest.metadata as { source?: string } | undefined)?.source,
111
+ path: contained.node.path,
112
+ },
113
+ });
114
+ }
115
+ }
116
+ }
117
+ // A step node is a dispatch site, not a resource — whatever it reaches
118
+ // is in `contents` in its own right, so judging the step too would
119
+ // report one placement twice.
120
+ if (contained.node.type !== "resource") continue;
121
+ const manifest = contained.node.manifest;
122
+ const module = (manifest.metadata as { module?: string } | undefined)?.module;
123
+ const rebuttal = violations(contained.node.kind, module)[attribute];
124
+ if (!rebuttal) continue;
125
+ if (!owned(manifest, reportModules)) continue;
126
+
127
+ const dedupe = `${region.provider.id}\0${region.slot}\0${contained.node.id}\0${attribute}`;
128
+ if (reported.has(dedupe)) continue;
129
+ reported.add(dedupe);
130
+
131
+ const path = [`${region.provider.name}.${region.slot}`, ...contained.via].join(" → ");
132
+ diagnostics.push({
133
+ severity: DiagnosticSeverity.Error,
134
+ code: "ZONE_ATTRIBUTE_VIOLATED",
135
+ source: SOURCE,
136
+ message:
137
+ `${contained.node.kind} '${contained.node.name}' is inside a ` +
138
+ `${region.provider.kind} '${region.provider.name}' region that declares ` +
139
+ `'${attribute}' (${region.reason}), but ${contained.node.kind} cannot honour ` +
140
+ `it: ${rebuttal}. Reached by ${path}. Move it outside the region, or use a ` +
141
+ `region that does not make that promise.`,
142
+ data: {
143
+ resource: { kind: contained.node.kind, name: contained.node.name },
144
+ filePath: (manifest.metadata as { source?: string } | undefined)?.source,
145
+ },
146
+ });
147
+ }
148
+ }
149
+ }
150
+
151
+ return diagnostics;
152
+ }
153
+
154
+ /**
155
+ * Does this declared retry policy back off far enough to park?
156
+ *
157
+ * EXACT, not an estimate, and it is the runtime's own arithmetic
158
+ * (`retryBackoffMs`) rather than a second copy of it — a static rule that
159
+ * re-derives what it describes drifts from the behaviour the first time either
160
+ * side gains a knob. It is exact for a second reason too: the runtime branches
161
+ * on the UN-JITTERED backoff, so there is no coin flip for this to
162
+ * approximate. The last attempt has the largest backoff, so it decides.
163
+ *
164
+ * Only a STATICALLY KNOWN policy is judged — the `LIVE_VALUE_RETRIED` posture,
165
+ * and for its reason: a CEL budget says nothing, and guessing would report a
166
+ * conflict against a manifest that may never wait that long. An unreadable
167
+ * policy is left to the runtime check, which sees the real number.
168
+ */
169
+ function suspendingBackoff(raw: unknown): boolean {
170
+ if (!raw || typeof raw !== "object") return false;
171
+ const policy = raw as Record<string, unknown>;
172
+ if (!Object.values(policy).every(isStaticValue)) return false;
173
+ const attempts = policy.attempts;
174
+ if (typeof attempts !== "number" || attempts <= 0) return false;
175
+ return retryBackoffMs(policy as never, attempts - 1) >= SUSPENDING_BACKOFF_MS;
176
+ }
177
+
178
+ /** A CEL leaf reaches here as a compiled value or a tagged sentinel, and either
179
+ * way the policy is not statically known. Refusing the whole policy rather than
180
+ * the one field is the conservative direction: `maxDelay` alone decides the
181
+ * answer, so reading around an unresolved one would report a bound the manifest
182
+ * may never reach. */
183
+ function isStaticValue(value: unknown): boolean {
184
+ return value === undefined || typeof value === "number" || typeof value === "string";
185
+ }
186
+
187
+ function owned(manifest: ResourceManifest, reportModules: ReadonlySet<string>): boolean {
188
+ const module = (manifest.metadata as { module?: string } | undefined)?.module;
189
+ return !module || reportModules.size === 0 || reportModules.has(module);
190
+ }
@@ -63,7 +63,11 @@ export const ANNOTATION_KEYWORDS = [
63
63
  "x-telo-provides-zone",
64
64
  "x-telo-ref",
65
65
  "x-telo-requires-zone",
66
+ "x-telo-resource-rules",
66
67
  "x-telo-schema-from",
68
+ "x-telo-schema-map",
69
+ "x-telo-schema-projection",
70
+ "x-telo-schema-projection-from",
67
71
  "x-telo-scope",
68
72
  "x-telo-step-context",
69
73
  "x-telo-topology-role",
package/src/zone-slot.ts CHANGED
@@ -10,6 +10,13 @@
10
10
  *
11
11
  * x-telo-provides-zone: true # uncorrelated — the zone is the kind
12
12
  * x-telo-provides-zone: /connection # correlation-key pointer (own field)
13
+ * x-telo-provides-zone: # correlation key + zone attributes
14
+ * key: /connection
15
+ * atomic: a rollback erases writes a journal recorded as done
16
+ * noSuspend: the transaction holds a connection a parked run would lose
17
+ *
18
+ * x-telo-violates-zone: # what this kind cannot honour
19
+ * noSuspend: this waits for a delivery that may be days away
13
20
  *
14
21
  * x-telo-requires-zone: Self.Transaction # uncorrelated string form
15
22
  * x-telo-requires-zone: # object form
@@ -18,8 +25,11 @@
18
25
  * reason: the statement would execute outside any transaction
19
26
  */
20
27
 
28
+ import { ZONE_ATTRIBUTES, type ZoneAttributes } from "@telorun/sdk";
29
+
21
30
  const PROVIDES = "x-telo-provides-zone";
22
31
  const REQUIRES = "x-telo-requires-zone";
32
+ const VIOLATES = "x-telo-violates-zone";
23
33
 
24
34
  /** A body slot that establishes the declaring kind's zone when dispatched
25
35
  * through. The zone's identity is always the declaring kind — the annotation
@@ -29,6 +39,16 @@ export interface ProvidesZoneSlot {
29
39
  * reference the zone carries as its correlation payload. Absent =
30
40
  * uncorrelated (`true`). */
31
41
  key?: string;
42
+ /** What this zone declares about everything executed inside it, keyed by the
43
+ * closed vocabulary's bare names with the author's REASON as each value (see
44
+ * `sdk/zone-attributes/`). Empty for the two scalar spellings, which say
45
+ * nothing about their contents.
46
+ *
47
+ * Read here and interpreted nowhere in this file: an attribute's meaning is
48
+ * entirely its consumer's — the containment walk, the step engine's collapse
49
+ * rule, the parking kinds — exactly as `readRefSlot` hands back `use` without
50
+ * acting on it. */
51
+ attributes: ZoneAttributes;
32
52
  }
33
53
 
34
54
  /** A field declaring that its resource must be reached through a zone. */
@@ -43,6 +63,22 @@ export interface RequiresZoneSlot {
43
63
  key: string[];
44
64
  /** The runtime consequence, quoted after the path in diagnostics. */
45
65
  reason?: string;
66
+ /**
67
+ * Zone attributes the satisfying zone must DECLARE — what it must guarantee,
68
+ * as opposed to which kind it is.
69
+ *
70
+ * The two are different questions and the kind test alone cannot answer the
71
+ * second. A kind may extend the required abstract, and so satisfy every kind
72
+ * check, while its body slot omits the attribute the requirer actually depends
73
+ * on — a durable workflow whose body does not declare `replayed` is a zone the
74
+ * durable checks never look inside, and a run parking there parks against
75
+ * nothing. Naming the attribute is what makes the requirement say what it
76
+ * means.
77
+ *
78
+ * Names come from the closed vocabulary (`sdk/zone-attributes/`), so the
79
+ * analyzer reads its own words here and no module's kind is named.
80
+ */
81
+ attributes: string[];
46
82
  }
47
83
 
48
84
  /** A self-relative JSON Pointer — the only correlation-key spelling the
@@ -59,9 +95,36 @@ function isPointer(value: unknown): value is string {
59
95
  * none or the value is malformed (`validate-zone-slots` reports those). */
60
96
  export function readProvidesZone(node: Record<string, any> | undefined): ProvidesZoneSlot | undefined {
61
97
  const raw = node?.[PROVIDES];
62
- if (raw === true) return {};
63
- if (isPointer(raw)) return { key: raw };
64
- return undefined;
98
+ if (raw === true) return { attributes: {} };
99
+ if (isPointer(raw)) return { key: raw, attributes: {} };
100
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
101
+
102
+ const obj = raw as Record<string, unknown>;
103
+ // A `key` that is present but unreadable makes the object form MEAN something
104
+ // different to the two halves — the correlation the author wrote is dropped
105
+ // here while the kernel's walk still resolves a bare name — so the whole
106
+ // annotation is refused rather than read as uncorrelated. Absent `key` is the
107
+ // legitimate uncorrelated case.
108
+ if (obj.key !== undefined && !isPointer(obj.key)) return undefined;
109
+
110
+ const attributes: Record<string, string> = {};
111
+ for (const [name, value] of Object.entries(obj)) {
112
+ if (name === "key") continue;
113
+ // Unknown names and wrong-shaped values are reported by
114
+ // `validate-zone-slots`; skipped here so a typo degrades to an attribute
115
+ // this zone does not declare rather than to a consumer reading a name that
116
+ // means nothing to it.
117
+ if (!ZONE_ATTRIBUTES.has(name) || typeof value !== "string" || !value) continue;
118
+ attributes[name] = value;
119
+ }
120
+ return { ...(isPointer(obj.key) ? { key: obj.key } : {}), attributes: attributes as ZoneAttributes };
121
+ }
122
+
123
+ /** The attributes the zone a slot provides declares, or an empty record when the
124
+ * node provides no zone. The shape every containment consumer wants, so none of
125
+ * them repeats the `readProvidesZone(...)?.attributes ?? {}` dance. */
126
+ export function providedZoneAttributes(node: Record<string, any> | undefined): ZoneAttributes {
127
+ return readProvidesZone(node)?.attributes ?? {};
65
128
  }
66
129
 
67
130
  /** True when the node carries `x-telo-provides-zone` in any shape, valid or not
@@ -74,17 +137,63 @@ export function hasProvidesZone(node: Record<string, any> | undefined): boolean
74
137
  * none or the value is malformed (`validate-zone-slots` reports those). */
75
138
  export function readRequiresZone(node: Record<string, any> | undefined): RequiresZoneSlot | undefined {
76
139
  const raw = node?.[REQUIRES];
77
- if (typeof raw === "string" && raw) return { zone: raw, key: [] };
140
+ if (typeof raw === "string" && raw) return { zone: raw, key: [], attributes: [] };
78
141
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
79
142
  const obj = raw as Record<string, unknown>;
80
143
  if (typeof obj.zone !== "string" || !obj.zone) return undefined;
81
144
  // One filter for both spellings — see `isPointer`.
82
145
  const key = (Array.isArray(obj.key) ? obj.key : [obj.key]).filter(isPointer);
83
- const slot: RequiresZoneSlot = { zone: obj.zone, key };
146
+ // Unknown names are dropped here and reported by `validate-zone-slots`, the
147
+ // lenient-reader / strict-validator split this file has throughout: a typo
148
+ // degrades to a guarantee this requirement does not ask for, never to a
149
+ // consumer matching against a name that means nothing.
150
+ const attributes = (Array.isArray(obj.attributes) ? obj.attributes : []).filter(
151
+ (a): a is string => typeof a === "string" && ZONE_ATTRIBUTES.has(a),
152
+ );
153
+ const slot: RequiresZoneSlot = { zone: obj.zone, key, attributes };
84
154
  if (typeof obj.reason === "string") slot.reason = obj.reason;
85
155
  return slot;
86
156
  }
87
157
 
158
+ /**
159
+ * What this kind CANNOT honour about a region it is placed inside.
160
+ *
161
+ * The third relation, and the one the other two cannot express. `provides`
162
+ * declares what a region guarantees about its contents; `requires` declares what
163
+ * a resource needs of the region around it. Neither says *this resource breaks
164
+ * that guarantee* — and without it a zone attribute has a promise and no way to
165
+ * name what falsifies it: `noSuspend` would be enforced only when a parking kind
166
+ * happened to reach its runtime check, and `Durable.Sleep` would be
167
+ * indistinguishable from `Durable.Value`, which needs the same journal and parks
168
+ * nothing.
169
+ *
170
+ * Declared at the kind's SCHEMA ROOT, because it is a property of the kind
171
+ * rather than of one of its slots — a kind that suspends suspends however it is
172
+ * configured.
173
+ *
174
+ * Values are the author's REASON, exactly as on `provides`: a diagnostic prints
175
+ * the region's promise and this resource's rebuttal side by side, and both are
176
+ * their own authors' words.
177
+ */
178
+ export function readViolatesZone(node: Record<string, any> | undefined): ZoneAttributes {
179
+ const raw = node?.[VIOLATES];
180
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
181
+ const out: Record<string, string> = {};
182
+ for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
183
+ // Lenient, like every reader here: `validate-zone-slots` reports a typo, and
184
+ // dropping it degrades to a violation nobody declared rather than to a
185
+ // consumer matching a name that means nothing.
186
+ if (!ZONE_ATTRIBUTES.has(name) || typeof value !== "string" || !value) continue;
187
+ out[name] = value;
188
+ }
189
+ return out as ZoneAttributes;
190
+ }
191
+
192
+ /** True when the node carries `x-telo-violates-zone` in any shape, valid or not. */
193
+ export function hasViolatesZone(node: Record<string, any> | undefined): boolean {
194
+ return node?.[VIOLATES] !== undefined;
195
+ }
196
+
88
197
  /** True when the node carries `x-telo-requires-zone` in any shape. */
89
198
  export function hasRequiresZone(node: Record<string, any> | undefined): boolean {
90
199
  return node?.[REQUIRES] !== undefined;