@telorun/kernel 0.75.0 → 0.77.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 (77) hide show
  1. package/dist/bundle/module-artifact.d.ts +21 -4
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +42 -17
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/bundle/module-manifest.d.ts +5 -1
  6. package/dist/bundle/module-manifest.d.ts.map +1 -1
  7. package/dist/bundle/module-manifest.js +5 -1
  8. package/dist/bundle/module-manifest.js.map +1 -1
  9. package/dist/controller-loader.d.ts +3 -2
  10. package/dist/controller-loader.d.ts.map +1 -1
  11. package/dist/controller-loader.js +10 -9
  12. package/dist/controller-loader.js.map +1 -1
  13. package/dist/controller-loaders/bundle-loader.d.ts +51 -4
  14. package/dist/controller-loaders/bundle-loader.d.ts.map +1 -1
  15. package/dist/controller-loaders/bundle-loader.js +260 -11
  16. package/dist/controller-loaders/bundle-loader.js.map +1 -1
  17. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  18. package/dist/controller-loaders/napi-loader.js +8 -1
  19. package/dist/controller-loaders/napi-loader.js.map +1 -1
  20. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  21. package/dist/controller-loaders/npm-loader.js +65 -3
  22. package/dist/controller-loaders/npm-loader.js.map +1 -1
  23. package/dist/controller-loaders/sibling-libraries.d.ts +93 -0
  24. package/dist/controller-loaders/sibling-libraries.d.ts.map +1 -0
  25. package/dist/controller-loaders/sibling-libraries.js +111 -0
  26. package/dist/controller-loaders/sibling-libraries.js.map +1 -0
  27. package/dist/controller-loaders/source-bundle-builder.d.ts +25 -2
  28. package/dist/controller-loaders/source-bundle-builder.d.ts.map +1 -1
  29. package/dist/controller-loaders/source-bundle-builder.js +232 -26
  30. package/dist/controller-loaders/source-bundle-builder.js.map +1 -1
  31. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  32. package/dist/controllers/resource-definition/resource-definition-controller.js +5 -1
  33. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  34. package/dist/host-versions.d.ts +23 -0
  35. package/dist/host-versions.d.ts.map +1 -0
  36. package/dist/host-versions.js +25 -0
  37. package/dist/host-versions.js.map +1 -0
  38. package/dist/index.d.ts +2 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/kernel.d.ts +11 -1
  43. package/dist/kernel.d.ts.map +1 -1
  44. package/dist/kernel.js +43 -5
  45. package/dist/kernel.js.map +1 -1
  46. package/dist/manifest-schemas.d.ts +1 -1
  47. package/dist/manifest-schemas.d.ts.map +1 -1
  48. package/dist/manifest-schemas.js +5 -3
  49. package/dist/manifest-schemas.js.map +1 -1
  50. package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -2
  51. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  52. package/dist/manifest-sources/local-manifest-cache-source.js +19 -3
  53. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  54. package/dist/resource-context.d.ts +5 -0
  55. package/dist/resource-context.d.ts.map +1 -1
  56. package/dist/resource-context.js +6 -0
  57. package/dist/resource-context.js.map +1 -1
  58. package/dist/runtime-seam.d.ts.map +1 -1
  59. package/dist/runtime-seam.js +2 -0
  60. package/dist/runtime-seam.js.map +1 -1
  61. package/package.json +3 -3
  62. package/src/bundle/module-artifact.ts +50 -20
  63. package/src/bundle/module-manifest.ts +14 -1
  64. package/src/controller-loader.ts +25 -7
  65. package/src/controller-loaders/bundle-loader.ts +326 -10
  66. package/src/controller-loaders/napi-loader.ts +8 -1
  67. package/src/controller-loaders/npm-loader.ts +73 -3
  68. package/src/controller-loaders/sibling-libraries.ts +171 -0
  69. package/src/controller-loaders/source-bundle-builder.ts +281 -24
  70. package/src/controllers/resource-definition/resource-definition-controller.ts +7 -1
  71. package/src/host-versions.ts +26 -0
  72. package/src/index.ts +2 -0
  73. package/src/kernel.ts +56 -12
  74. package/src/manifest-schemas.ts +8 -2
  75. package/src/manifest-sources/local-manifest-cache-source.ts +20 -3
  76. package/src/resource-context.ts +8 -0
  77. package/src/runtime-seam.ts +2 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The module-owned libraries a module's controller bundles import by bare
