@telorun/analyzer 0.52.0 → 0.54.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 (80) hide show
  1. package/dist/analysis-registry.d.ts +8 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +21 -3
  4. package/dist/analyzer.d.ts +3 -2
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +193 -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 +15 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -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-module-metadata.d.ts +38 -0
  40. package/dist/validate-module-metadata.d.ts.map +1 -0
  41. package/dist/validate-module-metadata.js +256 -0
  42. package/dist/validate-observed-state.d.ts +14 -13
  43. package/dist/validate-observed-state.d.ts.map +1 -1
  44. package/dist/validate-observed-state.js +21 -88
  45. package/dist/validate-ref-slots.d.ts +48 -0
  46. package/dist/validate-ref-slots.d.ts.map +1 -0
  47. package/dist/validate-ref-slots.js +219 -0
  48. package/dist/validate-references.d.ts.map +1 -1
  49. package/dist/validate-references.js +8 -1
  50. package/dist/validate-zone-slots.d.ts +39 -0
  51. package/dist/validate-zone-slots.d.ts.map +1 -0
  52. package/dist/validate-zone-slots.js +114 -0
  53. package/dist/zone-module-documents.d.ts +27 -0
  54. package/dist/zone-module-documents.d.ts.map +1 -0
  55. package/dist/zone-module-documents.js +1 -0
  56. package/dist/zone-slot.d.ts +61 -0
  57. package/dist/zone-slot.d.ts.map +1 -0
  58. package/dist/zone-slot.js +91 -0
  59. package/package.json +3 -3
  60. package/src/analysis-registry.ts +20 -2
  61. package/src/analyzer.ts +211 -24
  62. package/src/builtins.ts +32 -12
  63. package/src/call-graph.ts +827 -0
  64. package/src/dependency-graph.ts +34 -68
  65. package/src/flatten-for-analyzer.ts +32 -0
  66. package/src/index.ts +51 -0
  67. package/src/manifest-navigation.ts +91 -0
  68. package/src/manifest-visitor.ts +1 -1
  69. package/src/ref-slot.ts +273 -0
  70. package/src/reference-field-map.ts +39 -36
  71. package/src/resolve-schema-ref-kinds.ts +34 -7
  72. package/src/resolve-zone-requirements.ts +781 -0
  73. package/src/types.ts +8 -0
  74. package/src/validate-module-metadata.ts +335 -0
  75. package/src/validate-observed-state.ts +26 -92
  76. package/src/validate-ref-slots.ts +293 -0
  77. package/src/validate-references.ts +8 -1
  78. package/src/validate-zone-slots.ts +175 -0
  79. package/src/zone-module-documents.ts +27 -0
  80. package/src/zone-slot.ts +116 -0
