@telorun/analyzer 0.53.0 → 0.55.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 (76) hide show
  1. package/dist/analysis-registry.d.ts +20 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +36 -3
  4. package/dist/analyzer.d.ts +3 -2
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +188 -26
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +32 -12
  9. package/dist/call-graph.d.ts +189 -0
  10. package/dist/call-graph.d.ts.map +1 -0
  11. package/dist/call-graph.js +617 -0
  12. package/dist/dependency-graph.d.ts +17 -7
  13. package/dist/dependency-graph.d.ts.map +1 -1
  14. package/dist/dependency-graph.js +36 -65
  15. package/dist/flatten-for-analyzer.d.ts +8 -0
  16. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  17. package/dist/flatten-for-analyzer.js +32 -0
  18. package/dist/index.d.ts +14 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +7 -1
  21. package/dist/manifest-navigation.d.ts +32 -0
  22. package/dist/manifest-navigation.d.ts.map +1 -0
  23. package/dist/manifest-navigation.js +91 -0
  24. package/dist/manifest-visitor.js +1 -1
  25. package/dist/ref-slot.d.ts +125 -0
  26. package/dist/ref-slot.d.ts.map +1 -0
  27. package/dist/ref-slot.js +226 -0
  28. package/dist/reference-field-map.d.ts +15 -1
  29. package/dist/reference-field-map.d.ts.map +1 -1
  30. package/dist/reference-field-map.js +29 -35
  31. package/dist/resolve-schema-ref-kinds.d.ts +4 -0
  32. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  33. package/dist/resolve-schema-ref-kinds.js +31 -8
  34. package/dist/resolve-zone-requirements.d.ts +110 -0
  35. package/dist/resolve-zone-requirements.d.ts.map +1 -0
  36. package/dist/resolve-zone-requirements.js +541 -0
  37. package/dist/types.d.ts +8 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/validate-observed-state.d.ts +14 -13
  40. package/dist/validate-observed-state.d.ts.map +1 -1
  41. package/dist/validate-observed-state.js +21 -88
  42. package/dist/validate-ref-slots.d.ts +48 -0
  43. package/dist/validate-ref-slots.d.ts.map +1 -0
  44. package/dist/validate-ref-slots.js +219 -0
  45. package/dist/validate-references.d.ts.map +1 -1
  46. package/dist/validate-references.js +8 -1
  47. package/dist/validate-zone-slots.d.ts +39 -0
  48. package/dist/validate-zone-slots.d.ts.map +1 -0
  49. package/dist/validate-zone-slots.js +114 -0
  50. package/dist/zone-module-documents.d.ts +27 -0
  51. package/dist/zone-module-documents.d.ts.map +1 -0
  52. package/dist/zone-module-documents.js +1 -0
  53. package/dist/zone-slot.d.ts +61 -0
  54. package/dist/zone-slot.d.ts.map +1 -0
  55. package/dist/zone-slot.js +91 -0
  56. package/package.json +3 -3
  57. package/src/analysis-registry.ts +36 -2
  58. package/src/analyzer.ts +206 -24
  59. package/src/builtins.ts +32 -12
  60. package/src/call-graph.ts +827 -0
  61. package/src/dependency-graph.ts +34 -68
  62. package/src/flatten-for-analyzer.ts +32 -0
  63. package/src/index.ts +47 -0
  64. package/src/manifest-navigation.ts +91 -0
  65. package/src/manifest-visitor.ts +1 -1
  66. package/src/ref-slot.ts +273 -0
  67. package/src/reference-field-map.ts +39 -36
  68. package/src/resolve-schema-ref-kinds.ts +34 -7
  69. package/src/resolve-zone-requirements.ts +781 -0
  70. package/src/types.ts +8 -0
  71. package/src/validate-observed-state.ts +26 -92
  72. package/src/validate-ref-slots.ts +293 -0
  73. package/src/validate-references.ts +8 -1
  74. package/src/validate-zone-slots.ts +175 -0
  75. package/src/zone-module-documents.ts +27 -0
  76. package/src/zone-slot.ts +116 -0