3
+ * specifier, resolved to the artifacts that carry them.
4
+ *
5
+ * A bundle imports `@telorun/kv-store`; the manifest declares the dependency as
6
+ * `KvStore: ../kv-store`; the target module's own `library:` block says that its
7
+ * `js` entry point is what `@telorun/kv-store` means. This type is the joined
8
+ * result — computed once per module during `kernel.load()`, where the import
9
+ * graph and every module's artifact are both in hand, and handed to the loader.
10
+ *
11
+ * It is deliberately **not** something the loader derives for itself. Resolution
12
+ * needs the import edges (which module is `KvStore`), the target's manifest (what
13
+ * specifier it declares) and the target's artifact (where its layer is); a loader
14
+ * sees one canonical base URI and none of those.
15
+ */
16
+
17
+ import type { ArtifactSelector, LoadedGraph } from "@telorun/analyzer";
18
+ import type { Logger } from "@telorun/sdk";
19
+ import type { ModuleArtifact } from "../bundle/module-artifact.js";
20
+ import type { OwnerManifest } from "../bundle/module-manifest.js";
21
+
22
+ export interface ResolvedSiblingLibrary {
23
+ /** The bare specifier a consumer's bundle imports this library by. */
24
+ readonly specifier: string;
25
+ /** The selector of the library candidate — matched against the consuming
26
+ * candidate's own, so a `js` bundle resolves the `js` entry point. */
27
+ readonly selector: ArtifactSelector;
28
+ /** Module-root-relative built entry point. */
29
+ readonly path: string;
30
+ /** Module-root-relative TypeScript source, when the target declares one and is
31
+ * a working copy. */
32
+ readonly localPath?: string;
33
+ /** The owning module's directory: where its layers extract to, or where it
34
+ * simply sits on disk. `undefined` for a module whose payload has no local
35
+ * home (a `memory://` manifest), which makes the specifier unresolvable and
36
+ * is reported as such rather than guessed at. */
37
+ readonly moduleDir: string | undefined;
38
+ /** The owning module's artifact, when it ships one. Absent for a working copy,
39
+ * which is what selects the build-from-source path. */
40
+ readonly artifact: ModuleArtifact | undefined;
41
+ /** Canonical source URL of the owning module — diagnostics, and the key the
42
+ * loader reports a version skew against. */
43
+ readonly moduleSource: string;
44
+ /** The owning module's declared version, for the skew report. */
45
+ readonly moduleVersion: string | undefined;
46
+ /** The owning module's OWN sibling libraries. Building a library entry from
47
+ * source is the same build as building a controller from source, so it needs
48
+ * the same externals — `kv-store-sql`'s bundle externalizes `@telorun/sql`,
49
+ * and building `@telorun/sql`'s entry point in turn needs sql's own. Held as
50
+ * a live reference and filled in a second pass, so an import cycle cannot
51
+ * make construction recurse. */
52
+ readonly libraries: SiblingLibraryMap;
53
+ }
54
+
55
+ /** Every sibling library one module's bundles may import, by specifier. */
56
+ export type SiblingLibraryMap = ReadonlyMap<string, ResolvedSiblingLibrary>;
57
+
58
+ /** What a controller loader is handed: the libraries of the module that declared
59
+ * the candidate being resolved. Empty for a module that imports none. */
60
+ export const NO_SIBLING_LIBRARIES: SiblingLibraryMap = new Map();
61
+
62
+ /** What the join needs from the kernel: the already-parsed owner manifests, the
63
+ * artifacts built from them, and where each module's files live. Passed in
64
+ * rather than reached for, so the join is a pure function of the graph and is
65
+ * testable without booting a kernel. */
66
+ export interface SiblingLibraryInputs {
67
+ /** Owner manifest per module source. Parsed once in `load()` and shared with
68
+ * artifact construction — re-reading here would parse every target's whole
69
+ * `telo.yaml` again, once per import edge, on the boot path. */
70
+ readonly ownerManifests: ReadonlyMap<string, OwnerManifest>;
71
+ readonly artifactFor: (source: string) => ModuleArtifact | undefined;
72
+ readonly directoryFor: (source: string) => string | undefined;
73
+ readonly log?: Logger;
74
+ }
75
+
76
+ /**
77
+ * Join the import graph to each target's declared library entry point, so a
78
+ * controller bundle's bare `@telorun/kv-store` resolves to the module that owns
79
+ * it instead of being copied into the bundle.
80
+ *
81
+ * Called from `kernel.load()` for the same reason the artifacts are built there:
82
+ * that is the only point where all three halves are in hand — which module an
83
+ * alias names (`importEdges`), what specifier that module declares (`library:` on
84
+ * its owner doc), and where its payload lives. A controller loader sees one
85
+ * canonical base URI and none of them. The join itself is pure, so it lives here
86
+ * beside the model rather than in the orchestrator.
87
+ *
88
+ * Keyed by canonical module source, so a definition's `metadata.source` finds the
89
+ * libraries of the module that DECLARED it — never the consumer's. An import edge
90
+ * declared in an `include:` partial is attributed to the module that owns the
91
+ * partial, since the resulting bundle is that module's.
92
+ *
93
+ * Version skew is reported, not prevented: two dependents pinning different
94
+ * versions of one library legitimately resolve two copies, and two module scopes.
95
+ * That is different code, so it is correct — but a shared-state seam that assumed
96
+ * one scope would break silently, which is what the warning is for.
97
+ */
98
+ export function buildSiblingLibraries(
99
+ graph: LoadedGraph,
100
+ inputs: SiblingLibraryInputs,
101
+ ): Map<string, SiblingLibraryMap> {
102
+ // Which module owns each loaded file, so an import declared in a partial is
103
+ // attributed to its module.
104
+ const ownerOf = new Map<string, string>();
105
+ for (const [, module] of graph.modules) {
106
+ ownerOf.set(module.owner.source, module.owner.source);
107
+ for (const partial of module.partials) ownerOf.set(partial.source, module.owner.source);
108
+ }
109
+
110
+ // Two passes, so an import cycle cannot make construction recurse: every module
111
+ // gets its (mutable) map first, and each entry then holds a live reference to
112
+ // its target's map rather than a copy built inline.
113
+ const maps = new Map<string, Map<string, ResolvedSiblingLibrary>>();
114
+ for (const [, module] of graph.modules) {
115
+ if (!maps.has(module.owner.source)) maps.set(module.owner.source, new Map());
116
+ }
117
+
118
+ /** The module first seen behind each specifier, and the specifiers already
119
+ * reported as skewed — so one shared library resolved at two versions is one
120
+ * warning, not one per consumer that imports it. */
121
+ const claimed = new Map<string, { source: string; version: string | undefined }>();
122
+ const reported = new Set<string>();
123
+
124
+ for (const [declaringFile, edges] of graph.importEdges) {
125
+ const map = maps.get(ownerOf.get(declaringFile) ?? declaringFile);
126
+ if (!map) continue;
127
+ for (const [, edge] of edges) {
128
+ const target = graph.modules.get(edge.targetSource);
129
+ if (!target) continue;
130
+ const owner = inputs.ownerManifests.get(target.owner.source);
131
+ if (!owner || owner.library.length === 0) continue;
132
+ const moduleDir = inputs.directoryFor(target.owner.source);
133
+ for (const candidate of owner.library) {
134
+ const previous = claimed.get(candidate.specifier);
135
+ if (!previous) {
136
+ claimed.set(candidate.specifier, {
137
+ source: target.owner.source,
138
+ version: owner.version,
139
+ });
140
+ } else if (previous.source !== target.owner.source && !reported.has(candidate.specifier)) {
141
+ reported.add(candidate.specifier);
142
+ inputs.log?.warn(
143
+ `shared module library '${candidate.specifier}' resolves to two modules in this ` +
144
+ `graph, so it runs as two module scopes. A seam that must share state across them ` +
145
+ `has to say so — the honest granularity is the import pin.`,
146
+ {
147
+ "telo.library.specifier": candidate.specifier,
148
+ "telo.library.source": target.owner.source,
149
+ "telo.library.version": owner.version ?? "",
150
+ "telo.library.other_source": previous.source,
151
+ "telo.library.other_version": previous.version ?? "",
152
+ },
153
+ );
154
+ }
155
+ map.set(candidate.specifier, {
156
+ specifier: candidate.specifier,
157
+ selector: candidate.selector,
158
+ path: candidate.path,
159
+ ...(candidate.localPath ? { localPath: candidate.localPath } : {}),
160
+ moduleDir,
161
+ artifact: inputs.artifactFor(target.owner.source),
162
+ moduleSource: target.owner.source,
163
+ moduleVersion: owner.version,
164
+ libraries: maps.get(target.owner.source) ?? new Map(),
165
+ });
166
+ }
167
+ }
168
+ }
169
+
170
+ return maps;
171
+ }
@@ -1,8 +1,11 @@
1
+ import { DEFAULT_MANIFEST_FILENAME, type LibraryCandidate } from "@telorun/analyzer";
1
2
  import { RuntimeError } from "@telorun/sdk";