@@ -0,0 +1,541 @@
1
+ import { isRefSentinel } from "@telorun/templating";
2
+ import { buildCallGraph, } from "./call-graph.js";
3
+ import { enclosingOf, propertySchemas, resolveLocalRef, } from "./manifest-navigation.js";
4
+ import { readProvidesZone, readRequiresZone } from "./zone-slot.js";
5
+ import { DiagnosticSeverity } from "./types.js";
6
+ const SOURCE = "telo-analyzer";
7
+ /** Resolve a possibly alias-form kind to its definition in the DECLARING
8
+ * module's scope — the same layering `AnalysisRegistry.resolveDefinitionIn`
9
+ * uses. */
10
+ function definitionResolver(defs, aliases, aliasesByModule) {
11
+ return (kind, module) => {
12
+ const scope = (module ? aliasesByModule.get(module) : undefined) ?? aliases;
13
+ const canonical = scope.resolveKind(kind);
14
+ return defs.resolve(kind) ?? (canonical ? defs.resolve(canonical) : undefined);
15
+ };
16
+ }
17
+ const canonicalOf = (def) => `${def.metadata.module}.${def.metadata.name}`;
18
+ /**
19
+ * Correlation identity — the resolved DECLARATION SITE, mirroring runtime
20
+ * instance identity exactly. A named module-level resource is
21
+ * `(declaring file, name)` — the file, not the owning module name, because a
22
+ * re-exported instance is forwarded once per re-exporting module under that
23
+ * module's name while its declaration site survives the copy. A
24
+ * `with:`-scoped resource is its scope site plus name (one instance per scope
25
+ * run); an inline declaration is its own generated node.
26
+ */
27
+ function correlationIdOf(node) {
28
+ if (node.scoped)
29
+ return `${node.scopeOwner}\0${node.scopeSite}\0${node.name}`;
30
+ const source = node.manifest.metadata?.source ?? "";
31
+ return `${source}\0${node.name}`;
32
+ }
33
+ const labelOf = (node) => `${node.kind} '${node.name}'`;
34
+ /** The schema node at a field-map path (`steps`, `routes[].handler`), following
35
+ * `[]` into `items`, `{}` into `additionalProperties` and local `$defs` refs —
36
+ * where a slot's zone annotations live. */
37
+ function schemaNodeAt(rootSchema, slotPath) {
38
+ if (!rootSchema)
39
+ return undefined;
40
+ let current = rootSchema;
41
+ for (const segment of slotPath.split(".")) {
42
+ if (!current)
43
+ return undefined;
44
+ const bare = segment.replace(/(\[\]|\{\})+$/g, "");
45
+ let next = propertySchemas(current).find(([k]) => k === bare)?.[1];
46
+ for (const marker of segment.slice(bare.length).match(/\[\]|\{\}/g) ?? []) {
47
+ next = resolveLocalRef(marker === "[]"
48
+ ? next?.items
49
+ : next?.additionalProperties, rootSchema);
50
+ if (!next || typeof next !== "object")
51
+ return undefined;
52
+ }
53
+ current = resolveLocalRef(next, rootSchema);
54
+ }
55
+ return current;
56
+ }
57
+ /**
58
+ * The LOCAL name a correlation-key hop resolves to, or undefined when the value
59
+ * is not a reference or does not name something local.
60
+ *
61
+ * **A cross-module reference is undefined, deliberately**, matching the kernel's
62
+ * `referencedName` exactly: `!ref Alias.name` names an instance in another
63
+ * module's scope, and the only index available here is flat and
64
+ * module-unscoped, so taking the bare name would bind to whatever local
65
+ * resource happens to share it. Correlation is an identity comparison — binding
66
+ * it to the wrong resource is worse than leaving it uncorrelated, which is the
67
+ * under-approximating direction the whole pass leans on. `Self.` is a local
68
+ * name written the long way and does resolve.
69
+ *
70
+ * This deliberately differs from `call-graph`'s `refTargetName`, which answers a
71
+ * different question (what an EDGE points at, cross-module included, for a graph
72
+ * whose consumers tolerate an unresolved target) — hence two functions rather
73
+ * than one shared helper.
74
+ */
75
+ function refName(value) {
76
+ if (isRefSentinel(value)) {
77
+ const source = value.source;
78
+ const dot = source.indexOf(".");
79
+ if (dot <= 0)
80
+ return source;
81
+ return source.slice(0, dot) === "Self" ? source.slice(dot + 1) : undefined;
82
+ }
83
+ if (!value || typeof value !== "object" || Array.isArray(value))
84
+ return undefined;
85
+ const v = value;
86
+ const pure = typeof v.kind === "string" &&
87
+ typeof v.name === "string" &&
88
+ Object.keys(v).every((k) => k === "kind" || k === "name" || k === "alias" || k === "__ref");
89
+ if (!pure)
90
+ return undefined;
91
+ const alias = v.alias;
92
+ if (typeof alias === "string" && alias !== "Self")
93
+ return undefined;
94
+ return v.name;
95
+ }
96
+ /**
97
+ * Resolve an ordered correlation-key pointer list against a resource's
98
+ * manifest, first hit winning — the static counterpart of the kernel's walk.
99
+ * A pointer may traverse a `!ref` into the referenced resource's own manifest
100
+ * (read field → resolve reference → read field); traversal is mechanical, so
101
+ * no kind is named here. Returns undefined when nothing resolves — the
102
+ * requirement then discharges uncorrelated, the under-approximating side.
103
+ */
104
+ function resolveStaticKey(start, pointers, resolveName) {
105
+ for (const pointer of pointers) {
106
+ if (!pointer.startsWith("/"))
107
+ continue;
108
+ let manifest = start.manifest;
109
+ let context = start;
110
+ let value = undefined;
111
+ let failed = false;
112
+ const segments = pointer.slice(1).split("/");
113
+ for (let i = 0; i < segments.length; i++) {
114
+ const segment = segments[i].replace(/~1/g, "/").replace(/~0/g, "~");
115
+ if (i > 0) {
116
+ // Traverse the previous hop's reference into its declaration.
117
+ const name = refName(value);
118
+ const target = name ? resolveName(name, context) : undefined;
119
+ if (!target) {
120
+ failed = true;
121
+ break;
122
+ }
123
+ manifest = target.manifest;
124
+ context = target;
125
+ }
126
+ value = manifest?.[segment];
127
+ if (value === undefined || value === null) {
128
+ failed = true;
129
+ break;
130
+ }
131
+ }
132
+ if (failed)
133
+ continue;
134
+ const terminalName = refName(value);
135
+ const target = terminalName ? resolveName(terminalName, context) : undefined;
136
+ if (target)
137
+ return target;
138
+ }
139
+ return undefined;
140
+ }
141
+ /** Anchor a provider-side single pointer at the object enclosing the slot,
142
+ * then resolve exactly like a requirer key. */
143
+ function resolveProviderKey(provider, edge, pointer, resolveName) {
144
+ const enclosing = enclosingOf(provider.manifest, edge.path);
145
+ if (enclosing === provider.manifest || enclosing === undefined) {
146
+ return resolveStaticKey(provider, [pointer], resolveName);
147
+ }
148
+ // Slot inside an array item: resolve the first hop off the item, further
149
+ // hops through references as usual.
150
+ const synthetic = { ...provider, manifest: enclosing };
151
+ return resolveStaticKey(synthetic, [pointer], resolveName);
152
+ }
153
+ /** Run the projection over one graph. */
154
+ export function projectZoneRequirements(args) {
155
+ const { graph, defs, aliases, aliasesByModule, reportModules, seeds, exportOf } = args;
156
+ const resolveDef = definitionResolver(defs, aliases, aliasesByModule);
157
+ const diagnostics = [];
158
+ const openExports = new Map();
159
+ const reported = new Set();
160
+ // Scope-local name index, so a scoped resource's pointers resolve against its
161
+ // scope siblings first — the order `ScopeContext` and `!ref` agree on.
162
+ const scopeLocal = new Map();
163
+ for (const node of graph.nodes.values()) {
164
+ if (node.type !== "resource" || !node.scoped)
165
+ continue;
166
+ const key = `${node.scopeOwner}\0${node.scopeSite}`;
167
+ let bucket = scopeLocal.get(key);
168
+ if (!bucket)
169
+ scopeLocal.set(key, (bucket = new Map()));
170
+ bucket.set(node.name, node);
171
+ }
172
+ const resolveName = (name, from) => {
173
+ if (from.scoped) {
174
+ const local = scopeLocal.get(`${from.scopeOwner}\0${from.scopeSite}`)?.get(name);
175
+ if (local)
176
+ return local;
177
+ }
178
+ return graph.resourceByName(name);
179
+ };
180
+ const acceptedFor = (zone) => {
181
+ const out = new Set([zone]);
182
+ for (const def of defs.getByExtends(zone)) {
183
+ const module = def.metadata?.module;
184
+ if (module && def.metadata?.name)
185
+ out.add(`${module}.${def.metadata.name}`);
186
+ }
187
+ return out;
188
+ };
189
+ const moduleOf = (node) => node.manifest.metadata?.module;
190
+ const reportable = (node) => {
191
+ const module = moduleOf(node);
192
+ return module === undefined || reportModules.has(module);
193
+ };
194
+ const emit = (severity, code, edge, caller, req, via, why) => {
195
+ if (!reportable(caller))
196
+ return;
197
+ const dedupe = `${code}\0${edge.from}\0${edge.path}\0${req.key}`;
198
+ if (reported.has(dedupe))
199
+ return;
200
+ reported.add(dedupe);
201
+ const wanted = req.correlationLabel
202
+ ? `a ${req.zone} zone on ${req.correlationLabel}`
203
+ : `a ${req.zone} zone`;
204
+ const path = [...via, `${caller.name}.${edge.path}`].join(" → ");
205
+ const reason = req.reason ? ` ${req.reason}.` : "";
206
+ diagnostics.push({
207
+ severity,
208
+ code,
209
+ source: SOURCE,
210
+ message: `${req.origin} requires ${wanted}, and the path ${path} ${why}.${reason}`,
211
+ data: {
212
+ resource: { kind: caller.kind, name: caller.name },
213
+ filePath: caller.manifest.metadata?.source,
214
+ path: edge.path,
215
+ },
216
+ });
217
+ };
218
+ const visited = new Set();
219
+ const propagate = (node, req, via) => {
220
+ const visitKey = `${node.id}\0${req.key}`;
221
+ if (visited.has(visitKey))
222
+ return;
223
+ visited.add(visitKey);
224
+ // A requirement reaching an exported instance is part of that export's
225
+ // contract — recorded, and propagation continues (the export may also be
226
+ // reached internally, where an enclosing provider can discharge it).
227
+ const exportName = exportOf?.(node);
228
+ if (exportName !== undefined) {
229
+ let bucket = openExports.get(exportName);
230
+ if (!bucket)
231
+ openExports.set(exportName, (bucket = []));
232
+ if (!bucket.some((r) => `${r.zone}\0${r.correlation ?? ""}` === req.key)) {
233
+ bucket.push({
234
+ zone: req.zone,
235
+ correlation: req.correlation,
236
+ correlationLabel: req.correlationLabel,
237
+ correlationName: req.correlationName,
238
+ reason: req.reason,
239
+ origin: req.origin,
240
+ via: [...via],
241
+ });
242
+ }
243
+ }
244
+ for (const edge of graph.edgesTo(node.id)) {
245
+ const from = graph.nodes.get(edge.from);
246
+ if (!from)
247
+ continue;
248
+ const caller = from.type === "step"
249
+ ? graph.nodes.get(from.owner)
250
+ : from;
251
+ if (!caller || caller.type !== "resource")
252
+ continue;
253
+ // Discharge: the slot provides a zone whose kind satisfies the
254
+ // requirement and whose correlation payload is the same declaration
255
+ // site. Checked BEFORE termination, so a terminating provider slot
256
+ // (a detached durable body) still discharges its own zone.
257
+ const callerDef = resolveDef(caller.kind, moduleOf(caller));
258
+ const slotSchema = schemaNodeAt(callerDef?.schema, edge.slot);
259
+ const provides = readProvidesZone(slotSchema);
260
+ if (provides && callerDef && req.accepted.has(canonicalOf(callerDef))) {
261
+ const providerKey = provides.key
262
+ ? resolveProviderKey(caller, edge, provides.key, resolveName)
263
+ : undefined;
264
+ const discharged = req.correlation === undefined ||
265
+ (providerKey !== undefined && correlationIdOf(providerKey) === req.correlation);
266
+ if (discharged)
267
+ continue;
268
+ }
269
+ // A dynamic selector is already a hard diagnostic (validate-ref-slots);
270
+ // zones do not re-report it — and must not propagate through a use they
271
+ // cannot read.
272
+ if (edge.unresolvedReason === "dynamic")
273
+ continue;
274
+ if (edge.use.length === 0) {
275
+ emit(DiagnosticSeverity.Warning, "ZONE_REQUIREMENT_DEFERRED", edge, caller, req, via, "reaches a slot that declares no use, so whether the zone survives it cannot be decided statically; the runtime check remains the enforcement");
276
+ continue;
277
+ }
278
+ if (edge.unresolved) {
279
+ emit(DiagnosticSeverity.Warning, "ZONE_REQUIREMENT_DEFERRED", edge, caller, req, via, `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`);
280
+ continue;
281
+ }
282
+ if (edge.use.every((u) => u === "dependency" || u === "schema"))
283
+ continue;
284
+ if (edge.use.length > 1) {
285
+ // The zone's lifetime extends through the edge only if EVERY member is
286
+ // `call` — a set says several relations hold at once, not that one of
287
+ // them might.
288
+ if (edge.use.every((u) => u === "call")) {
289
+ continueUp(caller, edge, req, via);
290
+ continue;
291
+ }
292
+ // A member the runtime guarantees is cleared makes the set decidably
293
+ // wrong, not undecidable: the detached dispatch is NEVER inside the
294
+ // caller's zone, so the requirement — a universal claim — is violated
295
+ // on that path. What stays unknown is only how often that path is
296
+ // taken, and "sometimes throws" is not a working manifest.
297
+ //
298
+ // This is the one place the plan's original rule was inverted, and
299
+ // deliberately. That rule existed to stop `Cache.View`'s UNCONDITIONAL
300
+ // `[call, detached]` from hard-erroring every cached transactional call
301
+ // under the default `revalidate: sync`, where the controller never
302
+ // detaches — but the same change that added this pass re-annotated that
303
+ // slot as a case map, so a set now appears only where its detach really
304
+ // happens. The justification went with the annotation.
305
+ const guaranteedCleared = edge.use.filter((u) => u === "detached" || u === "trigger.inbound");
306
+ if (guaranteedCleared.length > 0) {
307
+ emit(DiagnosticSeverity.Error, "ZONE_REQUIREMENT_UNSATISFIED", edge, caller, req, via, `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`);
308
+ continue;
309
+ }
310
+ // Everything else in a set is undecidable rather than wrong — a
311
+ // `trigger.consumer` member means a drain site MIGHT be inside the
312
+ // zone, which no static reading can settle.
313
+ emit(DiagnosticSeverity.Warning, "ZONE_REQUIREMENT_DEFERRED", edge, caller, req, via, `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`);
314
+ continue;
315
+ }
316
+ switch (edge.use[0]) {
317
+ case "call":
318
+ continueUp(caller, edge, req, via);
319
+ break;
320
+ case "detached":
321
+ emit(DiagnosticSeverity.Error, "ZONE_REQUIREMENT_UNSATISFIED", edge, caller, req, via, "detaches there — the runtime guarantees the detached work a fresh context, outside every zone its caller was in");
322
+ break;
323
+ case "trigger.inbound":
324
+ emit(DiagnosticSeverity.Error, "ZONE_REQUIREMENT_UNSATISFIED", edge, caller, req, via, "is an inbound trigger registration — the handler runs on a fresh context driven by a request or timer, outside every zone");
325
+ break;
326
+ case "trigger.consumer":
327
+ emit(DiagnosticSeverity.Warning, "ZONE_REQUIREMENT_DEFERRED", edge, caller, req, via, "is dispatched when a returned value is drained, so where it runs is the drain site's choice; the runtime check remains the enforcement");
328
+ break;
329
+ // dependency / schema singletons were filtered above.
330
+ }
331
+ }
332
+ };
333
+ const continueUp = (caller, edge, req, via) => {
334
+ // Nothing encloses an Application's boot targets: an open requirement
335
+ // arriving there surfaces at boot.
336
+ if (caller.kind === "Telo.Application") {
337
+ emit(DiagnosticSeverity.Error, "ZONE_REQUIREMENT_UNSATISFIED", edge, caller, req, via, "reaches the application's boot targets, which nothing encloses");
338
+ return;
339
+ }
340
+ propagate(caller, req, [...via, caller.name]);
341
+ };
342
+ // ── Origins: instances of the entry graph whose annotated field is present.
343
+ for (const node of graph.nodes.values()) {
344
+ if (node.type !== "resource")
345
+ continue;
346
+ const meta = node.manifest.metadata;
347
+ // A forwarded export's requirements are derived by ITS library's own stage
348
+ // (with the internal graph in hand) and seeded below — deriving them here
349
+ // against the flattened view would resolve correlation against a graph
350
+ // that no longer holds the library's internals.
351
+ if (meta?.forwardedExport)
352
+ continue;
353
+ const def = resolveDef(node.kind, meta?.module);
354
+ const schema = def?.schema;
355
+ if (!def || !schema?.properties)
356
+ continue;
357
+ for (const [field, propSchema] of Object.entries(schema.properties)) {
358
+ const requires = readRequiresZone(propSchema);
359
+ if (!requires)
360
+ continue;
361
+ if (node.manifest[field] === undefined)
362
+ continue;
363
+ const zoneDef = resolveDef(requires.zone, def.metadata.module);
364
+ if (!zoneDef)
365
+ continue; // ZONE_PROVIDER_UNRESOLVED is reported at registration
366
+ const zone = canonicalOf(zoneDef);
367
+ const target = resolveStaticKey(node, requires.key, resolveName);
368
+ const req = {
369
+ zone,
370
+ accepted: acceptedFor(zone),
371
+ correlation: target ? correlationIdOf(target) : undefined,
372
+ correlationLabel: target ? labelOf(target) : undefined,
373
+ correlationName: target?.name,
374
+ reason: requires.reason,
375
+ origin: labelOf(node),
376
+ key: "",
377
+ };
378
+ req.key = `${req.zone}\0${req.correlation ?? ""}`;
379
+ propagate(node, req, [node.name]);
380
+ }
381
+ }
382
+ // ── Seeds: imported libraries' derived export contracts, attached at the
383
+ // forwarded nodes the importer's graph actually holds.
384
+ if (seeds && seeds.size > 0) {
385
+ for (const node of graph.nodes.values()) {
386
+ if (node.type !== "resource")
387
+ continue;
388
+ const meta = node.manifest.metadata;
389
+ if (!meta?.forwardedExport || !meta.module)
390
+ continue;
391
+ const specs = seeds.get(`${meta.module}\0${node.name}`);
392
+ if (!specs)
393
+ continue;
394
+ for (const spec of specs) {
395
+ const zoneDef = defs.resolve(spec.zone);
396
+ const req = {
397
+ zone: spec.zone,
398
+ accepted: zoneDef ? acceptedFor(spec.zone) : new Set([spec.zone]),
399
+ correlation: spec.correlation,
400
+ correlationLabel: spec.correlationLabel,
401
+ correlationName: spec.correlationName,
402
+ reason: spec.reason,
403
+ origin: spec.origin,
404
+ key: `${spec.zone}\0${spec.correlation ?? ""}`,
405
+ };
406
+ propagate(node, req, [...spec.via, node.name]);
407
+ }
408
+ }
409
+ }
410
+ return { diagnostics, openExports };
411
+ }
412
+ /** Content signature over a library's loaded documents — what makes a
413
+ * workspace library invalidate on the keystroke that changed it while a
414
+ * published library (immutable bytes) hits every time. FNV-1a over the JSON
415
+ * projection; collisions only stale a warning-level derivation, and the
416
+ * projection is cheap relative to the graph build it guards. */
417
+ export function zoneDocumentsSignature(manifests) {
418
+ let hash = 0x811c9dc5;
419
+ const mix = (text) => {
420
+ for (let i = 0; i < text.length; i++) {
421
+ hash ^= text.charCodeAt(i);
422
+ hash = Math.imul(hash, 0x01000193);
423
+ }
424
+ };
425
+ for (const m of manifests)
426
+ mix(JSON.stringify(m) ?? "");
427
+ return (hash >>> 0).toString(16);
428
+ }
429
+ /**
430
+ * Derive one library's export contracts: build the library-scoped graph from
431
+ * its full documents (which the flattened analysis view no longer holds), run
432
+ * the projection derive-only, and keep what reaches an exported instance.
433
+ */
434
+ export function deriveLibraryExportRequirements(docs, defs, aliases, aliasesByModule, cache) {
435
+ const signature = docs.signature ?? zoneDocumentsSignature(docs.manifests);
436
+ const cached = cache?.get(docs.sourceId);
437
+ if (cached && cached.signature === signature)
438
+ return cached.exports;
439
+ const exported = new Set(docs.exportedNames);
440
+ const graph = buildCallGraph(docs.manifests, defs, { aliases, aliasesByModule });
441
+ const { openExports } = projectZoneRequirements({
442
+ graph,
443
+ defs,
444
+ aliases,
445
+ aliasesByModule,
446
+ reportModules: new Set(),
447
+ exportOf: (node) => !node.scoped && exported.has(node.name) ? node.name : undefined,
448
+ });
449
+ cache?.set(docs.sourceId, { signature, exports: openExports });
450
+ return openExports;
451
+ }
452
+ /**
453
+ * The zone stage of `analyze()`: per-library export derivation (cached), the
454
+ * entry projection with those contracts seeded, and — when the entry is a
455
+ * library — the export-satisfiability check, at the one desk where it is
456
+ * fixable.
457
+ */
458
+ export function runZoneAnalysis(args) {
459
+ const { manifests, graph, defs, aliases, aliasesByModule, rootModules } = args;
460
+ // Per-library export contracts, derived over each library's own documents.
461
+ const seeds = new Map();
462
+ for (const docs of args.moduleDocuments ?? []) {
463
+ const contracts = deriveLibraryExportRequirements(docs, defs, aliases, aliasesByModule, args.cache);
464
+ for (const [exportName, specs] of contracts) {
465
+ if (specs.length > 0)
466
+ seeds.set(`${docs.module}\0${exportName}`, specs);
467
+ }
468
+ }
469
+ // The entry library's own export surface, for the satisfiability check.
470
+ const rootLibraries = [];
471
+ for (const m of manifests) {
472
+ if (m.kind !== "Telo.Library")
473
+ continue;
474
+ const name = m.metadata?.name;
475
+ if (!name || !rootModules.has(name))
476
+ continue;
477
+ const exportedNames = new Set();
478
+ for (const entry of m.exports?.resources ?? []) {
479
+ if (typeof entry !== "string")
480
+ continue;
481
+ const dot = entry.indexOf(".");
482
+ exportedNames.add(dot > 0 ? entry.slice(dot + 1) : entry);
483
+ }
484
+ if (exportedNames.size > 0)
485
+ rootLibraries.push({ module: name, exportedNames, doc: m });
486
+ }
487
+ const exportOf = rootLibraries.length > 0
488
+ ? (node) => {
489
+ if (node.scoped)
490
+ return undefined;
491
+ const module = node.manifest.metadata?.module;
492
+ const lib = rootLibraries.find((l) => l.module === module);
493
+ return lib?.exportedNames.has(node.name) ? node.name : undefined;
494
+ }
495
+ : undefined;
496
+ const { diagnostics, openExports } = projectZoneRequirements({
497
+ graph,
498
+ defs,
499
+ aliases,
500
+ aliasesByModule,
501
+ reportModules: rootModules,
502
+ seeds: seeds.size > 0 ? seeds : undefined,
503
+ exportOf,
504
+ });
505
+ // An export whose requirement correlates on a resource importers cannot
506
+ // reach is unsatisfiable by construction — raised HERE, at the exporting
507
+ // library, never by an importer against a file it does not own.
508
+ for (const lib of rootLibraries) {
509
+ for (const [exportName, specs] of openExports) {
510
+ if (!lib.exportedNames.has(exportName))
511
+ continue;
512
+ const node = graph.resourceByName(exportName);
513
+ if (!node || node.manifest.metadata?.module !== lib.module) {
514
+ continue;
515
+ }
516
+ for (const spec of specs) {
517
+ if (spec.correlationName === undefined)
518
+ continue; // uncorrelated — satisfiable by any zone
519
+ if (lib.exportedNames.has(spec.correlationName))
520
+ continue; // importers can reach it
521
+ diagnostics.push({
522
+ severity: DiagnosticSeverity.Error,
523
+ code: "ZONE_EXPORT_UNSATISFIABLE",
524
+ source: SOURCE,
525
+ message: `exported resource '${exportName}' carries an open requirement: ${spec.origin} requires ` +
526
+ `a ${spec.zone} zone on ${spec.correlationLabel}, which this library does not export — ` +
527
+ `no importer can satisfy it. Export '${spec.correlationName}' too (importers wrap ` +
528
+ `'${exportName}' in their own zone on it), or export a resource that goes through the ` +
529
+ `provider instead of '${exportName}' directly.` +
530
+ (spec.reason ? ` ${spec.reason}.` : ""),
531
+ data: {
532
+ resource: { kind: node.kind, name: node.name },
533
+ filePath: node.manifest.metadata?.source,
534
+ path: "",
535
+ },
536
+ });
537
+ }
538
+ }
539
+ }
540
+ return diagnostics;
541
+ }
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ZoneModuleDocuments } from "./zone-module-documents.js";
1
2
  /** Matches LSP DiagnosticSeverity values exactly.
2
3
  * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticSeverity */
