@telorun/kernel 0.75.0 → 0.76.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 (62) 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/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/kernel.d.ts +11 -1
  38. package/dist/kernel.d.ts.map +1 -1
  39. package/dist/kernel.js +37 -4
  40. package/dist/kernel.js.map +1 -1
  41. package/dist/manifest-sources/local-manifest-cache-source.d.ts +13 -2
  42. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  43. package/dist/manifest-sources/local-manifest-cache-source.js +19 -3
  44. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  45. package/dist/resource-context.d.ts +5 -0
  46. package/dist/resource-context.d.ts.map +1 -1
  47. package/dist/resource-context.js +6 -0
  48. package/dist/resource-context.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/bundle/module-artifact.ts +50 -20
  51. package/src/bundle/module-manifest.ts +14 -1
  52. package/src/controller-loader.ts +25 -7
  53. package/src/controller-loaders/bundle-loader.ts +326 -10
  54. package/src/controller-loaders/napi-loader.ts +8 -1
  55. package/src/controller-loaders/npm-loader.ts +73 -3
  56. package/src/controller-loaders/sibling-libraries.ts +171 -0
  57. package/src/controller-loaders/source-bundle-builder.ts +281 -24
  58. package/src/controllers/resource-definition/resource-definition-controller.ts +7 -1
  59. package/src/index.ts +1 -0
  60. package/src/kernel.ts +50 -11
  61. package/src/manifest-sources/local-manifest-cache-source.ts +20 -3
  62. package/src/resource-context.ts +8 -0
@@ -556,13 +556,83 @@ async function resolveKernelPackageRoot(name: string): Promise<string | null> {
556
556
  }
557
557
  }
558
558
 
559
+ /**
560
+ * Windows ships no executable named `npm`. npm, pnpm and every corepack shim
561
+ * are `.cmd` files: libuv's PATH search probes only `.com`/`.exe` so it never
562
+ * finds one, and Node has refused to spawn `.cmd`/`.bat` without a shell since
563
+ * CVE-2024-27980. The install therefore has to go through `cmd.exe`.
564
+ *
565
+ * Going through cmd.exe makes quoting OURS. Node builds the line as
566
+ * `cmd.exe /d /s /c "<file> <args joined by single spaces>"` and quotes
567
+ * nothing, so a `file:` install spec holding a space would be re-split into two
568
+ * arguments, and cmd's metacharacters (`& ^ | < > ( )`) would be interpreted
569
+ * before npm ever saw them — `^` is the one that matters here, since it is both
570
+ * cmd's escape character and legal in the semver ranges a spec carries. Under
571
+ * `/s` cmd strips the outer pair and takes the remainder verbatim, so quoting
572
+ * each token individually is what makes the line arrive intact.
573
+ *
574
+ * A literal `"` is rejected rather than escaped: it toggles cmd's quote state,
575
+ * the escape that restores it differs between cmd and the batch shim's own
576
+ * parser, and no install spec has any business carrying one. Residual cmd
577
+ * limitation: `%VAR%` still expands inside quotes and cannot be escaped on a
578
+ * command line (only in a batch file), so an install root under a directory
579
+ * whose name spells a defined environment variable is not reachable here. A
580
+ * lone `%` is left alone by cmd and is safe.
581
+ */
582
+ function quoteForCmd(token: string): string {
583
+ if (token.includes('"')) {
584
+ throw new Error(
585
+ `[telo] cannot pass '${token}' to '${PACKAGE_MANAGER}' on Windows: a literal '"' has no ` +
586
+ `portable escape through cmd.exe. Move the install root to a path without one.`,
587
+ );
588
+ }
589
+ return `"${token}"`;
590
+ }
591
+
592
+ /**
593
+ * The COMMAND is quoted only when it cannot be left bare, where every argument
594
+ * is quoted unconditionally. The asymmetry is not tidiness — quoting a bare
595
+ * command name breaks the batch shim it resolves to.
596
+ *
597
+ * `npm.cmd` locates the CLI it exists to launch relative to itself:
598
+ *
599
+ * SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
600
+ * SET "NPM_CLI_JS=%~dp0\node_modules\npm\bin\npm-cli.js"
601
+ *
602
+ * `%0` is the token as it appeared on the command line, and cmd substitutes the
603
+ * resolved script path only for a BARE one. Quoted, `%0` stays `"npm"`, so
604
+ * `%~dp0` — drive and path of a token carrying neither — expands against the
605
+ * current directory instead. The shim then looked for npm inside Telo's install
606
+ * root (`<root>/.telo/npm`), found no `node_modules/npm/bin/`, and both `SET`
607
+ * lines produced a MODULE_NOT_FOUND for a path that was never going to exist.
608
+ *
609
+ * A command that genuinely needs quoting is a path rather than a bare name
610
+ * (`TELO_PKG_MANAGER=C:\Program Files\nodejs\npm.cmd`), and there `%0` already
611
+ * carries a directory, so `%~dp0` is right whether or not it was quoted. Both
612
+ * cases are therefore correct, which is what makes this conditional rather than
613
+ * a preference.
614
+ */
615
+ const CMD_NEEDS_QUOTING = /[\s&^|<>()]/;
616
+
559
617
  async function runPackageManager(cwd: string, args: string[]): Promise<void> {
618
+ const viaCmd = process.platform === "win32";
560
619
  try {
561
- await execFileAsync(PACKAGE_MANAGER, args, { cwd, maxBuffer: 32 * 1024 * 1024, env: hostEnv() });
620
+ await execFileAsync(
621
+ viaCmd && CMD_NEEDS_QUOTING.test(PACKAGE_MANAGER)
622
+ ? quoteForCmd(PACKAGE_MANAGER)
623
+ : PACKAGE_MANAGER,
624
+ viaCmd ? args.map(quoteForCmd) : args,
625
+ { cwd, maxBuffer: 32 * 1024 * 1024, env: hostEnv(), shell: viaCmd },
626
+ );
562
627
  } catch (err: any) {
628
+ // Through a shell the binary always resolves — cmd.exe itself exists — so a
629
+ // missing package manager arrives as cmd's own 9009 plus "is not recognized
630
+ // as an internal or external command" on stderr, never as ENOENT. Matching
631
+ // only the direct-spawn shape reported that as a generic install failure and
632
+ // buried the one line saying what to install.
633
+ const said = `${err?.message ?? ""}\n${err?.stderr ?? ""}`;
563
634
  const isMissing =
564
- err?.code === "ENOENT" ||
565
- /not found|command not recognized/i.test(err?.message ?? "");
635
+ err?.code === "ENOENT" || err?.code === 9009 || /not found|not recognized/i.test(said);
566
636
  if (isMissing) {
567
637
  throw new Error(
568
638
  `[telo] '${PACKAGE_MANAGER}' not found on PATH. Telo's controller installer requires a ` +
@@ -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
  }