3
+ import { existsSync, readFileSync } from "node:fs";
2
4
  import { createHash } from "node:crypto";
3
5
  import * as fs from "node:fs/promises";
4
6
  import * as path from "node:path";
5
7
 
8
+ import { readOwnerManifest } from "../bundle/module-manifest.js";
6
9
  import { ControllerEnvMissingError } from "./napi-loader.js";
7
10
  import { REALM_COLLAPSE_NAMES } from "./realm.js";
8
11
 
@@ -58,6 +61,30 @@ import { REALM_COLLAPSE_NAMES } from "./realm.js";
58
61
  * entry per entry point recording the inputs its last build read. */
59
62
  const CACHE_DIR = "controller-src";
60
63
 
64
+ /**
65
+ * A module-owned library this bundle must **not** inline: the bare specifier its
66
+ * sources import it by, and the tree that specifier's code lives in.
67
+ *
68
+ * Both halves are load-bearing. The specifier is what esbuild externalizes; the
69
+ * tree is what the post-build check tests the metafile against, because an import
70
+ * written some other way — a relative path into a sibling's sources, a subpath, a
71
+ * transitive dependency that reaches the same file — would sail past an externals
72
+ * list and silently restore the duplicated module scope this whole mechanism
73
+ * exists to remove.
74
+ *
75
+ * The tree is the directory of the library's **entry source**, not the module's
76
+ * own directory. A module directory holds its tests, and a test fixture module
77
+ * nested inside one is a different module whose bundle is its own; taking the
78
+ * whole directory would report every such fixture as an inlined sibling. What can
79
+ * actually be inlined is what the entry point reaches, which is what it sits in.
80
+ */
81
+ export interface SiblingLibrary {
82
+ readonly specifier: string;
83
+ /** Absolute directory of the library's entry source. Absent for a published
84
+ * sibling, which ships no sources for a consumer's build to reach. */
85
+ readonly sourceDir?: string;
86
+ }
87
+
61
88
  /**
62
89
  * The esbuild options a controller bundle is built with. They must match the
63
90
  * flags each module's `build` script passes, because a bundle a contributor runs
@@ -65,7 +92,10 @@ const CACHE_DIR = "controller-src";
65
92
  *
66
93
  * The realm names stay external because the bundle loader symlinks them to the
67
94
  * kernel's own copy at load time. Inlining them would duplicate the runtime and
68
- * break the constructor identity `Stream` / `InvokeError` depend on.
95
+ * break the constructor identity `Stream` / `InvokeError` depend on. A sibling
96
+ * module's declared specifier is external for the same reason one step out: the
97
+ * loader resolves it to that module's own library layer, so every consumer —
98
+ * and the owning module's own controllers — share one module scope.
69
99
  *
70
100
  * The banner defines `require` in module scope so esbuild's `__require` shim —
71
101
  * emitted for `require(...)` calls inside a bundled CJS dependency — falls
@@ -99,18 +129,31 @@ const CONTROLLER_BUNDLE_OPTIONS = {
99
129
  } as const;
100
130
 
101
131
  /**
102
- * Fingerprint of the options above, folded into every cache key.
132
+ * Fingerprint of the options above **and this module's externals**, folded into
133
+ * every cache key.
103
134
  *
104
135
  * The output is a function of the inputs *and* how they were built, so a change
105
136
  * to the option set has to invalidate the cache the same way an edited source
106
137
  * does — otherwise a kernel upgrade that changes the banner or the externals
107
138
  * keeps serving bundles built the old way, which is the exact silent-stale-copy
108
- * failure the content-addressing exists to prevent.
139
+ * failure the content-addressing exists to prevent. The externals now vary per
140
+ * module, and they decide whether a library is inlined or resolved at load, so
141
+ * they belong in the key for exactly the same reason the banner does.
109
142
  */
