@telorun/analyzer 0.62.1 → 0.64.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 (171) hide show
  1. package/dist/analysis-registry.d.ts +24 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +41 -0
  4. package/dist/analyzer.d.ts +3 -37
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +396 -476
  7. package/dist/call-graph.d.ts.map +1 -1
  8. package/dist/call-graph.js +2 -7
  9. package/dist/cel-scope-query.d.ts +109 -0
  10. package/dist/cel-scope-query.d.ts.map +1 -0
  11. package/dist/cel-scope-query.js +270 -0
  12. package/dist/cel-scope.d.ts +166 -0
  13. package/dist/cel-scope.d.ts.map +1 -0
  14. package/dist/cel-scope.js +377 -0
  15. package/dist/definition-registry.d.ts +15 -0
  16. package/dist/definition-registry.d.ts.map +1 -1
  17. package/dist/definition-registry.js +25 -9
  18. package/dist/find-manifest.d.ts +10 -0
  19. package/dist/find-manifest.d.ts.map +1 -0
  20. package/dist/find-manifest.js +12 -0
  21. package/dist/flatten-for-analyzer.js +1 -1
  22. package/dist/import-resolution-diagnostics.d.ts +9 -2
  23. package/dist/import-resolution-diagnostics.d.ts.map +1 -1
  24. package/dist/import-resolution-diagnostics.js +26 -7
  25. package/dist/index.d.ts +22 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +22 -1
  28. package/dist/invocation-contract.d.ts +17 -1
  29. package/dist/invocation-contract.d.ts.map +1 -1
  30. package/dist/invocation-contract.js +42 -4
  31. package/dist/loaded-types.d.ts +12 -3
  32. package/dist/loaded-types.d.ts.map +1 -1
  33. package/dist/manifest-analysis.d.ts +73 -0
  34. package/dist/manifest-analysis.d.ts.map +1 -0
  35. package/dist/manifest-analysis.js +78 -0
  36. package/dist/manifest-loader.d.ts +0 -1
  37. package/dist/manifest-loader.d.ts.map +1 -1
  38. package/dist/manifest-loader.js +62 -41
  39. package/dist/manifest-path.d.ts +18 -0
  40. package/dist/manifest-path.d.ts.map +1 -0
  41. package/dist/manifest-path.js +37 -0
  42. package/dist/manifest-schemas.d.ts +699 -0
  43. package/dist/manifest-schemas.d.ts.map +1 -1
  44. package/dist/manifest-schemas.js +306 -14
  45. package/dist/module-compatibility.d.ts +40 -0
  46. package/dist/module-compatibility.d.ts.map +1 -0
  47. package/dist/module-compatibility.js +52 -0
  48. package/dist/ref-slot-reading.d.ts +30 -0
  49. package/dist/ref-slot-reading.d.ts.map +1 -0
  50. package/dist/ref-slot-reading.js +139 -0
  51. package/dist/referrer-rule.d.ts +67 -0
  52. package/dist/referrer-rule.d.ts.map +1 -0
  53. package/dist/referrer-rule.js +96 -0
  54. package/dist/release/index.d.ts +1 -1
  55. package/dist/release/index.d.ts.map +1 -1
  56. package/dist/release/index.js +1 -1
  57. package/dist/release/version-stamp.d.ts +16 -0
  58. package/dist/release/version-stamp.d.ts.map +1 -1
  59. package/dist/release/version-stamp.js +52 -0
  60. package/dist/release/workspace-config.d.ts +10 -6
  61. package/dist/release/workspace-config.d.ts.map +1 -1
  62. package/dist/release/workspace-config.js +10 -6
  63. package/dist/resolve-schema-ref-kinds.d.ts +4 -3
  64. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  65. package/dist/resolve-schema-ref-kinds.js +16 -0
  66. package/dist/resolve-throws-union.d.ts.map +1 -1
  67. package/dist/resolve-throws-union.js +4 -3
  68. package/dist/resolve-zone-containment.d.ts +99 -0
  69. package/dist/resolve-zone-containment.d.ts.map +1 -0
  70. package/dist/resolve-zone-containment.js +212 -0
  71. package/dist/resolve-zone-requirements.d.ts +5 -0
  72. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  73. package/dist/resolve-zone-requirements.js +48 -7
  74. package/dist/resource-rule.d.ts +106 -0
  75. package/dist/resource-rule.d.ts.map +1 -0
  76. package/dist/resource-rule.js +236 -0
  77. package/dist/rule-condition.d.ts +50 -0
  78. package/dist/rule-condition.d.ts.map +1 -0
  79. package/dist/rule-condition.js +108 -0
  80. package/dist/schema-keywords.d.ts.map +1 -1
  81. package/dist/schema-keywords.js +20 -0
  82. package/dist/schema-projection.d.ts +171 -0
  83. package/dist/schema-projection.d.ts.map +1 -0
  84. package/dist/schema-projection.js +317 -0
  85. package/dist/schema-walk.d.ts +25 -0
  86. package/dist/schema-walk.d.ts.map +1 -0
  87. package/dist/schema-walk.js +126 -0
  88. package/dist/step-slot.d.ts +39 -0
  89. package/dist/step-slot.d.ts.map +1 -0
  90. package/dist/step-slot.js +49 -0
  91. package/dist/telo-version.d.ts +1 -1
  92. package/dist/telo-version.js +1 -1
  93. package/dist/validate-cel-context.d.ts.map +1 -1
  94. package/dist/validate-cel-context.js +17 -3
  95. package/dist/validate-durable-regions.d.ts +15 -0
  96. package/dist/validate-durable-regions.d.ts.map +1 -0
  97. package/dist/validate-durable-regions.js +336 -0
  98. package/dist/validate-references.d.ts.map +1 -1
  99. package/dist/validate-references.js +44 -2
  100. package/dist/validate-referrer-rules.d.ts +120 -0
  101. package/dist/validate-referrer-rules.d.ts.map +1 -0
  102. package/dist/validate-referrer-rules.js +255 -0
  103. package/dist/validate-resource-rules.d.ts +114 -0
  104. package/dist/validate-resource-rules.d.ts.map +1 -0
  105. package/dist/validate-resource-rules.js +283 -0
  106. package/dist/validate-schema-projection.d.ts +32 -0
  107. package/dist/validate-schema-projection.d.ts.map +1 -0
  108. package/dist/validate-schema-projection.js +110 -0
  109. package/dist/validate-step-inputs.d.ts +19 -1
  110. package/dist/validate-step-inputs.d.ts.map +1 -1
  111. package/dist/validate-step-inputs.js +102 -9
  112. package/dist/validate-throws-coverage.d.ts.map +1 -1
  113. package/dist/validate-throws-coverage.js +11 -5
  114. package/dist/validate-zone-slots.d.ts +2 -2
  115. package/dist/validate-zone-slots.d.ts.map +1 -1
  116. package/dist/validate-zone-slots.js +202 -6
  117. package/dist/validate-zone-violations.d.ts +12 -0
  118. package/dist/validate-zone-violations.d.ts.map +1 -0
  119. package/dist/validate-zone-violations.js +169 -0
  120. package/dist/value-type-keyword.d.ts +1 -1
  121. package/dist/value-type-keyword.d.ts.map +1 -1
  122. package/dist/value-type-keyword.js +4 -0
  123. package/dist/zone-slot.d.ts +61 -0
  124. package/dist/zone-slot.d.ts.map +1 -1
  125. package/dist/zone-slot.js +86 -5
  126. package/package.json +2 -2
  127. package/src/analysis-registry.ts +43 -0
  128. package/src/analyzer.ts +452 -595
  129. package/src/call-graph.ts +2 -8
  130. package/src/cel-scope-query.ts +337 -0
  131. package/src/cel-scope.ts +570 -0
  132. package/src/definition-registry.ts +31 -13
  133. package/src/find-manifest.ts +19 -0
  134. package/src/flatten-for-analyzer.ts +1 -1
  135. package/src/import-resolution-diagnostics.ts +25 -7
  136. package/src/index.ts +86 -0
  137. package/src/invocation-contract.ts +67 -2
  138. package/src/loaded-types.ts +12 -3
  139. package/src/manifest-analysis.ts +132 -0
  140. package/src/manifest-loader.ts +87 -55
  141. package/src/manifest-path.ts +34 -0
  142. package/src/manifest-schemas.ts +328 -14
  143. package/src/module-compatibility.ts +72 -0
  144. package/src/ref-slot-reading.ts +156 -0
  145. package/src/referrer-rule.ts +116 -0
  146. package/src/release/index.ts +1 -0
  147. package/src/release/version-stamp.ts +58 -0
  148. package/src/release/workspace-config.ts +10 -6
  149. package/src/resolve-schema-ref-kinds.ts +28 -4
  150. package/src/resolve-throws-union.ts +4 -3
  151. package/src/resolve-zone-containment.ts +329 -0
  152. package/src/resolve-zone-requirements.ts +67 -7
  153. package/src/resource-rule.ts +252 -0
  154. package/src/rule-condition.ts +126 -0
  155. package/src/schema-keywords.ts +24 -0
  156. package/src/schema-projection.ts +403 -0
  157. package/src/schema-walk.ts +144 -0
  158. package/src/step-slot.ts +64 -0
  159. package/src/telo-version.ts +1 -1
  160. package/src/validate-cel-context.ts +17 -3
  161. package/src/validate-durable-regions.ts +401 -0
  162. package/src/validate-references.ts +57 -2
  163. package/src/validate-referrer-rules.ts +373 -0
  164. package/src/validate-resource-rules.ts +401 -0
  165. package/src/validate-schema-projection.ts +184 -0
  166. package/src/validate-step-inputs.ts +148 -10
  167. package/src/validate-throws-coverage.ts +12 -5
  168. package/src/validate-zone-slots.ts +192 -8
  169. package/src/validate-zone-violations.ts +190 -0
  170. package/src/value-type-keyword.ts +4 -0
  171. package/src/zone-slot.ts +114 -5
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The single reader for `x-telo-referrer-rules` — a kind declaring, as data,
3
+ * what must be true of whoever REFERENCES one of its resources.
4
+ *
5
+ * `x-telo-resource-rules` relates the fields of one resource; this relates a
6
+ * resource to the one that reached it. `Http.Reference` renders the OpenAPI
7
+ * document its server collects, so a server mounting it without an `openapi:`
8
+ * block has nothing to render — a disagreement neither kind can state alone,
9
+ * and one that otherwise surfaces only at boot, after the port is bound.
10
+ *
11
+ * Declared by the kind that HAS the requirement, never by the kind that must
12
+ * satisfy it. That is what lets a third-party mount carry its own requirement
13
+ * without `Http.Server` knowing the kind exists — and it is what keeps the rule
14
+ * free of a kind literal on the subject side, where the spelling would be the
15
+ * consumer's import alias rather than anything the rule author controls.
16
+ *
17
+ * The CEL scope is `self` (the declaring resource) and `referrer` (the one that
18
+ * references it). Deliberately NOT `this`: in a resource rule `this` is an
19
+ * ELEMENT of a collection, and binding it to a whole foreign manifest would give
20
+ * one word two meanings across the two families.
21
+ *
22
+ * Lenient by design, the `ref-slot.ts` precedent: anything unreadable here reads
23
+ * as absent, and `validate-referrer-rules.ts` is the strict half that reports it.
24
+ *
25
+ * Browser-safe: no Node built-ins.
26
+ */
27
+ import { celSourceOf, type ResourceRuleSeverity } from "./resource-rule.js";
28
+
29
+ export const REFERRER_RULES_ANNOTATION = "x-telo-referrer-rules";
30
+
31
+ export interface ReferrerRule {
32
+ /**
33
+ * Kind the referring resource must be for this rule to apply, in the
34
+ * alias-qualified grammar `extends:` and `x-telo-ref` use — canonicalized in
35
+ * the declaring module's scope at registration, so evaluation never sees an
36
+ * alias. Absent = any referrer, which conflates "references me" with the
37
+ * relation the rule is about, so a kind should write it.
38
+ */
39
+ readonly referrer?: string;
40
+ /** CEL source. TRUE when the rule holds. */
41
+ readonly condition: string;
42
+ /** The rule's own name, carried in `data.rule`. Never a diagnostic code —
43
+ * every violation reports under the analyzer-owned envelope. */
44
+ readonly code: string;
45
+ readonly message: string;
46
+ readonly severity: ResourceRuleSeverity;
47
+ /** Position in the annotation array — the anchor for a declaration defect. */
48
+ readonly index: number;
49
+ }
50
+
51
+ function isObject(value: unknown): value is Record<string, unknown> {
52
+ return !!value && typeof value === "object" && !Array.isArray(value);
53
+ }
54
+
55
+ /** The annotation exactly as written, for the strict half. `undefined` when the
56
+ * kind declares none; a non-array is returned as-is so the shape can be
57
+ * reported rather than silently skipped. */
58
+ export function readRawReferrerRules(schema: unknown): unknown {
59
+ if (!isObject(schema)) return undefined;
60
+ return schema[REFERRER_RULES_ANNOTATION];
61
+ }
62
+
63
+ /** Every rule this kind declares that is well-formed enough to run. */
64
+ export function readReferrerRules(schema: unknown): ReferrerRule[] {
65
+ const raw = readRawReferrerRules(schema);
66
+ if (!Array.isArray(raw)) return [];
67
+ const rules: ReferrerRule[] = [];
68
+ raw.forEach((entry, index) => {
69
+ if (!isObject(entry)) return;
70
+ const condition = celSourceOf(entry.condition);
71
+ const { code, message, referrer } = entry;
72
+ if (!condition || typeof code !== "string" || typeof message !== "string") return;
73
+ if (code.length === 0 || message.length === 0) return;
74
+ if (referrer !== undefined && typeof referrer !== "string") return;
75
+ if (entry.severity !== undefined && entry.severity !== "warning" && entry.severity !== "error") {
76
+ return;
77
+ }
78
+ rules.push({
79
+ ...(referrer === undefined ? {} : { referrer }),
80
+ condition,
81
+ code,
82
+ message,
83
+ severity: entry.severity === "warning" ? "warning" : "error",
84
+ index,
85
+ });
86
+ });
87
+ return rules;
88
+ }
89
+
90
+ /** True when this node carries the annotation — the recognizer the schema-kind
91
+ * canonicalization walk tests, so shape knowledge stays in this file. */
92
+ export function hasReferrerRules(node: Record<string, unknown>): boolean {
93
+ return Array.isArray(node[REFERRER_RULES_ANNOTATION]);
94
+ }
95
+
96
+ /**
97
+ * Rewrite each rule's `referrer:` kind through `rewrite`, in place.
98
+ *
99
+ * The mirror of `rewriteRequiresZoneKind`: the alias→canonical rule lives in
100
+ * `resolve-schema-ref-kinds.ts` and the shape lives here, so a filter is
101
+ * canonical everywhere downstream and a name that resolves to nothing is
102
+ * reported once, at the kind that wrote it. A rewrite returning `undefined`
103
+ * leaves the value as written — the caller reports it.
104
+ */
105
+ export function rewriteReferrerRuleKinds(
106
+ node: Record<string, unknown>,
107
+ rewrite: (kind: string) => string | undefined,
108
+ ): void {
109
+ const raw = node[REFERRER_RULES_ANNOTATION];
110
+ if (!Array.isArray(raw)) return;
111
+ for (const entry of raw) {
112
+ if (!isObject(entry) || typeof entry.referrer !== "string") continue;
113
+ const rewritten = rewrite(entry.referrer);
114
+ if (rewritten !== undefined) entry.referrer = rewritten;
115
+ }
116
+ }
@@ -59,6 +59,7 @@ export {
59
59
  readManifestVersion,
60
60
  stampCrateVersion,
61
61
  stampManifestVersion,
62
+ stampSelfNpmPins,
62
63
  stampPackageVersion,
63
64
  } from "./version-stamp.js";
