@telorun/kernel 0.85.0 → 0.87.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.
package/src/kernel.ts CHANGED
@@ -2,13 +2,17 @@ import {
2
2
  AnalysisRegistry,
3
3
  authoredModuleMetadata,
4
4
  buildEvalPaths,
5
- collectZoneModuleDocuments,
5
+ collectModuleDocuments,
6
+ declarationSignature,
7
+ diffManifests,
6
8
  flattenForAnalyzer,
7
9
  flattenLoadedModule,
8
10
  isModuleKind,
11
+ nodeIdFor,
9
12
  Loader,
10
13
  StaticAnalyzer,
11
14
  type DefResolver,
15
+ type DiffEntry,
12
16
  type LoadedGraph,
13
17
  type ManifestSource,
14
18
  } from "@telorun/analyzer";
@@ -47,6 +51,11 @@ import { KernelTracer } from "./tracing.js";
47
51
  import { KernelLogging, type LoggingManifestBlock } from "./logging/kernel-logging.js";
48
52
  import type { ScopeConfig } from "./logging/scope-config.js";
49
53
  import { formatSpanCounter } from "./logging/span-id.js";
54
+ import {
55
+ graphFileSources,
56
+ modulesThatMoved,
57
+ type ReconcileOutcome,
58
+ } from "./reconcile.js";
50
59
  import { ambientInvokeContext } from "./evaluation-context.js";
51
60
  import { ModuleContext } from "./module-context.js";
52
61
  import { ResourceContextImpl } from "./resource-context.js";
@@ -118,6 +127,11 @@ function throwInvalidState(operation: string, reason: string): never {
118
127
  );
119
128
  }
120
129
 