110
- const OPTIONS_FINGERPRINT = createHash("sha256")
111
- .update(JSON.stringify(CONTROLLER_BUNDLE_OPTIONS))
112
- .digest("hex")
113
- .slice(0, 8);
143
+ function optionsFingerprint(externals: readonly string[]): string {
144
+ return createHash("sha256")
145
+ .update(JSON.stringify(CONTROLLER_BUNDLE_OPTIONS))
146
+ .update("\n")
147
+ .update(JSON.stringify([...externals].sort()))
148
+ .digest("hex")
149
+ .slice(0, 8);
150
+ }
151
+
152
+ /** The bare specifiers one build externalizes: the realm names plus every
153
+ * sibling library, sorted so the fingerprint is order-independent. */
154
+ function externalSpecifiers(libraries: readonly SiblingLibrary[]): string[] {
155
+ return [...REALM_COLLAPSE_NAMES, ...libraries.map((l) => l.specifier)];
156
+ }
114
157
 
115
158
  interface BuildIndexEntry {
116
159
  /** Absolute paths of every file the last build read, from esbuild's metafile. */
@@ -166,7 +209,7 @@ async function pathExists(p: string): Promise<boolean> {
166
209
  * change: the caller rebuilds rather than trusting a signature computed over a
167
210
  * file set that no longer exists.
168
211
  */
169
- async function signInputs(inputs: string[]): Promise<string | null> {
212
+ async function signInputs(inputs: string[], externals: readonly string[]): Promise<string | null> {
170
213
  const stats = await Promise.all(
171
214
  inputs.map(async (file) => {
172
215
  try {
@@ -179,7 +222,7 @@ async function signInputs(inputs: string[]): Promise<string | null> {
179
222
  );
180
223
  if (stats.some((entry) => entry === null)) return null;
181
224
  return createHash("sha256")
182
- .update(OPTIONS_FINGERPRINT)
225
+ .update(optionsFingerprint(externals))
183
226
  .update("\n")
184
227
  .update(stats.join("\n"))
185
228
  .digest("hex")
@@ -191,8 +234,23 @@ function indexPath(cacheDir: string, entryFile: string): string {
191
234
  return path.join(cacheDir, `${id}.index.json`);
192
235
  }
193
236
 
237
+ /**
238
+ * Each build gets its **own directory**, not just its own filename.
239
+ *
240
+ * The bundle loader writes a `node_modules/` beside a bundle to make bare
241
+ * specifiers resolve — the realm names, and one shim per sibling library. Those
242
+ * are per bundle: two modules can legitimately resolve different versions of one
243
+ * library, and with every dev build sharing one flat cache directory the second
244
+ * would overwrite the first's shim and silently hand it the wrong copy. A
245
+ * directory per content-addressed key makes that unrepresentable, and costs an
246
+ * inode.
247
+ */
248
+ function bundleDir(cacheDir: string, key: string): string {
249
+ return path.join(cacheDir, key);
250
+ }
251
+
194
252
  function bundlePath(cacheDir: string, key: string): string {
195
- return path.join(cacheDir, `${key}.mjs`);
253
+ return path.join(bundleDir(cacheDir, key), "bundle.mjs");
196
254
  }
197
255
 
198
256
  /**
@@ -236,11 +294,13 @@ let tmpCounter = 0;
236
294
  export async function buildControllerFromSource(
237
295
  entryFile: string,
238
296
  cacheRoot: string,
297
+ libraries: readonly SiblingLibrary[] = [],
239
298
  ): Promise<string> {
240
299
  const cacheDir = path.join(cacheRoot, CACHE_DIR);
300
+ const externals = externalSpecifiers(libraries);
241
301
  const index = await readIndex(indexPath(cacheDir, entryFile));
242
302
  if (index) {
243
- const key = await signInputs(index.inputs);
303
+ const key = await signInputs(index.inputs, externals);
244
304
  if (key === index.key) {
245
305
  const cached = bundlePath(cacheDir, key);
246
306
  if (await pathExists(cached)) return cached;
@@ -249,7 +309,9 @@ export async function buildControllerFromSource(
249
309
 
250
310
  const inFlight = buildsInFlight.get(entryFile);
251
311
  if (inFlight) return inFlight;
252
- const work = build(entryFile, cacheDir).finally(() => buildsInFlight.delete(entryFile));
312
+ const work = build(entryFile, cacheDir, libraries).finally(() =>
313
+ buildsInFlight.delete(entryFile),
314
+ );
253
315
  buildsInFlight.set(entryFile, work);
254
316
  return work;
255
317
  }
@@ -272,12 +334,201 @@ export async function buildControllerFromSource(
272
334
  export async function buildControllerBundle(
273
335
  entryFile: string,
274
336
  cacheRoot: string,
337
+ libraries: readonly SiblingLibrary[] = [],
275
338
  ): Promise<{ path: string; inputs: string[] }> {
276
- const path = await buildControllerFromSource(entryFile, cacheRoot);
339
+ const path = await buildControllerFromSource(entryFile, cacheRoot, libraries);
277
340
  return { path, inputs: await lastBuildInputs(entryFile, cacheRoot) };
278
341
  }
279
342
 
280
- async function build(entryFile: string, cacheDir: string): Promise<string> {
343
+ /**
344
+ * Refuse a **subpath** of an externalized specifier.
345
+ *
346
+ * A module's library surface is one specifier and one entry point — subpaths are
347
+ * deliberately not representable, since reproducing npm's `exports` map inside
348
+ * the artifact would pull a package manager's resolution semantics into Telo. Left
349
+ * alone, `@telorun/ai/content` matches no external, so esbuild inlines it and the
350
+ * duplicated scope comes back silently. Marking `<specifier>/*` external instead
351
+ * would trade that for a module-not-found on someone else's machine, so the
352
+ * honest place to fail is the build.
353
+ */
354
+ function rejectSubpathImports(libraries: readonly SiblingLibrary[]): import("esbuild").Plugin {
355
+ return {
356
+ name: "telo-library-subpath",
357
+ setup(build) {
358
+ for (const library of libraries) {
359
+ const prefix = `${library.specifier}/`;
360
+ build.onResolve({ filter: new RegExp(`^${escapeRegExp(prefix)}`) }, (args) => ({
361
+ errors: [
362
+ {
363
+ text:
364
+ `'${args.path}' imports a subpath of the module library '${library.specifier}'. ` +
365
+ `A module exposes one entry point, so import '${library.specifier}' itself.`,
366
+ },
367
+ ],
368
+ }));
369
+ }
370
+ },
371
+ };
372
+ }
373
+
374
+ function escapeRegExp(value: string): string {
375
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
376
+ }
377
+
378
+ /**
379
+ * Refuse a bundle that reached into a sibling module's source tree by any route
380
+ * other than its declared specifier.
381
+ *
382
+ * The externals list only governs what the *entry's own* imports resolve to. A
383
+ * relative path into a sibling's `src/`, or a transitive hop through a third
384
+ * package, lands the sibling's source in this bundle regardless — one more copy
385
+ * of a module scope, and nothing else would ever report it. The metafile is the
386
+ * only place this is visible, so it is checked here, on both the load path and
387
+ * the publish path, which share this builder.
388
+ */
389
+ function assertNoInlinedSiblings(
390
+ entryFile: string,
391
+ inputs: string[],
392
+ libraries: readonly SiblingLibrary[],
393
+ ): void {
394
+ assertNoUndeclaredSiblings(entryFile, inputs, libraries);
395
+ const offenders = new Map<string, string[]>();
396
+ for (const input of inputs) {
397
+ for (const library of libraries) {
398
+ if (!library.sourceDir) continue;
399
+ const root = library.sourceDir.endsWith(path.sep)
400
+ ? library.sourceDir
401
+ : library.sourceDir + path.sep;
402
+ if (!input.startsWith(root)) continue;
403
+ const seen = offenders.get(library.specifier) ?? [];
404
+ seen.push(input);
405
+ offenders.set(library.specifier, seen);
406
+ }
407
+ }
408
+ if (offenders.size === 0) return;
409
+ const detail = [...offenders]
410
+ .map(([specifier, files]) => ` ${specifier}: ${files.slice(0, 5).join(", ")}`)
411
+ .join("\n");
412
+ throw new RuntimeError(
413
+ "ERR_CONTROLLER_BUILD_FAILED",
414
+ `Controller bundle "${entryFile}" inlines source from a module it imports:\n${detail}\n` +
415
+ `A module-owned library is resolved at load through the import graph, so its module scope ` +
416
+ `is shared. Import it by its declared specifier instead of reaching into its files.`,
417
+ );
418
+ }
419
+
420
+ /**
421
+ * Refuse a bundle that inlined a module-owned library it never declared an import
422
+ * for.
423
+ *
424
+ * The check above is derived from the `imports:` edges, so it is vacuous exactly
425
+ * where the mistake is made: a module whose TypeScript imports `@telorun/sql`
426
+ * while its manifest never says `Sql: ../sql`. Nothing externalizes the specifier,
427
+ * the package manager resolves it, esbuild inlines it, and the duplicated module
428
+ * scope this whole mechanism removes comes back with nothing to report it. The
429
+ * analyzer cannot see TypeScript sources; the metafile is the only place this is
430
+ * decidable, so it is decided here — on the run path and the publish path alike,
431
+ * since both go through this builder.
432
+ *
433
+ * Detection needs no workspace registry: an input that is neither under this
434
+ * module's own root nor inside a `node_modules` tree belongs to *some* other
435
+ * module, and the nearest enclosing `telo.yaml` says which. A third-party
436
+ * dependency always resolves inside a `node_modules` directory, so the probe skips
437
+ * the overwhelming majority of inputs without a filesystem walk.
438
+ */
439
+ function assertNoUndeclaredSiblings(
440
+ entryFile: string,
441
+ inputs: string[],
442
+ libraries: readonly SiblingLibrary[],
443
+ ): void {
444
+ const declared = new Set(libraries.map((library) => library.specifier));
445
+ const ownRoot = nearestModuleRoot(path.dirname(entryFile));
446
+ const offenders = new Map<string, { root: string; files: string[] }>();
447
+
448
+ for (const input of inputs) {
449
+ if (input.includes(`${path.sep}node_modules${path.sep}`)) continue;
450
+ if (ownRoot && isUnder(input, ownRoot)) continue;
451
+ const root = nearestModuleRoot(path.dirname(input));
452
+ if (!root || root === ownRoot) continue;
453
+ for (const candidate of libraryCandidatesOf(root)) {
454
+ // A declared specifier is the other check's business — it reports the same
455
+ // file with the instruction that fits (import it properly, not add it).
456
+ if (declared.has(candidate.specifier) || !candidate.localPath) continue;
457
+ if (!isUnder(input, path.dirname(path.resolve(root, candidate.localPath)))) continue;
458
+ const seen = offenders.get(candidate.specifier) ?? { root, files: [] };
459
+ seen.files.push(input);
460
+ offenders.set(candidate.specifier, seen);
461
+ }
462
+ }
463
+ if (offenders.size === 0) return;
464
+
465
+ const detail = [...offenders]
466
+ .map(
467
+ ([specifier, { root, files }]) =>
468
+ ` ${specifier} (${root}): ${files.slice(0, 5).join(", ")}`,
469
+ )
470
+ .join("\n");
471
+ throw new RuntimeError(
472
+ "ERR_CONTROLLER_BUILD_FAILED",
473
+ `Controller bundle "${entryFile}" inlines a module-owned library it does not import:\n` +
474
+ `${detail}\n` +
475
+ `Declare that module in this one's \`imports:\` — the kernel then resolves the specifier ` +
476
+ `to its own entry point at load, so the library is one module scope. Undeclared, it is ` +
477
+ `copied into this bundle and any state it keeps becomes a second copy.`,
478
+ );
479
+ }
480
+
481
+ function isUnder(file: string, dir: string): boolean {
482
+ const root = dir.endsWith(path.sep) ? dir : dir + path.sep;
483
+ return file.startsWith(root);
484
+ }
485
+
486
+ /** Nearest ancestor directory holding a `telo.yaml` — the module a file belongs
487
+ * to. Memoized per directory: a bundle's inputs cluster into a handful of trees,
488
+ * and the walk is otherwise repeated per file. */
489
+ const moduleRoots = new Map<string, string | undefined>();
490
+ function nearestModuleRoot(from: string): string | undefined {
491
+ const cached = moduleRoots.get(from);
492
+ if (cached !== undefined || moduleRoots.has(from)) return cached;
493
+ let dir = from;
494
+ for (;;) {
495
+ if (existsSync(path.join(dir, DEFAULT_MANIFEST_FILENAME))) {
496
+ moduleRoots.set(from, dir);
497
+ return dir;
498
+ }
499
+ const parent = path.dirname(dir);
500
+ if (parent === dir) {
501
+ moduleRoots.set(from, undefined);
502
+ return undefined;
503
+ }
504
+ dir = parent;
505
+ }
506
+ }
507
+
508
+ /** The `library:` candidates a module declares, read once per module root. An
509
+ * unreadable or malformed manifest contributes none: this check exists to report
510
+ * an inlined library, and the analyzer is what reports a broken manifest. */
511
+ const moduleLibraries = new Map<string, LibraryCandidate[]>();
512
+ function libraryCandidatesOf(root: string): LibraryCandidate[] {
513
+ const cached = moduleLibraries.get(root);
514
+ if (cached) return cached;
515
+ let candidates: LibraryCandidate[] = [];
516
+ try {
517
+ candidates = readOwnerManifest(
518
+ readFileSync(path.join(root, DEFAULT_MANIFEST_FILENAME), "utf8"),
519
+ ).library;
520
+ } catch {
521
+ candidates = [];
522
+ }
523
+ moduleLibraries.set(root, candidates);
524
+ return candidates;
525
+ }
526
+
527
+ async function build(
528
+ entryFile: string,
529
+ cacheDir: string,
530
+ libraries: readonly SiblingLibrary[],
531
+ ): Promise<string> {
281
532
  const esbuild = await loadEsbuild();
282
533
  if (!esbuild) {
283
534
  // Explicit rather than a silent fallthrough: esbuild is an *optional*
@@ -291,14 +542,16 @@ async function build(entryFile: string, cacheDir: string): Promise<string> {
291
542
  );
292
543
  }
293
544
 
545
+ const externals = externalSpecifiers(libraries);
294
546
  let built: import("esbuild").BuildResult<{ write: false; metafile: true }>;
295
547
  try {
296
548
  built = await esbuild.build({
297
549
  ...CONTROLLER_BUNDLE_OPTIONS,
298
550
  // esbuild's options are mutable arrays; the shared constant is `as const`
299
551
  // so it cannot be edited in place by one caller and read by another.
300
- external: [...CONTROLLER_BUNDLE_OPTIONS.external],
552
+ external: externals,
301
553
  conditions: [...CONTROLLER_BUNDLE_OPTIONS.conditions],
554
+ plugins: [rejectSubpathImports(libraries)],
302
555
  entryPoints: [entryFile],
303
556
  write: false,
304
557
  metafile: true,
@@ -323,13 +576,14 @@ async function build(entryFile: string, cacheDir: string): Promise<string> {
323
576
  // Absolute, so the signature is independent of the working directory the next
324
577
  // kernel happens to run from.
325
578
  const inputs = Object.keys(built.metafile.inputs).map((rel) => path.resolve(rel));
326
- const key = (await signInputs(inputs)) ?? createHash("sha256")
579
+ assertNoInlinedSiblings(entryFile, inputs, libraries);
580
+ const key = (await signInputs(inputs, externals)) ?? createHash("sha256")
327
581
  .update(output.text)
328
582
  .digest("hex")
329
583
  .slice(0, 32);
330
584
  const target = bundlePath(cacheDir, key);
331
585
 
332
- await fs.mkdir(cacheDir, { recursive: true });
586
+ await fs.mkdir(path.dirname(target), { recursive: true });
333
587
  const index = indexPath(cacheDir, entryFile);
334
588
  const superseded = (await readIndex(index))?.key;
335
589
  const tmp = `${target}.${process.pid}.${tmpCounter++}.tmp`;
@@ -347,14 +601,17 @@ async function build(entryFile: string, cacheDir: string): Promise<string> {
347
601
  /**
348
602
  * Drop the bundle this build replaced.
349
603
  *
350
- * Every save mints a new key, so without this a day of editing leaves one `.mjs`
351
- * per save and the cache grows for the life of the checkout. Pruned *after* the
352
- * new index is in place, so a concurrent reader is already being pointed at the
353
- * replacement; on Linux a process that opened the old file keeps reading it
354
- * through the open handle, and on Windows a failed unlink is swallowed — a stale
355
- * file costs disk, never correctness.
604
+ * Every save mints a new key, so without this a day of editing leaves one bundle
605
+ * directory per save and the cache grows for the life of the checkout. Pruned
606
+ * *after* the new index is in place, so a concurrent reader is already being
607
+ * pointed at the replacement; on Linux a process that opened the old file keeps
608
+ * reading it through the open handle, and on Windows a failed unlink is swallowed
609
+ * — a stale file costs disk, never correctness.
610
+ *
611
+ * The whole directory goes, since it holds the bundle's generated `node_modules/`
612
+ * as well as the bundle.
356
613
  */
357
614
  async function prune(cacheDir: string, superseded: string | undefined, current: string): Promise<void> {
358
615
  if (!superseded || superseded === current) return;
359
- await fs.rm(bundlePath(cacheDir, superseded), { force: true }).catch(() => {});
616
+ await fs.rm(bundleDir(cacheDir, superseded), { force: true, recursive: true }).catch(() => {});
360
617
  }
@@ -15,6 +15,7 @@ import {
15
15
  type DefResolver,
16
16
  } from "@telorun/analyzer";
17
17
  import type { ModuleArtifact } from "../../bundle/module-artifact.js";
18
+ import type { SiblingLibraryMap } from "../../controller-loaders/sibling-libraries.js";
18
19
  import { ControllerLoader } from "../../controller-loader.js";
19
20
  import { formatAjvErrors, validateResourceDefinition } from "../../manifest-schemas.js";
20
21
  import { createTemplateController } from "./resource-template-controller.js";
@@ -166,6 +167,10 @@ class ResourceDefinition implements ResourceInstance {
166
167
  // ref and the verified layer index, so the loader picks a candidate and asks
167
168
  // it for that selector's directory rather than fetching anything itself.
168
169
  const artifact = host.getModuleArtifact?.(this.resource.metadata.source);
170
+ // The libraries of the module that declared this kind, for the same reason:
171
+ // a bundle's bare `@telorun/kv-store` means whatever THAT module's imports
172
+ // say it means, never the consumer's.
173
+ const libraries = host.getSiblingLibraries?.(this.resource.metadata.source);
169
174
  ctx.registerDefinition(this.resource);
170
175
 
171
176
  const moduleName = this.resource.metadata.module;
@@ -187,7 +192,7 @@ class ResourceDefinition implements ResourceInstance {
187
192
  kindName,
188
193
  async () => {
189
194
  const resolved = await loader
190
- .resolve(controllers, source, policy, artifact)
195
+ .resolve(controllers, source, policy, artifact, libraries)
191
196
  .catch((err) => {
192
197
  if (err instanceof RuntimeError) {
193
198
  throw new RuntimeError(err.code, `kind '${moduleName}.${kindName}': ${err.message}`);
@@ -229,6 +234,7 @@ class ResourceDefinition implements ResourceInstance {
229
234
  */
230
235
  interface KernelResourceContext {
231
236
  getModuleArtifact?(source: string | undefined): ModuleArtifact | undefined;
237
+ getSiblingLibraries?(source: string | undefined): SiblingLibraryMap | undefined;
232
238
  getCacheRoot?(): string | undefined;
233
239
  registerLazyController(
234
240
  moduleName: string,
@@ -0,0 +1,26 @@
1
+ import type { HostVersions } from "@telorun/analyzer";
2
+
3
+ /**
4
+ * The versions this Node host can speak for, for a module's `requires.host.*`
5
+ * ranges.
6
+ *
7
+ * **One supplier, so the sites cannot drift.** Every Node-side entry into
8
+ * `StaticAnalyzer.analyze` — the kernel's load path, its `check` seam and the
9
+ * CLI's `check` command — must report the same host, or the same manifest would
10
+ * be accepted by one and rejected by another. Reading `process.versions` at each
11
+ * call site is three chances to forget one, and forgetting is silent: the axis is
12
+ * simply skipped and the requirement quietly stops being enforced, which is the
13
+ * failure class declared requirements exist to remove.
14
+ *
15
+ * It lives in the kernel rather than the analyzer because the analyzer is
16
+ * browser-safe and has no `process`. That split is the point: the analyzer owns
17
+ * reading and ordering the axes, and only the comparison target is per-runtime.
18
+ *
19
+ * `process.versions.node` is absent on no Node or Bun release this runs on, but
20
+ * an absent entry is skipped rather than guessed, so a host that cannot name
21
+ * itself declines to enforce rather than inventing a verdict.
22
+ */
23
+ export function nodeHostVersions(): HostVersions {
24
+ const node = process.versions?.node;
25
+ return typeof node === "string" && node ? { node } : {};
26
+ }
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export {
39
39
  buildControllerBundle,
40
40
  canBuildFromSource,
41
41
  lastBuildInputs,
42
+ type SiblingLibrary,
42
43
  } from "./controller-loaders/source-bundle-builder.js";
43
44
  export type {
44
45
  PayloadLayer,
@@ -110,3 +111,4 @@ export type {
110
111
  } from "./logging/index.js";
111
112
  export { KernelLogging } from "./logging/kernel-logging.js";
112
113
  export type { LoggingHost } from "./logging/logging-host.js";
114
+ export { nodeHostVersions } from "./host-versions.js";