64
65
 
@@ -66,6 +66,64 @@ export function stampManifestVersion(
66
66
  return spliceScalar(text, node, version, where);
67
67
  }
68
68
 
69
+ /**
70
+ * `pkg:npm/<name>@<version>` candidates naming the module's OWN package.
71
+ *
72
+ * A module has one version, and its manifest pins the package it ships itself —
73
+ * so the pin has to move with the rest of it. Left behind, the manifest names a
74
+ * tarball older than the module describing it, which is exactly how an
75
+ * npm-delivered module came to point at a years-old version of itself.
76
+ *
77
+ * Anchored on the `controllers:` SCALARS the YAML actually holds, not on a free
78
+ * text match: this file's whole rule is find the scalar, splice its span, touch
79
+ * nothing else, and a bare regex over the document would also reach a PURL
80
+ * written in a description, an example or a comment. Matched by exact package
81
+ * name, so a PURL naming a DIFFERENT package — a real dependency, pinned
82
+ * deliberately — is untouched.
83
+ */
84
+ export function stampSelfNpmPins(
85
+ text: string,
86
+ packageName: string,
87
+ version: string,
88
+ where = "controllers",
89
+ ): string {
90
+ const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
91
+ const prefix = `pkg:npm/${packageName}@`;
92
+ const edits: { start: number; end: number; newText: string }[] = [];
93
+
94
+ for (const doc of docs) {
95
+ const controllers = doc.get("controllers", true);
96
+ const items = (controllers as { items?: unknown[] } | undefined)?.items;
97
+ if (!Array.isArray(items)) continue;
98
+ for (const item of items) {
99
+ if (!isScalar(item) || typeof item.value !== "string") continue;
100
+ const purl = item.value;
101
+ if (!purl.startsWith(prefix)) continue;
102
+ // Everything from the version up to the first qualifier or fragment is the
103
+ // pin; the rest of the PURL (`?local_path=…#Export`) is carried through.
104
+ const rest = purl.slice(prefix.length);
105
+ const tail = rest.search(/[?#]/);
106
+ const rewritten = `${prefix}${version}${tail === -1 ? "" : rest.slice(tail)}`;
107
+ if (rewritten === purl) continue;
108
+ const range = item.range;
109
+ if (!range) {
110
+ throw new VersionStampError(`${where}: a controller PURL carries no source range.`);
111
+ }
112
+ const [start, end] = range;
113
+ const replacement = renderFixReplacement(text.slice(start, end), rewritten);
114
+ if (replacement === undefined) {
115
+ throw new VersionStampError(
116
+ `${where}: the controller PURL '${purl}' cannot be rewritten in place. ` +
117
+ `Write it as a plain or quoted scalar on one line.`,
118
+ );
119
+ }
120
+ edits.push({ start, end, newText: replacement });
121
+ }
122
+ }
123
+
124
+ return edits.length === 0 ? text : applyTextEdits(text, edits);
125
+ }
126
+
69
127
  /** Read the module doc's `metadata.version` without rewriting it. */
70
128
  export function readManifestVersion(text: string): string | undefined {
71
129
  const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
@@ -8,12 +8,16 @@
8
8
  * would read every example and every cached `.telo/manifests` copy as a released
9
9
  * module).
10
10
  *
11
- * The file is **optional and read only by `telo release`**. Nothing else — not
12
- * `run`, `check`, `publish`, `install`, `upgrade`, `migrate`, `module`, and not
13
- * the kernellooks for it, so a single-manifest repo, a bare `examples/`
14
- * directory and a third-party module checkout keep working with nothing added.
15
- * Any field added later must be true of the whole tree, not derivable from it,
16
- * and harmless by its absence.
11
+ * The file is **optional**, and everything reading it is harmless without one.
12
+ * `telo release` requires one. `telo run` uses its LOCATION — never `modules:`,
13
+ * which is release scope as the bound on the walk-up that collects `.env` /
14
+ * `.env.local`, and with no marker reads the manifest's own directory alone, as
15
+ * it did before this file existed; a bound is what keeps that walk out of a
16
+ * user's home directory. Nothing else — not `check`, `publish`, `install`,
17
+ * `upgrade`, `migrate`, `module`, and not the kernel — looks for it, so a
18
+ * single-manifest repo, a bare `examples/` directory and a third-party module
19
+ * checkout keep working with nothing added. Any field added later must be true
20
+ * of the whole tree, not derivable from it, and harmless by its absence.
17
21
  *
18
22
  * Parsing lives here, in the browser-safe half, because the editor answers
19
23
  * "what does changing this library bump?" from the same model. Finding the file
@@ -1,6 +1,11 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import { rewriteRefSlotKinds } from "./ref-slot.js";
4
+ import {
5
+ REFERRER_RULES_ANNOTATION,
6
+ hasReferrerRules,
7
+ rewriteReferrerRuleKinds,
8
+ } from "./referrer-rule.js";
4
9
  import { hasRequiresZone, rewriteRequiresZoneKind } from "./zone-slot.js";
5
10
 
6
11
  const REF_ANNOTATION = "x-telo-ref";
@@ -27,9 +32,10 @@ export interface RefConstraintIssue {
27
32
  manifest: ResourceManifest;
28
33
  reason: RefConstraintReason;
29
34
  /** Which annotation carried the unresolved name: an `x-telo-ref` constraint
30
- * (the default) or an `x-telo-requires-zone` provider kind the caller
31
- * reports the latter as ZONE_PROVIDER_UNRESOLVED. */
32
- annotation?: "ref" | "zone";
35
+ * (the default), an `x-telo-requires-zone` provider kind, or an
36
+ * `x-telo-referrer-rules` `referrer:` filter — the caller reports those as
37
+ * ZONE_PROVIDER_UNRESOLVED and REFERRER_RULE_INVALID. */
38
+ annotation?: "ref" | "zone" | "referrer";
33
39
  /** For `gated`: the target module and the kinds it does export. */
34
40
  gate?: { module: string; exported: string[] };
35
41
  /** Aliases the declaring scope does know — the "did you mean" material for
@@ -75,7 +81,11 @@ export function resolveSchemaRefKinds(
75
81
  ): RefConstraintIssue[] {
76
82
  const issues: RefConstraintIssue[] = [];
77
83
 
78
- const record = (ref: string, path: string, annotation: "ref" | "zone" = "ref"): void => {
84
+ const record = (
85
+ ref: string,
86
+ path: string,
87
+ annotation: "ref" | "zone" | "referrer" = "ref",
88
+ ): void => {
79
89
  if (isLegacyRefIdentity(ref)) {
80
90
  issues.push({ ref, path, manifest: definition, reason: "legacy", annotation });
81
91
  return;
@@ -135,6 +145,20 @@ export function resolveSchemaRefKinds(
135
145
  return undefined;
136
146
  });
137
147
  }
148
+ if (hasReferrerRules(obj)) {
149
+ // A referrer rule's `referrer:` filter names a kind through the identical
150
+ // alias-qualified grammar, resolved in the same declaring scope — so the
151
+ // evaluation pass compares canonical kinds and never has to know which
152
+ // alias the CONSUMER happened to import the kind under. That is the whole
153
+ // reason such a rule can be sound where the same test written on the
154
+ // referring kind cannot.
155
+ rewriteReferrerRuleKinds(obj, (kind) => {
156
+ const result = resolver.resolveKindResult(kind);
157
+ if (result.status === "ok") return result.kind;
158
+ record(kind, `${path}.${REFERRER_RULES_ANNOTATION}`, "referrer");
159
+ return undefined;
160
+ });
161
+ }
138
162
  for (const key of Object.keys(obj)) {
139
163
  walk(obj[key], path ? `${path}.${key}` : key);
140
164
  }
@@ -2,6 +2,7 @@ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { isTaggedSentinel } from "@telorun/templating";
3
3
  import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { readStepSlot } from "./step-slot.js";
5
6
 
6
7
  export interface ThrowsCodeMeta {
7
8
  data?: Record<string, any>;
@@ -174,8 +175,8 @@ function resolveInherited(
174
175
  if (!props) return result;
175
176
 
176
177
  for (const [fieldName, fieldSchema] of Object.entries(props)) {
177
- const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
178
- if (!stepCtx?.invoke) continue;
178
+ const stepCtx = readStepSlot(fieldSchema);
179
+ if (!stepCtx) continue;
179
180
  const steps = (manifest as Record<string, any>)[fieldName];
180
181
  if (!Array.isArray(steps)) continue;
181
182
  unionInto(result, collectStepArrayThrows(steps, stepCtx.invoke, undefined, ctx, ownerModule));
@@ -205,7 +206,7 @@ function collectStepArrayThrows(
205
206
  /** Walk one step, dispatching by shape. Generic for any Run.Sequence-style
206
207
  * composer: the step keys it recognises (`try` / `catch` / `finally` / `then`
207
208
  * / `else` / `elseif` / `do` / `cases` / `default`) are the same set already
208
- * traversed by the analyzer's `x-telo-step-context` schema builder, so future
209
+ * traversed by the analyzer's step-body walk, so future
209
210
  * composers that reuse those shape conventions work without changes here. */
210
211
  function collectStepThrows(
211
212
  step: Record<string, any>,
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Zone containment — *what is inside this zone*, which is the opposite question
3
+ * from the one `resolve-zone-requirements.ts` answers.
4
+ *
5
+ * The landed projection propagates a requirement callee→caller and asks *is this
6
+ * requirement satisfied*. Every check built on zone ATTRIBUTES asks the reverse:
7
+ * a `noSuspend` region has to know what it contains before it can forbid parking
8
+ * inside it, a `replayed` region before it can reject a detached dispatch, an
9
+ * `idempotent` region before it can call impure CEL a broken promise. So this is
10
+ * a DOWNWARD walk from a providing slot to everything its body reaches.
11
+ *
12
+ * It is a second consumer of the shared call graph, not a second graph, and it
13
+ * is **parameterized over the attribute that opens the region** — so the durable
14
+ * zone (`replayed`) and the constraint zones (`noSuspend`, `atomic`,
15
+ * `idempotent`) share one traversal. It resolves an attribute NAME and nothing
16
+ * else: no kind is named here, which is what keeps `modules/durable` out of the
17
+ * analyzer's surface and satisfies the topology-driven constraint.
18
+ *
19
+ * **Two shapes of body slot, one region.** A slot may hold its body as a
20
+ * reference to an executable (`Sql.Transaction.steps`) or carry a step array
21
+ * natively (the shared `Step` fragment, which any kind may point at). Both are
22
+ * regions; the first is entered through the slot's edge, the second through the
23
+ * step nodes the slot declares. Handling only the first would have made the
24
+ * checks silently vacuous on exactly the kinds written after the grammar became
25
+ * shared vocabulary.
26
+ *
27
+ * **The walk under-approximates, and that is the safe direction — for a CHECK.**
28
+ * A dynamically dispatched edge is invisible here, so a check over the result
29
+ * may stay silent where it should have spoken, and the runtime enforcement is
30
+ * what closes it. A consumer at the OPPOSITE polarity — the durable manifest
31
+ * digest, whose blind spot is silent replay against changed code rather than a
32
+ * missed diagnostic — must not inherit this tolerance, and verifies its coverage
33
+ * against the journal instead of trusting this set.
34
+ *
35
+ * Browser-safe: no Node built-ins.
36
+ */
37
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
38
+ import type { CallGraph, CallGraphEdge, CallGraphNode, ResourceGraphNode } from "./call-graph.js";
39
+ import { propertySchemas, resolveLocalRef } from "./manifest-navigation.js";
40
+ import { isStepSlot } from "./step-slot.js";
41
+ import { readProvidesZone } from "./zone-slot.js";
42
+
43
+ /** One node the region reaches, with the route taken to it. */
44
+ export interface ContainedNode {
45
+ node: CallGraphNode;
46
+ /** Labels of the hops from the body slot to this node, for a diagnostic that
47
+ * names the path rather than only the endpoint. */
48
+ via: string[];
49
+ }
50
+
51
+ /** A dispatch that leaves the region without extending it.
52
+ *
53
+ * Recorded rather than followed, because the two facts are different and both
54
+ * are wanted: the zone's lifetime does NOT reach the target (so nothing inside
55
+ * it is contained), while the dispatch itself is a site inside the region that
56
+ * a check may forbid outright — `DURABLE_DETACH_FORBIDDEN` is exactly that. */
57
+ export interface RegionBoundary {
58
+ edge: CallGraphEdge;
59
+ /** The resource whose slot dispatches this way. */
60
+ from: ResourceGraphNode;
61
+ via: string[];
62
+ /** Why the zone stops here: the uses that do not extend it. */
63
+ escaping: string[];
64
+ }
65
+
66
+ /** A region opened by one providing slot carrying the requested attribute. */
67
+ export interface ZoneRegion {
68
+ /** The attribute that opened it — the one the walk was asked for. */
69
+ attribute: string;
70
+ /** The author's reason, quoted verbatim by every diagnostic over this region.
71
+ * Present by construction: an attribute's value IS the reason. */
72
+ reason: string;
73
+ /** The resource whose slot establishes the zone. */
74
+ provider: ResourceGraphNode;
75
+ /** Field-map path of the body slot (`steps`, `invoke`). */
76
+ slot: string;
77
+ /** Every attribute the slot declares, not only the requested one — a consumer
78
+ * deciding collapse reads `atomic` and `idempotent` off one region. */
79
+ attributes: Readonly<Record<string, string>>;
80
+ /** Everything control reaches from the body, keyed by node id. Includes the
81
+ * step nodes of a native body, since a step is where a check anchors. */
82
+ contents: ReadonlyMap<string, ContainedNode>;
83
+ /** Dispatches inside the region that the zone does not extend through. */
84
+ boundaries: readonly RegionBoundary[];
85
+ }
86
+
87
+ /** Resolves a kind name to its definition in the scope of the module that
88
+ * DECLARED it — the same resolver the projection takes, passed in rather than
89
+ * rebuilt so both walks agree about what a kind means. */
90
+ export type DefinitionLookup = (kind: string, module?: string) => ResourceDefinition | undefined;
91
+
92
+ const moduleOf = (node: ResourceGraphNode): string | undefined =>
93
+ (node.manifest.metadata as { module?: string } | undefined)?.module;
94
+
95
+ /** The schema node at a field-map path, following `[]` into `items`, `{}` into
96
+ * `additionalProperties` and local `$defs` refs. Same navigation the projection
97
+ * performs — a slot's annotations live wherever this lands. */
98
+ function schemaNodeAt(
99
+ rootSchema: Record<string, any> | undefined,
100
+ slotPath: string,
101
+ ): Record<string, any> | undefined {
102
+ if (!rootSchema) return undefined;
103
+ let current: Record<string, any> | undefined = rootSchema;
104
+ for (const segment of slotPath.split(".")) {
105
+ if (!current) return undefined;
106
+ const bare = segment.replace(/(\[\]|\{\})+$/g, "");
107
+ let next: Record<string, any> | undefined = propertySchemas(current).find(
108
+ ([k]) => k === bare,
109
+ )?.[1];
110
+ for (const marker of segment.slice(bare.length).match(/\[\]|\{\}/g) ?? []) {
111
+ next = resolveLocalRef(
112
+ marker === "[]"
113
+ ? (next?.items as Record<string, any> | undefined)
114
+ : (next?.additionalProperties as Record<string, any> | undefined),
115
+ rootSchema,
116
+ );
117
+ if (!next || typeof next !== "object") return undefined;
118
+ }
119
+ current = resolveLocalRef(next, rootSchema);
120
+ }
121
+ return current;
122
+ }
123
+
124
+ /**
125
+ * Does a zone's lifetime extend through this edge?
126
+ *
127
+ * EVERY member of `use` must be `call`. This is the landed reduction, and its
128
+ * asymmetry with the propagation rule is deliberate: a set says several
129
+ * relations hold at once, so a slot declaring `[call, detached]` really does
130
+ * detach on some dispatch, and a detached dispatch is never inside the caller's
131
+ * zone. An edge whose `use` could not be read extends nothing either — the
132
+ * conservative direction HERE, since over-reporting containment would invent
133
+ * failures inside regions that are correct.
134
+ */
135
+ function extendsZone(edge: CallGraphEdge): boolean {
136
+ return edge.use.length > 0 && !edge.unresolved && edge.use.every((u) => u === "call");
137
+ }
138
+
139
+ /** Uses on an edge that leave the region, for the boundary record. */
140
+ function escapingUses(edge: CallGraphEdge): string[] {
141
+ return edge.use.filter((u) => u !== "call" && u !== "dependency" && u !== "schema");
142
+ }
143
+
144
+ /** Walk down from one entry edge, collecting what the zone reaches. */
145
+ function collect(
146
+ graph: CallGraph,
147
+ entries: readonly { edge?: CallGraphEdge; node: CallGraphNode; via: string[] }[],
148
+ contents: Map<string, ContainedNode>,
149
+ boundaries: RegionBoundary[],
150
+ ): void {
151
+ const queue = [...entries];
152
+ while (queue.length > 0) {
153
+ const current = queue.shift()!;
154
+ if (contents.has(current.node.id)) continue;
155
+ contents.set(current.node.id, { node: current.node, via: current.via });
156
+
157
+ // A resource's own step nodes are part of whatever region reaches the
158
+ // resource: a step is where a check anchors (`the retry at
159
+ // importAll/fetch`), and its outgoing edge is the dispatch a rule judges.
160
+ const stepNodes =
161
+ current.node.type === "resource" ? graph.steps(current.node.id) : [];
162
+ for (const step of stepNodes) {
163
+ if (contents.has(step.id)) continue;
164
+ queue.push({
165
+ node: step,
166
+ via: [...current.via, step.name ? `step '${step.name}'` : step.path],
167
+ });
168
+ }
169
+
170
+ const owner: ResourceGraphNode | undefined =
171
+ current.node.type === "resource"
172
+ ? current.node
173
+ : (graph.nodes.get(current.node.owner) as ResourceGraphNode | undefined);
174
+
175
+ for (const edge of graph.edgesFrom(current.node.id)) {
176
+ if (edge.use.every((u) => u === "dependency" || u === "schema")) continue;
177
+ const target = edge.to ? graph.nodes.get(edge.to) : undefined;
178
+ if (!extendsZone(edge)) {
179
+ if (owner && escapingUses(edge).length > 0) {
180
+ boundaries.push({
181
+ edge,
182
+ from: owner,
183
+ via: current.via,
184
+ escaping: escapingUses(edge),
185
+ });
186
+ }
187
+ continue;
188
+ }
189
+ if (!target) continue;
190
+ queue.push({
191
+ node: target,
192
+ via: [...current.via, `${edge.slot} → ${edge.toName}`],
193
+ });
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Every region in the graph opened by a slot declaring `attribute`.
200
+ *
201
+ * The provider itself is NOT in `contents` — a zone constrains what runs inside
202
+ * its body, not the resource that establishes it. A transaction's own
203
+ * `connection:` dependency is outside the region it opens, and a rule that
204
+ * treated the provider as contained would report the provider against its own
205
+ * constraint.
206
+ */
207
+ export function findZoneRegions(
208
+ graph: CallGraph,
209
+ resolveDef: DefinitionLookup,
210
+ attribute: string,
211
+ ): ZoneRegion[] {
212
+ const regions: ZoneRegion[] = [];
213
+
214
+ for (const node of graph.nodes.values()) {
215
+ if (node.type !== "resource") continue;
216
+ const def = resolveDef(node.kind, moduleOf(node));
217
+ const rootSchema = def?.schema as Record<string, any> | undefined;
218
+ if (!rootSchema) continue;
219
+
220
+ // The providing slots of this kind, found by walking its own declared
221
+ // properties rather than by knowing any kind's field names.
222
+ for (const [slot, slotSchema] of providingSlots(rootSchema)) {
223
+ const provides = readProvidesZone(slotSchema);
224
+ const reason = provides?.attributes[attribute as keyof typeof provides.attributes];
225
+ if (!provides || typeof reason !== "string") continue;
226
+
227
+ const contents = new Map<string, ContainedNode>();
228
+ const boundaries: RegionBoundary[] = [];
229
+
230
+ // Shape one: the slot carries a step array natively. Its steps are the
231
+ // body, and they are owned by this resource, so they are found by path
232
+ // rather than by an edge.
233
+ if (isStepSlot(slotSchema)) {
234
+ const entries = graph
235
+ .steps(node.id)
236
+ .filter((step) => step.array === slot || step.array.startsWith(`${slot}[`))
237
+ .map((step) => ({
238
+ node: step as CallGraphNode,
239
+ via: [step.name ? `step '${step.name}'` : step.path],
240
+ }));
241
+ collect(graph, entries, contents, boundaries);
242
+ } else {
243
+ // Shape two: the slot references an executable. Its edges are the body.
244
+ // Entered regardless of the slot's OWN use — a providing slot
245
+ // establishes its zone before the enclosing lifetime terminates on that
246
+ // use, which is what lets a detached durable body both shed every
247
+ // enclosing zone and open its own.
248
+ const entries = graph
249
+ .edgesFrom(node.id)
250
+ .filter((edge) => edge.slot === slot && edge.to)
251
+ .map((edge) => ({
252
+ node: graph.nodes.get(edge.to!)!,
253
+ via: [`${slot} → ${edge.toName}`],
254
+ }))
255
+ .filter((entry) => entry.node !== undefined);
256
+ collect(graph, entries, contents, boundaries);
257
+ }
258
+
259
+ regions.push({
260
+ attribute,
261
+ reason,
262
+ provider: node,
263
+ slot,
264
+ attributes: provides.attributes as Readonly<Record<string, string>>,
265
+ contents,
266
+ boundaries,
267
+ });
268
+ }
269
+ }
270
+
271
+ return regions;
272
+ }
273
+
274
+ /** Field-map paths of every slot in a kind's schema carrying a provides-zone
275
+ * annotation, with the schema node at each. Walks properties, array items and
276
+ * `additionalProperties`, resolving local `$ref`s — the paths the call graph's
277
+ * own edges are keyed by, so a slot found here matches an edge's `slot`. */
278
+ function providingSlots(
279
+ rootSchema: Record<string, any>,
280
+ ): Array<[slot: string, schema: Record<string, any>]> {
281
+ const found: Array<[string, Record<string, any>]> = [];
282
+ const seen = new Set<object>();
283
+
284
+ const walk = (schema: Record<string, any> | undefined, path: string): void => {
285
+ const node = resolveLocalRef(schema, rootSchema);
286
+ if (!node || typeof node !== "object" || seen.has(node)) return;
287
+ seen.add(node);
288
+ if (path && readProvidesZone(node)) found.push([path, node]);
289
+ for (const [key, child] of propertySchemas(node)) {
290
+ const childPath = path ? `${path}.${key}` : key;
291
+ walk(child, childPath);
292
+ // A slot's zone annotation may sit on the ARRAY (`steps`) or on its item
293
+ // (`routes[].handler`); both are real field-map paths.
294
+ const items = resolveLocalRef(child?.items as Record<string, any> | undefined, rootSchema);
295
+ if (items) walk(items, `${childPath}[]`);
296
+ const additional = resolveLocalRef(
297
+ child?.additionalProperties as Record<string, any> | undefined,
298
+ rootSchema,
299
+ );
300
+ if (additional) walk(additional, `${childPath}{}`);
301
+ }
302
+ };
303
+
304
+ walk(rootSchema, "");
305
+ return found;
306
+ }
307
+
308
+ /** Convenience for a consumer that only wants membership: every node id inside
309
+ * any region opened by `attribute`, mapped to the region that contains it. */
310
+ export function containmentIndex(
311
+ regions: readonly ZoneRegion[],
312
+ ): ReadonlyMap<string, ZoneRegion> {
313
+ const index = new Map<string, ZoneRegion>();
314
+ for (const region of regions) {
315
+ for (const id of region.contents.keys()) {
316
+ if (!index.has(id)) index.set(id, region);
317
+ }
318
+ }
319
+ return index;
320
+ }
321
+
322
+ /** The manifests a region's contents belong to — what a diagnostic anchors on. */
323
+ export function regionManifests(region: ZoneRegion): ResourceManifest[] {
324
+ const out: ResourceManifest[] = [];
325
+ for (const { node } of region.contents.values()) {
326
+ if (node.type === "resource") out.push(node.manifest);
327
+ }
328
+ return out;
329
+ }