130
+ /** Docs that register a KIND. `DefinitionRegistry` only ever adds, so a change
131
+ * to one of these cannot be reconciled into a running kernel — the previous
132
+ * registration would survive it. */
133
+ const DEFINITION_KINDS: ReadonlySet<string> = new Set(["Telo.Definition", "Telo.Abstract"]);
134
+
121
135
  export interface KernelOptions {
122
136
  stdin?: NodeJS.ReadableStream;
123
137
  stdout?: NodeJS.WritableStream;
@@ -172,6 +186,24 @@ export class Kernel implements IKernel {
172
186
  * which module's library layer each resolves to. Rebuilt on every `load()`. */
173
187
  private readonly siblingLibraries = new Map<string, SiblingLibraryMap>();
174
188
  private _loadedGraph?: LoadedGraph;
189
+ /** Declaration signatures of the installed set, taken at load time — see
190
+ * `ManifestDiffOptions.previousSignatures` for why they cannot be taken
191
+ * later. */
192
+ private _declarationSignatures = new Map<string, string>();
193
+
194
+ /** Set while a reconciliation is in flight. Two overlapping calls would
195
+ * interleave unwind, deregister and re-initialize on one context, and a watch
196
+ * loop with fast saves is exactly the caller that would do it. */
197
+ private _reconciling = false;
198
+
199
+ /** The directories and cache policy `load()` produced with, replayed by
200
+ * {@link reconcile} so a second production cannot differ from the first. */
201
+ private _produceOptions?: {
202
+ manifestsDir: string | undefined;
203
+ analysisDir: string | undefined;
204
+ writeCache: boolean;
205
+ analyzeOnly: boolean;
206
+ };
175
207
  // Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
176
208
  // teardown() is the only idempotent method; everything else throws on misuse.
177
209
  private _bootCalled = false;
@@ -454,6 +486,263 @@ export class Kernel implements IKernel {
454
486
  // through spawnChild() to module imports and scoped handles.
455
487
  this.rootContext.getDefinition = (kind) => this.controllers.getDefinition(kind);
456
488
 
489
+ // Kept so `reconcile()` re-produces against the same directories and cache
490
+ // policy this load used — a second load resolving them again could differ.
491
+ this._produceOptions = { manifestsDir, analysisDir, writeCache, analyzeOnly: false };
492
+ const produced = await this.produceManifests(sourceUrl, {
493
+ manifestsDir,
494
+ analysisDir,
495
+ writeCache,
496
+ analyzeOnly: options?.analyzeOnly === true,
497
+ });
498
+ // `analyzeOnly` stops before instantiation, so there is nothing to install.
499
+ if (!produced) return;
500
+ this._declarationSignatures = produced.signatures;
501
+ this.installManifests(produced.manifests);
502
+ }
503
+
504
+ /**
505
+ * Re-read the entry and bring the running kernel into line with it, rebuilding
506
+ * only what changed.
507
+ *
508
+ * Three things decide what happens, in order, and each escalates rather than
509
+ * narrowing on a guess:
510
+ *
511
+ * 1. **A module other than the entry moved.** The kernel's runtime manifest
512
+ * set is entry-only — an imported library's resources live in the child
513
+ * context its `Telo.Import` owns — so a library edit is invisible to the
514
+ * resource diff and cannot be narrowed here at all.
515
+ * 2. **A module doc changed.** `variables` / `secrets` / `ports` / `logging`
516
+ * are resolved once for the whole application and read by anything, so a
517
+ * change there has no bounded impact set.
518
+ * 3. **Otherwise**, the resources whose declarations moved are unwound
519
+ * together with everything transitively holding them, re-registered, and
520
+ * re-initialized. Everything else keeps running, its instances untouched.
521
+ *
522
+ * `restartRequired` is a REPORT, not a failure: the caller rebuilds the
523
+ * kernel, which is what it does for every edit today. Nothing has been
524
+ * unwound when it is set.
525
+ */
526
+ async reconcile(): Promise<ReconcileOutcome> {
527
+ if (this._isTornDown) throwInvalidState("reconcile", "kernel has been torn down");
528
+ if (!this._isBooted) throwInvalidState("reconcile", "boot() has not completed");
529
+ const entryUrl = this._entryUrl;
530
+ const previousGraph = this._loadedGraph;
531
+ const produceOptions = this._produceOptions;
532
+ if (!entryUrl || !previousGraph || !produceOptions) {
533
+ throwInvalidState("reconcile", "load() has not been called");
534
+ }
535
+ if (this._reconciling) {
536
+ throwInvalidState("reconcile", "a reconciliation is already in progress");
537
+ }
538
+ const previousManifests = this.staticManifests;
539
+ const previousSignatures = this._declarationSignatures;
540
+ this._reconciling = true;
541
+ try {
542
+ return await this.reconcileLocked(
543
+ entryUrl,
544
+ previousGraph,
545
+ produceOptions,
546
+ previousManifests,
547
+ previousSignatures,
548
+ );
549
+ } finally {
550
+ this._reconciling = false;
551
+ }
552
+ }
553
+
554
+ private async reconcileLocked(
555
+ entryUrl: string,
556
+ previousGraph: LoadedGraph,
557
+ produceOptions: NonNullable<Kernel["_produceOptions"]>,
558
+ previousManifests: ResourceManifest[],
559
+ previousSignatures: Map<string, string>,
560
+ ): Promise<ReconcileOutcome> {
561
+
562
+ // The loader memoizes a file's parse on the assumption its contents do not
563
+ // change underneath one Loader — precisely what a reload breaks — so every
564
+ // file the previous graph read is dropped before it is asked for again.
565
+ for (const source of graphFileSources(previousGraph)) this.loader.forget(source);
566
+
567
+ const produced = await this.produceManifests(entryUrl, produceOptions);
568
+ if (!produced) throwInvalidState("reconcile", "produced no manifests");
569
+
570
+ // `produceManifests` writes the kernel's static half as it goes — the graph,
571
+ // the flattened set, the module artifacts, the definition registry. Those
572
+ // writes describe manifests that are only INSTALLED further down, so every
573
+ // exit before that point puts them back: a caller told to restart would
574
+ // otherwise hold a kernel whose static half had already moved, and a second
575
+ // reconcile would diff the new set against itself and report no change
576
+ // while the live instances are still the originals.
577
+ const restoreProduced = (): void => {
578
+ this._loadedGraph = previousGraph;
579
+ this.staticManifests = previousManifests;
580
+ this._declarationSignatures = previousSignatures;
581
+ };
582
+ const halted = (reason: string): ReconcileOutcome => {
583
+ restoreProduced();
584
+ return { reinitialized: [], removed: [], restartRequired: reason };
585
+ };
586
+
587
+
588
+ const moved = modulesThatMoved(previousGraph, produced.graph);
589
+ if (moved.length > 0) {
590
+ return halted(`an imported module changed: ${moved.join(", ")}`);
591
+ }
592
+
593
+ const diff = diffManifests(previousManifests, produced.manifests, { previousSignatures });
594
+ if (diff.entries.length === 0) {
595
+ // Nothing to install, so the new record describes exactly what is already
596
+ // running — keep it rather than restoring, so the next diff compares
597
+ // against the freshest read of the same file.
598
+ return { reinitialized: [], removed: [] };
599
+ }
600
+
601
+ const movedDoc = diff.entries.find(
602
+ (entry: DiffEntry) => isModuleKind((entry.next ?? entry.previous)!.kind as string),
603
+ );
604
+ if (movedDoc) return halted("the application document changed");
605
+
606
+ // A kind registration is once per kernel: `DefinitionRegistry` only ever
607
+ // adds, so a deleted or edited `Telo.Definition` would leave the old kind
608
+ // registered and the running kernel enforcing a weaker contract than
609
+ // `telo check` does against the same file — a divergence nothing reports.
610
+ const movedKind = diff.entries.find((entry: DiffEntry) =>
611
+ DEFINITION_KINDS.has((entry.next ?? entry.previous)!.kind as string),
612
+ );
613
+ if (movedKind) return halted("a resource kind definition changed");
614
+
615
+ // Names rather than node ids from here on: the root context keys everything
616
+ // by the local name, and a manifest in this set is by definition the entry
617
+ // module's own.
618
+ const nameOf = (manifest: ResourceManifest): string => manifest.metadata.name as string;
619
+ const stale = diff.entries
620
+ .filter((entry: DiffEntry) => entry.change !== "added")
621
+ .map((entry: DiffEntry) => nameOf(entry.previous!));
622
+ // Closed under HOLDERS: a resource holding one of these has the live
623
+ // instance injected into its slot, so it cannot outlive the rebuild.
624
+ const { impacted, opaque } = this.rootContext.impactedBy(stale);
625
+
626
+ // A module document is a registered resource whose `targets:` and
627
+ // `logging.sinks` hold references, so it is a HOLDER of them and the closure
628
+ // reaches it whenever one of those moves. It is the one resource nothing
629
+ // here can rebuild: only `installManifests` re-applies what it carries —
630
+ // targets, module metadata, the resolved environment, logging — and
631
+ // unwinding it would report the application itself as a routine removal.
632
+ const impactedDoc = [...impacted].find((name) => {
633
+ const kind = this.rootContext.declaredManifestFor(name)?.kind as string | undefined;
634
+ return kind !== undefined && isModuleKind(kind);
635
+ });
636
+ if (impactedDoc) return halted("the application document is in the impact set");
637
+
638
+ if (opaque.length > 0) {
639
+ // Someone resolved these by name during initialization, so the set of
640
+ // holders is unknown and no closure over the declared edges is an answer.
641
+ return halted(`a resource is held through a by-name resolution: ${opaque.join(", ")}`);
642
+ }
643
+
644
+ // A resource that has been started is one nothing will start again: boot
645
+ // targets run once, and re-initializing a Service leaves it constructed and
646
+ // not listening while this call would report it as rebuilt.
647
+ const started = [...impacted].filter((name) => this.rootContext.wasStarted(name));
648
+ if (started.length > 0) return halted(`a running resource would be rebuilt: ${started.join(", ")}`);
649
+
650
+ // BEFORE the unwind: both are pure over the manifests plus the registry, so
651
+ // running them afterwards would turn a detectable condition — an invalid
652
+ // reference, a cycle the edit introduced — into a kernel whose resources are
653
+ // already gone.
654
+ const { diagnostics, order, cycleError } = this.analyzer.prepare(
655
+ produced.manifests,
656
+ this.registry,
657
+ );
658
+ if (diagnostics.length > 0) {
659
+ restoreProduced();
660
+ throw new RuntimeError(
661
+ "ERR_MANIFEST_VALIDATION_FAILED",
662
+ "Manifest validation failed",
663
+ diagnostics.map(staticDiagnosticToRuntime),
664
+ );
665
+ }
666
+ if (cycleError) {
667
+ restoreProduced();
668
+ throw new RuntimeError("ERR_CIRCULAR_DEPENDENCY", cycleError);
669
+ }
670
+
671
+ const surviving = new Map<string, ResourceManifest>();
672
+ for (const manifest of produced.manifests) {
673
+ if (!isModuleKind(manifest.kind as string)) surviving.set(nameOf(manifest), manifest);
674
+ }
675
+
676
+ await this.rootContext.unwindResources(impacted);
677
+ for (const name of impacted) this.rootContext.deregisterManifest(name);
678
+
679
+ const removed = [...impacted].filter((name) => !surviving.has(name));
680
+ const added = diff.entries
681
+ .filter((entry: DiffEntry) => entry.change === "added")
682
+ .map((entry: DiffEntry) => nameOf(entry.next!));
683
+ const reinitialized = [...new Set([...impacted, ...added])].filter((name) =>
684
+ surviving.has(name),
685
+ );
686
+ for (const name of reinitialized) this.rootContext.registerManifest(surviving.get(name)!);
687
+ // A survivor keeps its instance and its declaration, but the declaration
688
+ // object is the previous load's and carries that load's `sourceLine`. Swap
689
+ // in the fresh one so a diagnostic anchored on a resource nobody touched
690
+ // still points at the line it is on now.
691
+ const rebuilt = new Set(reinitialized);
692
+ for (const [name, manifest] of surviving) {
693
+ if (!rebuilt.has(name)) this.rootContext.refreshManifest(name, manifest);
694
+ }
695
+
696
+ // Re-open for another pass: a resource resolving a sibling that has not been
697
+ // rebuilt yet must get the deferral the init loop retries on. Closed in the
698
+ // `finally` so a failure cannot leave the context open, which would turn
699
+ // every later lookup into a deferral with no pass coming.
700
+ this.rootContext.reopenForInitialization();
701
+ try {
702
+ if (order) this.rootContext.setInitOrder(order);
703
+ await this.rootContext.initializeResources();
704
+ } catch (error) {
705
+ // The resources are already gone; there is no rollback to a state that no
706
+ // longer exists. Say so rather than letting the caller read a validation
707
+ // failure as though nothing had happened.
708
+ throw new RuntimeError(
709
+ "ERR_RECONCILE_FAILED",
710
+ `reconciliation failed after unwinding ${[...impacted].join(", ")} — the kernel is ` +
711
+ `degraded and must be rebuilt: ${error instanceof Error ? error.message : String(error)}`,
712
+ error instanceof RuntimeError ? error.diagnostics : undefined,
713
+ );
714
+ } finally {
715
+ this.rootContext.closeInitialization();
716
+ }
717
+
718
+ return { reinitialized, removed };
719
+ }
720
+
721
+ /**
722
+ * Everything between a URL and a set of manifests ready to install: load the
723
+ * graph, validate it, flatten it, and normalize inline resources.
724
+ *
725
+ * Split from {@link load} because it is the half that can run AGAIN. A
726
+ * reconciliation re-produces manifests against the same context to find what
727
+ * moved, where `load` also builds the context, the built-in definitions and
728
+ * the injection hooks — all of which exist once per kernel.
729
+ *
730
+ * Returns `undefined` under `analyzeOnly`, which deliberately stops before
731
+ * module instantiation and target wiring.
732
+ */
733
+ private async produceManifests(
734
+ sourceUrl: string,
735
+ opts: {
736
+ manifestsDir: string | undefined;
737
+ analysisDir: string | undefined;
738
+ writeCache: boolean;
739
+ analyzeOnly: boolean;
740
+ },
741
+ ): Promise<
742
+ | { graph: LoadedGraph; manifests: ResourceManifest[]; signatures: Map<string, string> }
743
+ | undefined
744
+ > {
745
+ const { manifestsDir, analysisDir, writeCache } = opts;
457
746
  // Static analysis pre-flight: validates schemas and invocation context compatibility.
458
747
  // All errors are fatal — kernel does not start if analysis fails.
459
748
  // `desugarImports` expands each module's inline `imports:` map into synthetic
@@ -558,7 +847,7 @@ export class Kernel implements IKernel {
558
847
  // export surface, never its internal dispatch chain.
559
848
  {
560
849
  skipValidation,
561
- moduleDocuments: collectZoneModuleDocuments(analysisGraph),
850
+ moduleDocuments: collectModuleDocuments(analysisGraph),
562
851
  hostVersions: nodeHostVersions(),
563
852
  },
564
853
  this.registry,
@@ -590,7 +879,7 @@ export class Kernel implements IKernel {
590
879
  // before module instantiation / target wiring / application-env value
591
880
  // resolution — those need a running environment (e.g. session secrets) the
592
881
  // build does not have, and the runtime `load()` performs them anyway.
593
- if (options?.analyzeOnly) {
882
+ if (opts.analyzeOnly) {
594
883
  if (rootModuleDoc?.kind === "Telo.Application") {
595
884
  precompileApplicationEnvSchemas(
596
885
  rootModuleDoc as Record<string, any>,
@@ -667,7 +956,27 @@ export class Kernel implements IKernel {
667
956
  staticManifests,
668
957
  );
669
958
  this.staticManifests = normalizedManifests;
959
+ // Signed HERE, while these are still declarations. Installing them hands
960
+ // the very same objects to the context, and resolving a reference writes a
961
+ // live instance into one — so a signature taken later is of something else.
962
+ const signatures = new Map<string, string>();
963
+ for (const manifest of normalizedManifests) {
964
+ signatures.set(nodeIdFor(manifest), declarationSignature(manifest));
965
+ }
966
+ return { graph: analysisGraph, manifests: normalizedManifests, signatures };
967
+ }
670
968
 
969
+ /**
970
+ * Install manifests into the root context: register each one, and apply what
971
+ * a module doc carries — boot targets, module metadata, the application's
972
+ * resolved environment and its logging configuration.
973
+ *
974
+ * The other half of the {@link load} split. A reconciliation registers only
975
+ * the declarations that moved, so it calls {@link EvaluationContext.registerManifest}
976
+ * itself rather than coming through here; what lives here is the whole-set
977
+ * install, which happens once.
978
+ */
979
+ private installManifests(normalizedManifests: ResourceManifest[]): void {
671
980
  let rootApplicationManifest: ResourceManifest | undefined;
672
981
  for (const manifest of normalizedManifests) {
673
982
  if (isModuleKind(manifest.kind)) {
@@ -224,6 +224,23 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
224
224
  this._rebuildContext();
225
225
  }
226
226
 
227
+ /**
228
+ * Drop a resource's published reading, so `resources.<name>` reads as absent.
229
+ *
230
+ * What makes an unwind observable to CEL. A compile-eval field is expanded at
231
+ * create time, so a reader rebuilt while its provider's OLD reading was still
232
+ * published would bake that stale value in and succeed — where on a fresh boot
233
+ * the same expansion finds nothing and defers until the provider is back. This
234
+ * is what makes the two agree.
235
+ */
236
+ override clearPublishedReading(name: string): void {
237
+ if (!(name in this._resources)) return;
238
+ const next = { ...this._resources };
239
+ delete next[name];
240
+ this._resources = next;
241
+ this._rebuildContext();
242
+ }
243
+
227
244
  setControllerPolicy(policy: ControllerPolicy | undefined): void {
228
245
  this._controllerPolicy = policy;
229
246
  }
@@ -458,7 +475,30 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
458
475
  return this.importAliases.has(alias);
459
476
  }
460
477
 
461
- getInstance(name: string): unknown {
478
+ /**
479
+ * The recording door — see the contract in `@telorun/sdk`.
480
+ *
481
+ * A resolution taken while this module is still initializing is recorded
482
+ * against the NAME, because that is all this context has: it is reached as
483
+ * `ctx.moduleContext`, which is shared by every resource of the module, so
484
+ * there is no caller to attribute the read to. Recording the target is enough
485
+ * for the only decision that depends on it — a resource somebody may be
486
+ * holding cannot be rebuilt on its own — and the escalation that follows is
487
+ * never worse than rebuilding the whole context, which is what happens today.
488
+ *
489
+ * `state !== "Initialized"` is the same discriminator the deferral below
490
+ * already turns on, and it is the right one: before that point a caller is
491
+ * running `create()` or `init()` and may keep what it gets, after it a caller
492
+ * is handling a dispatch and resolves again next time.
493
+ */
494
+ getInstance(name: string, declaredBy?: { kind: string; name: string }): unknown {
495
+ // `declaredBy` means the name came out of a declared ref slot, so the edge
496
+ // is in the manifest and the host can already see it.
497
+ if (!declaredBy && this.state !== "Initialized") this.recordOpaqueRead(name);
498
+ return this.lookupInstance(name);
499
+ }
500
+
501
+ private lookupInstance(name: string): unknown {
462
502
  const entry = this.resourceInstances.get(name);
463
503
  if (!entry) {
464
504
  // A name this module DID declare but that has no instance is never an
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The pure half of reconciliation: what a second load of the same entry changed,
3
+ * at the granularity of whole modules.
4
+ *
5
+ * The per-RESOURCE answer is the analyzer's (`diffManifests`), and it is exact.
6
+ * This is the coarser question that has to be asked first, because the kernel's
7
+ * runtime manifest set is entry-only: an imported library's resources live in
8
+ * the child context its `Telo.Import` owns and never appear in the set the
9
+ * resource diff walks. A library edit is therefore invisible to that diff, and
10
+ * would silently reconcile to "nothing changed".
11
+ *
12
+ * So the modules are compared by content, and anything moving outside the entry
13
+ * escalates rather than being narrowed. That is the same posture the opaque-read
14
+ * escalation takes: a fallback to rebuilding, which is what a host does today.
15
+ */
16
+ import type { LoadedGraph, LoadedModule } from "@telorun/analyzer";
17
+
18
+ /** What one {@link reconcile} pass did, or why it could not narrow. */
19
+ export interface ReconcileOutcome {
20
+ /** Resources rebuilt: the declarations that moved, plus everything that was
21
+ * holding one of them. */
22
+ readonly reinitialized: readonly string[];
23
+ /** Resources whose declaration is gone. Unwound, not replaced. */
24
+ readonly removed: readonly string[];
25
+ /** Set when the change had no bounded impact set and the caller must rebuild
26
+ * the kernel. Nothing has been unwound when this is present. */
27
+ readonly restartRequired?: string;
28
+ }
29
+
30
+ /** Every file the graph read, so a caller can drop them from the loader's cache
31
+ * before asking for them again. `Loader.loadFile` assumes a file's contents do
32
+ * not change under one Loader, which is exactly the assumption a reload
33
+ * breaks. */
34
+ export function graphFileSources(graph: LoadedGraph): string[] {
35
+ const sources = new Set<string>();
36
+ for (const module of graph.modules.values()) {
37
+ sources.add(module.owner.source);
38
+ for (const partial of module.partials) sources.add(partial.source);
39
+ }
40
+ sources.add(graph.entry.owner.source);
41
+ for (const partial of graph.entry.partials) sources.add(partial.source);
42
+ return [...sources];
43
+ }
44
+
45
+ /** A module's content: every file it is made of, as text.
46
+ *
47
+ * Compared as a string rather than hashed — there is no collision to reason
48
+ * about, and a missed change here is a library that silently keeps running
49
+ * against source it no longer matches. */
50
+ function moduleSignature(module: LoadedModule): string {
51
+ return [module.owner, ...module.partials]
52
+ .map((file) => `${file.source}\u0000${file.text}`)
53
+ .join("\u0000\u0000");
54
+ }
55
+
56
+ /**
57
+ * Modules other than the entry whose content moved between two loads, including
58
+ * ones that appeared or disappeared.
59
+ *
60
+ * The entry is excluded because the resource diff answers for it precisely.
61
+ * Everything else is a library, whose resources this kernel cannot see
62
+ * individually.
63
+ */
64
+ export function modulesThatMoved(previous: LoadedGraph, next: LoadedGraph): string[] {
65
+ const before = new Map<string, string>();
66
+ for (const [source, module] of previous.modules) {
67
+ if (source === previous.rootSource) continue;
68
+ before.set(source, moduleSignature(module));
69
+ }
70
+
71
+ const moved: string[] = [];
72
+ const seen = new Set<string>();
73
+ for (const [source, module] of next.modules) {
74
+ if (source === next.rootSource) continue;
75
+ seen.add(source);
76
+ const was = before.get(source);
77
+ if (was === undefined || was !== moduleSignature(module)) moved.push(source);
78
+ }
79
+ for (const source of before.keys()) {
80
+ if (!seen.has(source)) moved.push(source);
81
+ }
82
+ return moved;
83
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * What the create-time reference edges tell us about a set of resources.
3
+ *
4
+ * The edges themselves are captured by `collectResourceRefs` and projected onto
5
+ * local names by `localDependencyNames` (both in `evaluation-context.ts`, beside
6
+ * the init loop that records them). This module is the two questions asked of
7
+ * that record afterwards, and they are the same question read in opposite
8
+ * directions:
9
+ *
10
+ * - **Forward**, for teardown: in what order may these unwind, so that a
11
+ * consumer's inverses run while what it holds is still alive.
12
+ * - **Reverse**, for reconciliation: if these resources are about to become
13
+ * invalid, who else is holding one and therefore becomes invalid too.
14
+ *
15
+ * Both read `Map<consumer, provider names>` and neither knows what a resource
16
+ * is, which is what keeps them testable without a kernel.
17
+ */
18
+
19
+ /** A resource's outgoing edges: the local names it holds. */
20
+ export type DependencyMap = ReadonlyMap<string, readonly string[]>;
21
+
22
+ /**
23
+ * Order resources so that a consumer is torn down before everything it holds.
24
+ *
25
+ * The caller's order is the TIEBREAK, not the rule. Reverse insertion is
26
+ * already a valid reverse-topological order for every edge Phase-5 injection
27
+ * resolves, because the init loop defers a resource whose refs are unresolved
28
+ * (`ERR_LOCAL_REF_PENDING`) and so cannot insert a consumer before its
29
+ * provider. What this adds is the edges that never pass through injection — a
30
+ * controller resolving a sibling by name inside `init()` — where insertion
31
+ * order says nothing.
32
+ *
33
+ * A cycle emits the first unordered entry and continues: teardown must always
34
+ * run to completion, so an unorderable set degrades to the caller's order
35
+ * rather than raising.
36
+ */
37
+ export function reverseTopologicalOrder<T>(
38
+ entries: ReadonlyArray<readonly [string, T]>,
39
+ nameOf: (value: T) => string,
40
+ dependenciesOf: (name: string) => readonly string[] | undefined,
41
+ ): Array<readonly [string, T]> {
42
+ const indexByName = new Map<string, number>();
43
+ entries.forEach(([, value], index) => indexByName.set(nameOf(value), index));
44
+
45
+ // One edge per (consumer, provider): the provider waits for the consumer.
46
+ const providersOf: number[][] = entries.map(() => []);
47
+ const waiting: number[] = entries.map(() => 0);
48
+ entries.forEach(([, value], consumer) => {
49
+ for (const dependency of dependenciesOf(nameOf(value)) ?? []) {
50
+ const provider = indexByName.get(dependency);
51
+ if (provider === undefined || provider === consumer) continue;
52
+ providersOf[consumer]!.push(provider);
53
+ waiting[provider]! += 1;
54
+ }
55
+ });
56
+
57
+ const ordered: Array<readonly [string, T]> = [];
58
+ const emitted: boolean[] = entries.map(() => false);
59
+ for (let count = 0; count < entries.length; count++) {
60
+ let pick = entries.findIndex((_, i) => !emitted[i] && waiting[i] === 0);
61
+ if (pick < 0) pick = entries.findIndex((_, i) => !emitted[i]);
62
+ emitted[pick] = true;
63
+ ordered.push(entries[pick]!);
64
+ for (const provider of providersOf[pick]!) waiting[provider]! -= 1;
65
+ }
66
+ return ordered;
67
+ }
68
+
69
+ /**
70
+ * Every resource that becomes invalid when `seeds` do: the seeds themselves, and
71
+ * everything that transitively HOLDS one of them.
72
+ *
73
+ * A holder has to go with what it holds because it is holding the instance
74
+ * itself — Phase-5 injection wrote a live object into its reference slot, and
75
+ * rebuilding the target leaves the holder pointing at an object nothing will
76
+ * ever call again. There is no version of this where the holder keeps running,
77
+ * which is why replacing one resource restarts everything above it. That is a
78
+ * cost to state rather than a defect to fix: editing a connection's declaration
79
+ * restarts what uses it.
80
+ *
81
+ * **Exact over the DECLARED edge set, and only that.** An edge exists here when
82
+ * a reference slot named the target, or when a CEL expression read it. A
83
+ * controller that resolves a sibling by NAME instead has a real dependency no
84
+ * walk of the manifest can see; those resolutions are recorded separately as
85
+ * they happen (`opaquelyRead`), and a caller whose closure reaches one has to
86
+ * escalate rather than trust this answer.
87
+ *
88
+ * A cycle is not a special case: the walk visits each name once.
89
+ */
90
+ export function impactClosure(seeds: Iterable<string>, dependencies: DependencyMap): Set<string> {
91
+ // Reversed once per call rather than maintained: the map moves on every
92
+ // create, this is asked once per reconciliation, and an index kept in step
93
+ // with a mutating map is a second source of truth.
94
+ const holdersOf = new Map<string, string[]>();
95
+ for (const [consumer, providers] of dependencies) {
96
+ for (const provider of providers) {
97
+ const held = holdersOf.get(provider);
98
+ if (held) held.push(consumer);
99
+ else holdersOf.set(provider, [consumer]);
100
+ }
101
+ }
102
+
103
+ const impacted = new Set<string>();
104
+ const queue = [...seeds];
105
+ while (queue.length > 0) {
106
+ const name = queue.pop()!;
107
+ if (impacted.has(name)) continue;
108
+ impacted.add(name);
109
+ for (const holder of holdersOf.get(name) ?? []) {
110
+ if (!impacted.has(holder)) queue.push(holder);
111
+ }
112
+ }
113
+ return impacted;
114
+ }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Loader,
3
3
  StaticAnalyzer,
4
- collectZoneModuleDocuments,
4
+ collectModuleDocuments,
5
5
  diagnosticFix,
6
6
  flattenForAnalyzer,
7
7
  remapMigratedPaths,
@@ -9,7 +9,7 @@ import {
9
9
  type DiagnosticData,
10
10
  type LoadedGraph,
11
11
  type ManifestSource,
12
- type ZoneModuleDocuments,
12
+ type ModuleDocuments,
13
13
  } from "@telorun/analyzer";
14
14
  import {
15
15
  Stream,
@@ -235,7 +235,7 @@ export class KernelRuntimeSeam implements RuntimeSeam {
235
235
  let parseDiagnostics: AnalysisDiagnostic[] = [];
236
236
  let versionDiagnostics: AnalysisDiagnostic[] = [];
237
237
  let migrationDiagnostics: AnalysisDiagnostic[] = [];
238
- let moduleDocuments: ZoneModuleDocuments[] = [];
238
+ let moduleDocuments: ModuleDocuments[] = [];
239
239
  // Carried out of the try for the same reason the diagnostics are: analysis
240
240
  // runs over the MIGRATED tree while every path a caller resolves points at
241
241
  // the raw file, so the driver's provenance record has to be in hand below.
@@ -257,7 +257,7 @@ export class KernelRuntimeSeam implements RuntimeSeam {
257
257
  manifests = flattenForAnalyzer(graph);
258
258
  // The zone stage derives each imported library's export contracts from
259
259
  // its own full documents, which the flattened list drops.
260
- moduleDocuments = collectZoneModuleDocuments(graph);
260
+ moduleDocuments = collectModuleDocuments(graph);
261
261
  } catch (err) {
262
262
  // A graph that would not load is an answer, not a failure of the call —
263
263
  // "this manifest does not load, and here is the reason" is precisely what