3
4
  export declare const DiagnosticSeverity: {
@@ -74,6 +75,13 @@ export interface LoaderInitOptions {
74
75
  }
75
76
  export interface AnalysisOptions {
76
77
  strictContexts?: boolean;
78
+ /** Imported libraries' FULL document sets, for the zone stage's per-library
79
+ * export derivation — the flattened analysis view forwards only each
80
+ * library's export surface, never its internal dispatch chain. Collected
81
+ * from a LoadedGraph via `collectZoneModuleDocuments`. Omitting it skips
82
+ * the derivation (the under-approximating direction — the runtime check
83
+ * remains the enforcement). */
84
+ moduleDocuments?: ZoneModuleDocuments[];
77
85
  /** When true, `analyze()` runs the state-mutating setup (module identity /
78
86
  * alias / definition registration plus `normalizeInlineResources`) but
79
87
  * skips every diagnostic-producing pass — per-resource validation, the
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;qHACqH;AACrH,eAAO,MAAM,kBAAkB;;;;;CAKrB,CAAC;AACX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC;AAE9F,gFAAgF;AAChF,eAAO,MAAM,yBAAyB,cAAc,CAAC;AAErD,MAAM,WAAW,QAAQ;IACvB,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,QAAQ,CAAC;IAChB,GAAG,EAAE,QAAQ,CAAC;CACf;AAED;;oDAEoD;AACpD,MAAM,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAE/C;6EAC6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAExD;;qEAEiE;IACjE,UAAU,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAEjE;;qEAEiE;IACjE,cAAc,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC1D;AAED,MAAM,WAAW,WAAW;IAC1B;;;+EAG2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;mEAO+D;IAC/D,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC;6FACyF;IACzF,WAAW,CAAC,EAAE,OAAO,sBAAsB,EAAE,WAAW,CAAC;CAC1D;AAED,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;sDAUkD;IAClD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;gEAKgE;AAChE,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC;IACtD,WAAW,CAAC,EAAE,OAAO,0BAA0B,EAAE,kBAAkB,CAAC;IACpE;;;;+EAI2E;IAC3E,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC,CAAC;CAC5E"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACtE;qHACqH;AACrH,eAAO,MAAM,kBAAkB;;;;;CAKrB,CAAC;AACX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC;AAE9F,gFAAgF;AAChF,eAAO,MAAM,yBAAyB,cAAc,CAAC;AAErD,MAAM,WAAW,QAAQ;IACvB,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,QAAQ,CAAC;IAChB,GAAG,EAAE,QAAQ,CAAC;CACf;AAED;;oDAEoD;AACpD,MAAM,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAE/C;6EAC6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;IAExD;;qEAEiE;IACjE,UAAU,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAEjE;;qEAEiE;IACjE,cAAc,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC1D;AAED,MAAM,WAAW,WAAW;IAC1B;;;+EAG2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;mEAO+D;IAC/D,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC;6FACyF;IACzF,WAAW,CAAC,EAAE,OAAO,sBAAsB,EAAE,WAAW,CAAC;CAC1D;AAED,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;oCAKgC;IAChC,eAAe,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACxC;;;;;;;;;;sDAUkD;IAClD,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;gEAKgE;AAChE,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC;IACtD,WAAW,CAAC,EAAE,OAAO,0BAA0B,EAAE,kBAAkB,CAAC;IACpE;;;;+EAI2E;IAC3E,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC,CAAC;CAC5E"}
@@ -0,0 +1,38 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import { type AnalysisDiagnostic } from "./types.js";
5
+ /**
6
+ * Static validation of the `metadata:` block on module docs (`Telo.Application` /
7
+ * `Telo.Library`) and of `metadata.deprecated` wherever it appears.
8
+ *
9
+ * These fields are descriptive — nothing in the kernel branches on them — but they
10
+ * are the module's public face: a hub indexes them, and a consumer reads them
11
+ * before deciding to import. That is exactly why they need checking. A field the
12
+ * runtime ignores has no failure mode that would ever surface it, so a mistyped
13
+ * `licence:` or `deprecatd:` is invisible forever, and the module ships claiming
14
+ * nothing while its author believes otherwise.
15
+ *
16
+ * The vocabulary stays **open** — `metadata` accepts any key, because a publisher
17
+ * may carry their own — so an unknown key is only reported when it is a near-miss
18
+ * of a known one. That catches the typo without closing the set.
19
+ *
20
+ * **Everything here is a WARNING, and fatal only at `telo publish`** (see
21
+ * {@link PUBLISH_BLOCKING_CODES}). Refusing to *run* a manifest over a field no
22
+ * runtime reads gets the cost backwards: `version: 1.0` is a YAML float rather
23
+ * than a string, which is a real mistake worth reporting, but stopping the app
24
+ * from starting over it is worse than the mistake. Publication is the moment
25
+ * these fields become consequential — they are projected onto the artifact's
26
+ * annotations and indexed by the hub — so that is where they block.
27
+ */
28
+ /**
29
+ * Codes that must not block running a manifest but MUST block publishing one.
30
+ *
31
+ * Kept as a set rather than a severity because the two audiences differ: a
32
+ * developer running a manifest wants to know, a publisher must be stopped. If a
33
+ * later check earns the same treatment, add its code here rather than inventing
34
+ * a third severity level.
35
+ */
36
+ export declare const PUBLISH_BLOCKING_CODES: ReadonlySet<string>;
37
+ export declare function validateModuleMetadata(manifests: ResourceManifest[], registry: DefinitionRegistry, aliases: AliasResolver): AnalysisDiagnostic[];
38
+ //# sourceMappingURL=validate-module-metadata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-module-metadata.d.ts","sourceRoot":"","sources":["../src/validate-module-metadata.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIzE;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAKrD,CAAC;AAiDH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,kBAAkB,EAC5B,OAAO,EAAE,aAAa,GACrB,kBAAkB,EAAE,CA8CtB"}