@@ -0,0 +1,781 @@
1
+ /**
2
+ * The zone projection — a CONSUMER of the call-graph service, with no traversal
3
+ * of its own (see `plans/execution-zones.md`): it filters the graph's edges by
4
+ * `use`, propagates zone requirements callee→caller along `call` edges,
5
+ * discharges them at providing slots under the correlation rule, and fires at
6
+ * terminating edges and at boot.
7
+ *
8
+ * Polarity: zones UNDER-approximate. A requirement is asserted only where the
9
+ * manifest states it, a correlation only where a key pointer resolves, and an
10
+ * edge whose `use` is unknown neither propagates nor terminates — it warns.
11
+ * The runtime (`requireZone`) stays the enforcement; a path this pass cannot
12
+ * see degrades to the runtime error at the right place, never to silence.
13
+ *
14
+ * Browser-safe: no Node built-ins.
15
+ */
16
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
17
+ import { isRefSentinel } from "@telorun/templating";
18
+ import type { AliasResolver } from "./alias-resolver.js";
19
+ import {
20
+ buildCallGraph,
21
+ type CallGraph,
22
+ type CallGraphEdge,
23
+ type ResourceGraphNode,
24
+ } from "./call-graph.js";
25
+ import type { DefinitionRegistry } from "./definition-registry.js";
26
+ import {
27
+ enclosingOf,
28
+ propertySchemas,
29
+ resolveLocalRef,
30
+ } from "./manifest-navigation.js";
31
+ import type { ZoneModuleDocuments } from "./zone-module-documents.js";
32
+ import { readProvidesZone, readRequiresZone } from "./zone-slot.js";
33
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
34
+
35
+ const SOURCE = "telo-analyzer";
36
+
37
+ /** One open requirement on a library's exported resource — the contract an
38
+ * importer must satisfy. Plain data, so it caches and crosses the
39
+ * per-library derivation boundary. */
40
+ export interface ZoneRequirementSpec {
41
+ /** Canonical `<module>.<Kind>` of the required zone. */
42
+ zone: string;
43
+ /** Correlation identity — the resolved declaration site of the instance the
44
+ * requirement correlates on (see {@link correlationIdOf}). Absent =
45
+ * uncorrelated. */
46
+ correlation?: string;
47
+ /** Human label of the correlation target (`sqlite.Connection 'billingDb'`). */
48
+ correlationLabel?: string;
49
+ /** Bare name of the correlation target — what the export-satisfiability
50
+ * check compares against `exports.resources`. */
51
+ correlationName?: string;
52
+ reason?: string;
53
+ /** Label of the requiring resource (`sql.Command 'charge'`). */
54
+ origin: string;
55
+ /** Resource names on the propagation path so far, origin first. */
56
+ via: string[];
57
+ }
58
+
59
+ /** Per-library derived export contracts: export name → open requirements. */
60
+ export type ZoneExportRequirements = Map<string, ZoneRequirementSpec[]>;
61
+
62
+ export interface ZoneExportCacheEntry {
63
+ signature: string;
64
+ exports: ZoneExportRequirements;
65
+ }
66
+
67
+ /**
68
+ * Host-lifetime cache for per-library export derivation, keyed by the
69
+ * library's source identity with a content signature guarding staleness. The
70
+ * HOST owns it (the editor re-analyzes on every keystroke and must not rebuild
71
+ * every dependency's graph each time); the CLI passes none. It deliberately
72
+ * does not live in `AnalysisRegistry`, which the editor constructs fresh per
73
+ * closure per run — a cache there dies at exactly the boundary it must cross.
74
+ */
75
+ export type ZoneExportCache = Map<string, ZoneExportCacheEntry>;
76
+
77
+ interface Requirement {
78
+ zone: string;
79
+ /** Kinds that discharge it: the zone kind plus everything extending it. */
80
+ accepted: ReadonlySet<string>;
81
+ correlation?: string;
82
+ correlationLabel?: string;
83
+ correlationName?: string;
84
+ reason?: string;
85
+ origin: string;
86
+ /** Identity for memoization / dedup: zone + correlation. */
87
+ key: string;
88
+ }
89
+
90
+ interface ProjectionArgs {
91
+ graph: CallGraph;
92
+ defs: DefinitionRegistry;
93
+ aliases: AliasResolver;
94
+ aliasesByModule: Map<string, AliasResolver>;
95
+ /** Modules whose files diagnostics may be reported against (the analysis
96
+ * entry's own modules). Empty = derive-only, report nothing. */
97
+ reportModules: ReadonlySet<string>;
98
+ /** Extra requirements seeded at forwarded export nodes, keyed
99
+ * `${module}\0${name}` — an imported library's derived contracts. */
100
+ seeds?: Map<string, ZoneRequirementSpec[]>;
101
+ /** When set, record every requirement that reaches a module-level resource
102
+ * this returns an export name for. */
103
+ exportOf?: (node: ResourceGraphNode) => string | undefined;
104
+ }
105
+
106
+ interface ProjectionResult {
107
+ diagnostics: AnalysisDiagnostic[];
108
+ openExports: ZoneExportRequirements;
109
+ }
110
+
111
+ /** Resolve a possibly alias-form kind to its definition in the DECLARING
112
+ * module's scope — the same layering `AnalysisRegistry.resolveDefinitionIn`
113
+ * uses. */
114
+ function definitionResolver(
115
+ defs: DefinitionRegistry,
116
+ aliases: AliasResolver,
117
+ aliasesByModule: Map<string, AliasResolver>,
118
+ ) {
119
+ return (kind: string, module?: string): ResourceDefinition | undefined => {
120
+ const scope = (module ? aliasesByModule.get(module) : undefined) ?? aliases;
121
+ const canonical = scope.resolveKind(kind);
122
+ return defs.resolve(kind) ?? (canonical ? defs.resolve(canonical) : undefined);
123
+ };
124
+ }
125
+
126
+ const canonicalOf = (def: ResourceDefinition): string =>
127
+ `${def.metadata.module}.${def.metadata.name}`;
128
+
129
+ /**
130
+ * Correlation identity — the resolved DECLARATION SITE, mirroring runtime
131
+ * instance identity exactly. A named module-level resource is
132
+ * `(declaring file, name)` — the file, not the owning module name, because a
133
+ * re-exported instance is forwarded once per re-exporting module under that
134
+ * module's name while its declaration site survives the copy. A
135
+ * `with:`-scoped resource is its scope site plus name (one instance per scope
136
+ * run); an inline declaration is its own generated node.
137
+ */
138
+ function correlationIdOf(node: ResourceGraphNode): string {
139
+ if (node.scoped) return `${node.scopeOwner}\0${node.scopeSite}\0${node.name}`;
140
+ const source = (node.manifest.metadata as { source?: string } | undefined)?.source ?? "";
141
+ return `${source}\0${node.name}`;
142
+ }
143
+
144
+ const labelOf = (node: ResourceGraphNode): string => `${node.kind} '${node.name}'`;
145
+
146
+ /** The schema node at a field-map path (`steps`, `routes[].handler`), following
147
+ * `[]` into `items`, `{}` into `additionalProperties` and local `$defs` refs —
148
+ * where a slot's zone annotations live. */
149
+ function schemaNodeAt(
150
+ rootSchema: Record<string, any> | undefined,
151
+ slotPath: string,
152
+ ): Record<string, any> | undefined {
153
+ if (!rootSchema) return undefined;
154
+ let current: Record<string, any> | undefined = rootSchema;
155
+ for (const segment of slotPath.split(".")) {
156
+ if (!current) return undefined;
157
+ const bare = segment.replace(/(\[\]|\{\})+$/g, "");
158
+ let next: Record<string, any> | undefined = propertySchemas(current).find(
159
+ ([k]) => k === bare,
160
+ )?.[1];
161
+ for (const marker of segment.slice(bare.length).match(/\[\]|\{\}/g) ?? []) {
162
+ next = resolveLocalRef(
163
+ marker === "[]"
164
+ ? (next?.items as Record<string, any> | undefined)
165
+ : (next?.additionalProperties as Record<string, any> | undefined),
166
+ rootSchema,
167
+ );
168
+ if (!next || typeof next !== "object") return undefined;
169
+ }
170
+ current = resolveLocalRef(next, rootSchema);
171
+ }
172
+ return current;
173
+ }
174
+
175
+ /**
176
+ * The LOCAL name a correlation-key hop resolves to, or undefined when the value
177
+ * is not a reference or does not name something local.
178
+ *
179
+ * **A cross-module reference is undefined, deliberately**, matching the kernel's
180
+ * `referencedName` exactly: `!ref Alias.name` names an instance in another
181
+ * module's scope, and the only index available here is flat and
182
+ * module-unscoped, so taking the bare name would bind to whatever local
183
+ * resource happens to share it. Correlation is an identity comparison — binding
184
+ * it to the wrong resource is worse than leaving it uncorrelated, which is the
185
+ * under-approximating direction the whole pass leans on. `Self.` is a local
186
+ * name written the long way and does resolve.
187
+ *
188
+ * This deliberately differs from `call-graph`'s `refTargetName`, which answers a
189
+ * different question (what an EDGE points at, cross-module included, for a graph
190
+ * whose consumers tolerate an unresolved target) — hence two functions rather
191
+ * than one shared helper.
192
+ */
193
+ function refName(value: unknown): string | undefined {
194
+ if (isRefSentinel(value)) {
195
+ const source = value.source;
196
+ const dot = source.indexOf(".");
197
+ if (dot <= 0) return source;
198
+ return source.slice(0, dot) === "Self" ? source.slice(dot + 1) : undefined;
199
+ }
200
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
201
+ const v = value as Record<string, unknown>;
202
+ const pure =
203
+ typeof v.kind === "string" &&
204
+ typeof v.name === "string" &&
205
+ Object.keys(v).every((k) => k === "kind" || k === "name" || k === "alias" || k === "__ref");
206
+ if (!pure) return undefined;
207
+ const alias = v.alias;
208
+ if (typeof alias === "string" && alias !== "Self") return undefined;
209
+ return v.name as string;
210
+ }
211
+
212
+ /**
213
+ * Resolve an ordered correlation-key pointer list against a resource's
214
+ * manifest, first hit winning — the static counterpart of the kernel's walk.
215
+ * A pointer may traverse a `!ref` into the referenced resource's own manifest
216
+ * (read field → resolve reference → read field); traversal is mechanical, so
217
+ * no kind is named here. Returns undefined when nothing resolves — the
218
+ * requirement then discharges uncorrelated, the under-approximating side.
219
+ */
220
+ function resolveStaticKey(
221
+ start: ResourceGraphNode,
222
+ pointers: readonly string[],
223
+ resolveName: (name: string, from: ResourceGraphNode) => ResourceGraphNode | undefined,
224
+ ): ResourceGraphNode | undefined {
225
+ for (const pointer of pointers) {
226
+ if (!pointer.startsWith("/")) continue;
227
+ let manifest: Record<string, unknown> | undefined = start.manifest as Record<string, unknown>;
228
+ let context = start;
229
+ let value: unknown = undefined;
230
+ let failed = false;
231
+ const segments = pointer.slice(1).split("/");
232
+ for (let i = 0; i < segments.length; i++) {
233
+ const segment = segments[i]!.replace(/~1/g, "/").replace(/~0/g, "~");
234
+ if (i > 0) {
235
+ // Traverse the previous hop's reference into its declaration.
236
+ const name = refName(value);
237
+ const target = name ? resolveName(name, context) : undefined;
238
+ if (!target) {
239
+ failed = true;
240
+ break;
241
+ }
242
+ manifest = target.manifest as Record<string, unknown>;
243
+ context = target;
244
+ }
245
+ value = manifest?.[segment];
246
+ if (value === undefined || value === null) {
247
+ failed = true;
248
+ break;
249
+ }
250
+ }
251
+ if (failed) continue;
252
+ const terminalName = refName(value);
253
+ const target = terminalName ? resolveName(terminalName, context) : undefined;
254
+ if (target) return target;
255
+ }
256
+ return undefined;
257
+ }
258
+
259
+ /** Anchor a provider-side single pointer at the object enclosing the slot,
260
+ * then resolve exactly like a requirer key. */
261
+ function resolveProviderKey(
262
+ provider: ResourceGraphNode,
263
+ edge: CallGraphEdge,
264
+ pointer: string,
265
+ resolveName: (name: string, from: ResourceGraphNode) => ResourceGraphNode | undefined,
266
+ ): ResourceGraphNode | undefined {
267
+ const enclosing = enclosingOf(provider.manifest, edge.path);
268
+ if (enclosing === provider.manifest || enclosing === undefined) {
269
+ return resolveStaticKey(provider, [pointer], resolveName);
270
+ }
271
+ // Slot inside an array item: resolve the first hop off the item, further
272
+ // hops through references as usual.
273
+ const synthetic: ResourceGraphNode = { ...provider, manifest: enclosing as ResourceManifest };
274
+ return resolveStaticKey(synthetic, [pointer], resolveName);
275
+ }
276
+
277
+ /** Run the projection over one graph. */
278
+ export function projectZoneRequirements(args: ProjectionArgs): ProjectionResult {
279
+ const { graph, defs, aliases, aliasesByModule, reportModules, seeds, exportOf } = args;
280
+ const resolveDef = definitionResolver(defs, aliases, aliasesByModule);
281
+ const diagnostics: AnalysisDiagnostic[] = [];
282
+ const openExports: ZoneExportRequirements = new Map();
283
+ const reported = new Set<string>();
284
+
285
+ // Scope-local name index, so a scoped resource's pointers resolve against its
286
+ // scope siblings first — the order `ScopeContext` and `!ref` agree on.
287
+ const scopeLocal = new Map<string, Map<string, ResourceGraphNode>>();
288
+ for (const node of graph.nodes.values()) {
289
+ if (node.type !== "resource" || !node.scoped) continue;
290
+ const key = `${node.scopeOwner}\0${node.scopeSite}`;
291
+ let bucket = scopeLocal.get(key);
292
+ if (!bucket) scopeLocal.set(key, (bucket = new Map()));
293
+ bucket.set(node.name, node);
294
+ }
295
+ const resolveName = (
296
+ name: string,
297
+ from: ResourceGraphNode,
298
+ ): ResourceGraphNode | undefined => {
299
+ if (from.scoped) {
300
+ const local = scopeLocal.get(`${from.scopeOwner}\0${from.scopeSite}`)?.get(name);
301
+ if (local) return local;
302
+ }
303
+ return graph.resourceByName(name);
304
+ };
305
+
306
+ const acceptedFor = (zone: string): ReadonlySet<string> => {
307
+ const out = new Set<string>([zone]);
308
+ for (const def of defs.getByExtends(zone)) {
309
+ const module = (def.metadata as { module?: string } | undefined)?.module;
310
+ if (module && def.metadata?.name) out.add(`${module}.${def.metadata.name as string}`);
311
+ }
312
+ return out;
313
+ };
314
+
315
+ const moduleOf = (node: ResourceGraphNode): string | undefined =>
316
+ (node.manifest.metadata as { module?: string } | undefined)?.module;
317
+
318
+ const reportable = (node: ResourceGraphNode): boolean => {
319
+ const module = moduleOf(node);
320
+ return module === undefined || reportModules.has(module);
321
+ };
322
+
323
+ const emit = (
324
+ severity: DiagnosticSeverity,
325
+ code: string,
326
+ edge: CallGraphEdge,
327
+ caller: ResourceGraphNode,
328
+ req: Requirement,
329
+ via: string[],
330
+ why: string,
331
+ ): void => {
332
+ if (!reportable(caller)) return;
333
+ const dedupe = `${code}\0${edge.from}\0${edge.path}\0${req.key}`;
334
+ if (reported.has(dedupe)) return;
335
+ reported.add(dedupe);
336
+ const wanted = req.correlationLabel
337
+ ? `a ${req.zone} zone on ${req.correlationLabel}`
338
+ : `a ${req.zone} zone`;
339
+ const path = [...via, `${caller.name}.${edge.path}`].join(" → ");
340
+ const reason = req.reason ? ` ${req.reason}.` : "";
341
+ diagnostics.push({
342
+ severity,
343
+ code,
344
+ source: SOURCE,
345
+ message: `${req.origin} requires ${wanted}, and the path ${path} ${why}.${reason}`,
346
+ data: {
347
+ resource: { kind: caller.kind, name: caller.name },
348
+ filePath: (caller.manifest.metadata as { source?: string } | undefined)?.source,
349
+ path: edge.path,
350
+ },
351
+ });
352
+ };
353
+
354
+ const visited = new Set<string>();
355
+
356
+ const propagate = (node: ResourceGraphNode, req: Requirement, via: string[]): void => {
357
+ const visitKey = `${node.id}\0${req.key}`;
358
+ if (visited.has(visitKey)) return;
359
+ visited.add(visitKey);
360
+
361
+ // A requirement reaching an exported instance is part of that export's
362
+ // contract — recorded, and propagation continues (the export may also be
363
+ // reached internally, where an enclosing provider can discharge it).
364
+ const exportName = exportOf?.(node);
365
+ if (exportName !== undefined) {
366
+ let bucket = openExports.get(exportName);
367
+ if (!bucket) openExports.set(exportName, (bucket = []));
368
+ if (!bucket.some((r) => `${r.zone}\0${r.correlation ?? ""}` === req.key)) {
369
+ bucket.push({
370
+ zone: req.zone,
371
+ correlation: req.correlation,
372
+ correlationLabel: req.correlationLabel,
373
+ correlationName: req.correlationName,
374
+ reason: req.reason,
375
+ origin: req.origin,
376
+ via: [...via],
377
+ });
378
+ }
379
+ }
380
+
381
+ for (const edge of graph.edgesTo(node.id)) {
382
+ const from = graph.nodes.get(edge.from);
383
+ if (!from) continue;
384
+ const caller =
385
+ from.type === "step"
386
+ ? (graph.nodes.get(from.owner) as ResourceGraphNode | undefined)
387
+ : from;
388
+ if (!caller || caller.type !== "resource") continue;
389
+
390
+ // Discharge: the slot provides a zone whose kind satisfies the
391
+ // requirement and whose correlation payload is the same declaration
392
+ // site. Checked BEFORE termination, so a terminating provider slot
393
+ // (a detached durable body) still discharges its own zone.
394
+ const callerDef = resolveDef(caller.kind, moduleOf(caller));
395
+ const slotSchema = schemaNodeAt(
396
+ callerDef?.schema as Record<string, any> | undefined,
397
+ edge.slot,
398
+ );
399
+ const provides = readProvidesZone(slotSchema);
400
+ if (provides && callerDef && req.accepted.has(canonicalOf(callerDef))) {
401
+ const providerKey = provides.key
402
+ ? resolveProviderKey(caller, edge, provides.key, resolveName)
403
+ : undefined;
404
+ const discharged =
405
+ req.correlation === undefined ||
406
+ (providerKey !== undefined && correlationIdOf(providerKey) === req.correlation);
407
+ if (discharged) continue;
408
+ }
409
+
410
+ // A dynamic selector is already a hard diagnostic (validate-ref-slots);
411
+ // zones do not re-report it — and must not propagate through a use they
412
+ // cannot read.
413
+ if (edge.unresolvedReason === "dynamic") continue;
414
+
415
+ if (edge.use.length === 0) {
416
+ emit(
417
+ DiagnosticSeverity.Warning,
418
+ "ZONE_REQUIREMENT_DEFERRED",
419
+ edge,
420
+ caller,
421
+ req,
422
+ via,
423
+ "reaches a slot that declares no use, so whether the zone survives it cannot be decided statically; the runtime check remains the enforcement",
424
+ );
425
+ continue;
426
+ }
427
+ if (edge.unresolved) {
428
+ emit(
429
+ DiagnosticSeverity.Warning,
430
+ "ZONE_REQUIREMENT_DEFERRED",
431
+ edge,
432
+ caller,
433
+ req,
434
+ via,
435
+ `reaches a slot whose use selector could not be resolved (${edge.unresolvedReason}), so whether the zone survives it cannot be decided statically; the runtime check remains the enforcement`,
436
+ );
437
+ continue;
438
+ }
439
+ if (edge.use.every((u) => u === "dependency" || u === "schema")) continue;
440
+
441
+ if (edge.use.length > 1) {
442
+ // The zone's lifetime extends through the edge only if EVERY member is
443
+ // `call` — a set says several relations hold at once, not that one of
444
+ // them might.
445
+ if (edge.use.every((u) => u === "call")) {
446
+ continueUp(caller, edge, req, via);
447
+ continue;
448
+ }
449
+ // A member the runtime guarantees is cleared makes the set decidably
450
+ // wrong, not undecidable: the detached dispatch is NEVER inside the
451
+ // caller's zone, so the requirement — a universal claim — is violated
452
+ // on that path. What stays unknown is only how often that path is
453
+ // taken, and "sometimes throws" is not a working manifest.
454
+ //
455
+ // This is the one place the plan's original rule was inverted, and
456
+ // deliberately. That rule existed to stop `Cache.View`'s UNCONDITIONAL
457
+ // `[call, detached]` from hard-erroring every cached transactional call
458
+ // under the default `revalidate: sync`, where the controller never
459
+ // detaches — but the same change that added this pass re-annotated that
460
+ // slot as a case map, so a set now appears only where its detach really
461
+ // happens. The justification went with the annotation.
462
+ const guaranteedCleared = edge.use.filter(
463
+ (u) => u === "detached" || u === "trigger.inbound",
464
+ );
465
+ if (guaranteedCleared.length > 0) {
466
+ emit(
467
+ DiagnosticSeverity.Error,
468
+ "ZONE_REQUIREMENT_UNSATISFIED",
469
+ edge,
470
+ caller,
471
+ req,
472
+ via,
473
+ `reaches a slot that dispatches its target several ways in one invocation ([${edge.use.join(", ")}]), and the ${guaranteedCleared.join(" / ")} dispatch is guaranteed a fresh context — so on that path the zone is gone`,
474
+ );
475
+ continue;
476
+ }
477
+ // Everything else in a set is undecidable rather than wrong — a
478
+ // `trigger.consumer` member means a drain site MIGHT be inside the
479
+ // zone, which no static reading can settle.
480
+ emit(
481
+ DiagnosticSeverity.Warning,
482
+ "ZONE_REQUIREMENT_DEFERRED",
483
+ edge,
484
+ caller,
485
+ req,
486
+ via,
487
+ `reaches a slot whose declared use is the set [${edge.use.join(", ")}]; whether the zone survives depends on where the consumer drains it, so it cannot be decided statically and the runtime check remains the enforcement`,
488
+ );
489
+ continue;
490
+ }
491
+
492
+ switch (edge.use[0]) {
493
+ case "call":
494
+ continueUp(caller, edge, req, via);
495
+ break;
496
+ case "detached":
497
+ emit(
498
+ DiagnosticSeverity.Error,
499
+ "ZONE_REQUIREMENT_UNSATISFIED",
500
+ edge,
501
+ caller,
502
+ req,
503
+ via,
504
+ "detaches there — the runtime guarantees the detached work a fresh context, outside every zone its caller was in",
505
+ );
506
+ break;
507
+ case "trigger.inbound":
508
+ emit(
509
+ DiagnosticSeverity.Error,
510
+ "ZONE_REQUIREMENT_UNSATISFIED",
511
+ edge,
512
+ caller,
513
+ req,
514
+ via,
515
+ "is an inbound trigger registration — the handler runs on a fresh context driven by a request or timer, outside every zone",
516
+ );
517
+ break;
518
+ case "trigger.consumer":
519
+ emit(
520
+ DiagnosticSeverity.Warning,
521
+ "ZONE_REQUIREMENT_DEFERRED",
522
+ edge,
523
+ caller,
524
+ req,
525
+ via,
526
+ "is dispatched when a returned value is drained, so where it runs is the drain site's choice; the runtime check remains the enforcement",
527
+ );
528
+ break;
529
+ // dependency / schema singletons were filtered above.
530
+ }
531
+ }
532
+ };
533
+
534
+ const continueUp = (
535
+ caller: ResourceGraphNode,
536
+ edge: CallGraphEdge,
537
+ req: Requirement,
538
+ via: string[],
539
+ ): void => {
540
+ // Nothing encloses an Application's boot targets: an open requirement
541
+ // arriving there surfaces at boot.
542
+ if (caller.kind === "Telo.Application") {
543
+ emit(
544
+ DiagnosticSeverity.Error,
545
+ "ZONE_REQUIREMENT_UNSATISFIED",
546
+ edge,
547
+ caller,
548
+ req,
549
+ via,
550
+ "reaches the application's boot targets, which nothing encloses",
551
+ );
552
+ return;
553
+ }
554
+ propagate(caller, req, [...via, caller.name]);
555
+ };
556
+
557
+ // ── Origins: instances of the entry graph whose annotated field is present.
558
+ for (const node of graph.nodes.values()) {
559
+ if (node.type !== "resource") continue;
560
+ const meta = node.manifest.metadata as
561
+ | { module?: string; forwardedExport?: boolean }
562
+ | undefined;
563
+ // A forwarded export's requirements are derived by ITS library's own stage
564
+ // (with the internal graph in hand) and seeded below — deriving them here
565
+ // against the flattened view would resolve correlation against a graph
566
+ // that no longer holds the library's internals.
567
+ if (meta?.forwardedExport) continue;
568
+ const def = resolveDef(node.kind, meta?.module);
569
+ const schema = def?.schema as Record<string, any> | undefined;
570
+ if (!def || !schema?.properties) continue;
571
+ for (const [field, propSchema] of Object.entries(
572
+ schema.properties as Record<string, Record<string, any>>,
573
+ )) {
574
+ const requires = readRequiresZone(propSchema);
575
+ if (!requires) continue;
576
+ if ((node.manifest as Record<string, unknown>)[field] === undefined) continue;
577
+ const zoneDef = resolveDef(requires.zone, def.metadata.module);
578
+ if (!zoneDef) continue; // ZONE_PROVIDER_UNRESOLVED is reported at registration
579
+ const zone = canonicalOf(zoneDef);
580
+ const target = resolveStaticKey(node, requires.key, resolveName);
581
+ const req: Requirement = {
582
+ zone,
583
+ accepted: acceptedFor(zone),
584
+ correlation: target ? correlationIdOf(target) : undefined,
585
+ correlationLabel: target ? labelOf(target) : undefined,
586
+ correlationName: target?.name,
587
+ reason: requires.reason,
588
+ origin: labelOf(node),
589
+ key: "",
590
+ };
591
+ req.key = `${req.zone}\0${req.correlation ?? ""}`;
592
+ propagate(node, req, [node.name]);
593
+ }
594
+ }
595
+
596
+ // ── Seeds: imported libraries' derived export contracts, attached at the
597
+ // forwarded nodes the importer's graph actually holds.
598
+ if (seeds && seeds.size > 0) {
599
+ for (const node of graph.nodes.values()) {
600
+ if (node.type !== "resource") continue;
601
+ const meta = node.manifest.metadata as
602
+ | { module?: string; forwardedExport?: boolean }
603
+ | undefined;
604
+ if (!meta?.forwardedExport || !meta.module) continue;
605
+ const specs = seeds.get(`${meta.module}\0${node.name}`);
606
+ if (!specs) continue;
607
+ for (const spec of specs) {
608
+ const zoneDef = defs.resolve(spec.zone);
609
+ const req: Requirement = {
610
+ zone: spec.zone,
611
+ accepted: zoneDef ? acceptedFor(spec.zone) : new Set([spec.zone]),
612
+ correlation: spec.correlation,
613
+ correlationLabel: spec.correlationLabel,
614
+ correlationName: spec.correlationName,
615
+ reason: spec.reason,
616
+ origin: spec.origin,
617
+ key: `${spec.zone}\0${spec.correlation ?? ""}`,
618
+ };
619
+ propagate(node, req, [...spec.via, node.name]);
620
+ }
621
+ }
622
+ }
623
+
624
+ return { diagnostics, openExports };
625
+ }
626
+
627
+ /** Content signature over a library's loaded documents — what makes a
628
+ * workspace library invalidate on the keystroke that changed it while a
629
+ * published library (immutable bytes) hits every time. FNV-1a over the JSON
630
+ * projection; collisions only stale a warning-level derivation, and the
631
+ * projection is cheap relative to the graph build it guards. */
632
+ export function zoneDocumentsSignature(manifests: readonly ResourceManifest[]): string {
633
+ let hash = 0x811c9dc5;
634
+ const mix = (text: string): void => {
635
+ for (let i = 0; i < text.length; i++) {
636
+ hash ^= text.charCodeAt(i);
637
+ hash = Math.imul(hash, 0x01000193);
638
+ }
639
+ };
640
+ for (const m of manifests) mix(JSON.stringify(m) ?? "");
641
+ return (hash >>> 0).toString(16);
642
+ }
643
+
644
+ /**
645
+ * Derive one library's export contracts: build the library-scoped graph from
646
+ * its full documents (which the flattened analysis view no longer holds), run
647
+ * the projection derive-only, and keep what reaches an exported instance.
648
+ */
649
+ export function deriveLibraryExportRequirements(
650
+ docs: ZoneModuleDocuments,
651
+ defs: DefinitionRegistry,
652
+ aliases: AliasResolver,
653
+ aliasesByModule: Map<string, AliasResolver>,
654
+ cache?: ZoneExportCache,
655
+ ): ZoneExportRequirements {
656
+ const signature = docs.signature ?? zoneDocumentsSignature(docs.manifests);
657
+ const cached = cache?.get(docs.sourceId);
658
+ if (cached && cached.signature === signature) return cached.exports;
659
+
660
+ const exported = new Set(docs.exportedNames);
661
+ const graph = buildCallGraph(docs.manifests, defs, { aliases, aliasesByModule });
662
+ const { openExports } = projectZoneRequirements({
663
+ graph,
664
+ defs,
665
+ aliases,
666
+ aliasesByModule,
667
+ reportModules: new Set(),
668
+ exportOf: (node) =>
669
+ !node.scoped && exported.has(node.name) ? node.name : undefined,
670
+ });
671
+ cache?.set(docs.sourceId, { signature, exports: openExports });
672
+ return openExports;
673
+ }
674
+
675
+ export interface ZoneAnalysisArgs {
676
+ /** The entry analysis set (post inline-normalization + sentinel resolution). */
677
+ manifests: ResourceManifest[];
678
+ graph: CallGraph;
679
+ defs: DefinitionRegistry;
680
+ aliases: AliasResolver;
681
+ aliasesByModule: Map<string, AliasResolver>;
682
+ rootModules: ReadonlySet<string>;
683
+ moduleDocuments?: readonly ZoneModuleDocuments[];
684
+ cache?: ZoneExportCache;
685
+ }
686
+
687
+ /**
688
+ * The zone stage of `analyze()`: per-library export derivation (cached), the
689
+ * entry projection with those contracts seeded, and — when the entry is a
690
+ * library — the export-satisfiability check, at the one desk where it is
691
+ * fixable.
692
+ */
693
+ export function runZoneAnalysis(args: ZoneAnalysisArgs): AnalysisDiagnostic[] {
694
+ const { manifests, graph, defs, aliases, aliasesByModule, rootModules } = args;
695
+
696
+ // Per-library export contracts, derived over each library's own documents.
697
+ const seeds = new Map<string, ZoneRequirementSpec[]>();
698
+ for (const docs of args.moduleDocuments ?? []) {
699
+ const contracts = deriveLibraryExportRequirements(
700
+ docs,
701
+ defs,
702
+ aliases,
703
+ aliasesByModule,
704
+ args.cache,
705
+ );
706
+ for (const [exportName, specs] of contracts) {
707
+ if (specs.length > 0) seeds.set(`${docs.module}\0${exportName}`, specs);
708
+ }
709
+ }
710
+
711
+ // The entry library's own export surface, for the satisfiability check.
712
+ const rootLibraries: Array<{ module: string; exportedNames: Set<string>; doc: ResourceManifest }> =
713
+ [];
714
+ for (const m of manifests) {
715
+ if (m.kind !== "Telo.Library") continue;
716
+ const name = m.metadata?.name as string | undefined;
717
+ if (!name || !rootModules.has(name)) continue;
718
+ const exportedNames = new Set<string>();
719
+ for (const entry of (m as { exports?: { resources?: unknown[] } }).exports?.resources ?? []) {
720
+ if (typeof entry !== "string") continue;
721
+ const dot = entry.indexOf(".");
722
+ exportedNames.add(dot > 0 ? entry.slice(dot + 1) : entry);
723
+ }
724
+ if (exportedNames.size > 0) rootLibraries.push({ module: name, exportedNames, doc: m });
725
+ }
726
+ const exportOf =
727
+ rootLibraries.length > 0
728
+ ? (node: ResourceGraphNode): string | undefined => {
729
+ if (node.scoped) return undefined;
730
+ const module = (node.manifest.metadata as { module?: string } | undefined)?.module;
731
+ const lib = rootLibraries.find((l) => l.module === module);
732
+ return lib?.exportedNames.has(node.name) ? node.name : undefined;
733
+ }
734
+ : undefined;
735
+
736
+ const { diagnostics, openExports } = projectZoneRequirements({
737
+ graph,
738
+ defs,
739
+ aliases,
740
+ aliasesByModule,
741
+ reportModules: rootModules,
742
+ seeds: seeds.size > 0 ? seeds : undefined,
743
+ exportOf,
744
+ });
745
+
746
+ // An export whose requirement correlates on a resource importers cannot
747
+ // reach is unsatisfiable by construction — raised HERE, at the exporting
748
+ // library, never by an importer against a file it does not own.
749
+ for (const lib of rootLibraries) {
750
+ for (const [exportName, specs] of openExports) {
751
+ if (!lib.exportedNames.has(exportName)) continue;
752
+ const node = graph.resourceByName(exportName);
753
+ if (!node || (node.manifest.metadata as { module?: string } | undefined)?.module !== lib.module) {
754
+ continue;
755
+ }
756
+ for (const spec of specs) {
757
+ if (spec.correlationName === undefined) continue; // uncorrelated — satisfiable by any zone
758
+ if (lib.exportedNames.has(spec.correlationName)) continue; // importers can reach it
759
+ diagnostics.push({
760
+ severity: DiagnosticSeverity.Error,
761
+ code: "ZONE_EXPORT_UNSATISFIABLE",
762
+ source: SOURCE,
763
+ message:
764
+ `exported resource '${exportName}' carries an open requirement: ${spec.origin} requires ` +
765
+ `a ${spec.zone} zone on ${spec.correlationLabel}, which this library does not export — ` +
766
+ `no importer can satisfy it. Export '${spec.correlationName}' too (importers wrap ` +
767
+ `'${exportName}' in their own zone on it), or export a resource that goes through the ` +
768
+ `provider instead of '${exportName}' directly.` +
769
+ (spec.reason ? ` ${spec.reason}.` : ""),
770
+ data: {
771
+ resource: { kind: node.kind, name: node.name },
772
+ filePath: (node.manifest.metadata as { source?: string } | undefined)?.source,
773
+ path: "",
774
+ },
775
+ });
776
+ }
777
+ }
778
+ }
779
+
780
+ return diagnostics;
781
+ }