@telorun/kernel 0.79.0 → 0.81.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 (53) hide show
  1. package/dist/bundle/module-artifact.d.ts +13 -1
  2. package/dist/bundle/module-artifact.d.ts.map +1 -1
  3. package/dist/bundle/module-artifact.js +22 -3
  4. package/dist/bundle/module-artifact.js.map +1 -1
  5. package/dist/controller-loader.js +1 -1
  6. package/dist/controller-loader.js.map +1 -1
  7. package/dist/controller-loaders/napi-loader.d.ts +20 -0
  8. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  9. package/dist/controller-loaders/napi-loader.js +31 -4
  10. package/dist/controller-loaders/napi-loader.js.map +1 -1
  11. package/dist/controller-loaders/npm-install-root.d.ts +56 -0
  12. package/dist/controller-loaders/npm-install-root.d.ts.map +1 -0
  13. package/dist/controller-loaders/npm-install-root.js +109 -0
  14. package/dist/controller-loaders/npm-install-root.js.map +1 -0
  15. package/dist/controller-loaders/npm-loader.d.ts +3 -0
  16. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  17. package/dist/controller-loaders/npm-loader.js +79 -5
  18. package/dist/controller-loaders/npm-loader.js.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/kernel.d.ts +6 -2
  24. package/dist/kernel.d.ts.map +1 -1
  25. package/dist/kernel.js +24 -13
  26. package/dist/kernel.js.map +1 -1
  27. package/dist/manifest-sources/analysis-stamp.d.ts +12 -8
  28. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -1
  29. package/dist/manifest-sources/analysis-stamp.js +50 -23
  30. package/dist/manifest-sources/analysis-stamp.js.map +1 -1
  31. package/dist/manifest-sources/local-manifest-cache-source.d.ts +45 -9
  32. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  33. package/dist/manifest-sources/local-manifest-cache-source.js +65 -12
  34. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  35. package/dist/runtime-seam.d.ts.map +1 -1
  36. package/dist/runtime-seam.js +9 -1
  37. package/dist/runtime-seam.js.map +1 -1
  38. package/dist/workspace-marker.d.ts +37 -0
  39. package/dist/workspace-marker.d.ts.map +1 -0
  40. package/dist/workspace-marker.js +68 -0
  41. package/dist/workspace-marker.js.map +1 -0
  42. package/package.json +4 -3
  43. package/src/bundle/module-artifact.ts +22 -7
  44. package/src/controller-loader.ts +1 -1
  45. package/src/controller-loaders/napi-loader.ts +36 -3
  46. package/src/controller-loaders/npm-install-root.ts +131 -0
  47. package/src/controller-loaders/npm-loader.ts +84 -5
  48. package/src/index.ts +1 -0
  49. package/src/kernel.ts +24 -14
  50. package/src/manifest-sources/analysis-stamp.ts +53 -25
  51. package/src/manifest-sources/local-manifest-cache-source.ts +68 -13
  52. package/src/runtime-seam.ts +9 -1
  53. package/src/workspace-marker.ts +68 -0
package/src/kernel.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  } from "./manifest-sources/analysis-stamp.js";
66
66
  import {
67
67
  cachePathForCanonical,
68
+ legacyManifestsDirFallback,
68
69
  resolveCacheRoot,
69
70
  resolveEntryDir,
70
71
  } from "./manifest-sources/local-manifest-cache-source.js";
@@ -421,19 +422,20 @@ export class Kernel implements IKernel {
421
422
  options?.cacheDir !== undefined ? options.cacheDir : resolveCacheRoot(sourceUrl);
422
423
  this._cacheRoot = cacheRoot;
423
424
  const manifestsDir = cacheRoot ? `${cacheRoot}/manifests` : undefined;
425
+ const analysisDir = cacheRoot ? `${cacheRoot}/analysis` : undefined;
424
426
  // `writeCache: false` (`telo run --no-cache-write`) keeps the cache
425
427
  // READ-only: compiled validators and the analysis stamp are still loaded
426
428
  // from disk, but never written back — so an ephemeral, read-only session
427
429
  // rootfs validates in-memory without touching the baked cache.
428
430
  const writeCache = options?.writeCache !== false;
429
431
  // Point the shared schema validator at the cache so compiled AJV validators
430
- // are loaded (and, when writable, persisted) under
431
- // `<cache-root>/manifests/__validators/`. Memory-/HTTP-rooted entries skip
432
- // the cache; their schema compiles stay in-process only.
433
- this.sharedSchemaValidator.setCacheDir(
434
- manifestsDir ? `${manifestsDir}/__validators` : undefined,
435
- { write: writeCache },
436
- );
432
+ // are loaded (and, when writable, persisted) under `<cache-root>/validators/`.
433
+ // Beside `manifests/` rather than inside it: a compiled validator is not a
434
+ // cached module manifest. Memory-/HTTP-rooted entries skip the cache; their
435
+ // schema compiles stay in-process only.
436
+ this.sharedSchemaValidator.setCacheDir(cacheRoot ? `${cacheRoot}/validators` : undefined, {
437
+ write: writeCache,
438
+ });
437
439
  this.rootContext = new ModuleContext(
438
440
  sourceUrl,
439
441
  {},
@@ -551,9 +553,7 @@ export class Kernel implements IKernel {
551
553
  // passes are elided. Memory- / HTTP-rooted entries have no
552
554
  // local stamp store and always re-validate.
553
555
  const analysisSignature = computeAnalysisSignature(analysisGraph);
554
- const stamp = manifestsDir
555
- ? await readAnalysisStamp("", manifestsDir)
556
- : undefined;
556
+ const stamp = analysisDir ? await readAnalysisStamp(sourceUrl, analysisDir) : undefined;
557
557
  const skipValidation = stamp?.signature === analysisSignature;
558
558
  const errors = this.analyzer.analyzeErrors(
559
559
  staticManifests,
@@ -574,13 +574,13 @@ export class Kernel implements IKernel {
574
574
  errors.map(staticDiagnosticToRuntime),
575
575
  );
576
576
  }
577
- if (manifestsDir && writeCache && !skipValidation) {
577
+ if (analysisDir && writeCache && !skipValidation) {
578
578
  // Best-effort: stamp the verdict so subsequent loads hit the fast
579
579
  // path. A read-only filesystem (baked Docker image) reports the
580
580
  // failure on stderr and keeps running — the lookup above will
581
581
  // simply miss next time. Skipped under `--no-cache-write`.
582
582
  try {
583
- await writeAnalysisStamp("", analysisSignature, manifestsDir);
583
+ await writeAnalysisStamp(sourceUrl, analysisSignature, analysisDir);
584
584
  } catch (err) {
585
585
  this.logging.kernelLogger().warn("analysis stamp write failed", undefined, { error: err });
586
586
  }
@@ -1017,8 +1017,12 @@ export class Kernel implements IKernel {
1017
1017
  return this._entryUrl;
1018
1018
  }
1019
1019
 
1020
- /** The npm install root for this load (`<cache-root>/npm`), threaded to the
1021
- * controller loader so it doesn't re-derive it from the entry URL. */
1020
+ /** The npm install BASE for this load (`<cache-root>/npm`), threaded to the
1021
+ * controller loader so it doesn't re-derive it from the entry URL. The base
1022
+ * holds one root per (entry path, host platform); the loader picks which one
1023
+ * this runner uses, so two runners over one workspace — a host and a
1024
+ * bind-mounting container — never share a tree whose `package.json` can only
1025
+ * be true for one of them. */
1022
1026
  getInstallRoot(): string | undefined {
1023
1027
  return this._cacheRoot ? `${this._cacheRoot}/npm` : undefined;
1024
1028
  }
@@ -1060,6 +1064,11 @@ export class Kernel implements IKernel {
1060
1064
  this.siblingLibraries.clear();
1061
1065
  const transports = defaultTransportRegistry(this.registryUrl);
1062
1066
  const entryDir = this._entryUrl ? resolveEntryDir(this._entryUrl) ?? "" : "";
1067
+ // The same pre-anchor root `LocalManifestCacheSource` falls back to. Layers
1068
+ // live beside the cached manifest, so both halves have to look in the same
1069
+ // place or an offline upgrade resolves a manifest from disk and then fetches
1070
+ // its controllers.
1071
+ const legacyDir = legacyManifestsDirFallback(entryDir, manifestsDir ?? null);
1063
1072
  // Parsed once per module and shared with the sibling-library join below: a
1064
1073
  // `telo.yaml` is a large multi-document file, and re-reading each target's
1065
1074
  // once per import edge put dozens of redundant full-YAML parses on the boot
@@ -1077,6 +1086,7 @@ export class Kernel implements IKernel {
1077
1086
  entryDir,
1078
1087
  this.registryUrl,
1079
1088
  manifestsDir,
1089
+ legacyDir,
1080
1090
  );
1081
1091
  directories.set(file.source, moduleDir ?? undefined);
1082
1092
  const artifact = moduleArtifactFor({
@@ -7,19 +7,30 @@ import * as path from "path";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
9
  /**
10
- * Hash-keyed analysis cache: a tiny JSON sidecar in `.telo/manifests/`
10
+ * Hash-keyed analysis cache: a tiny JSON sidecar under `.telo/analysis/`
11
11
  * recording that an exact set of manifest bytes — under specific
12
12
  * `@telorun/kernel` and `@telorun/analyzer` package versions — passed
13
13
  * `analyzer.analyzeErrors`. The next `kernel.load` reads the sidecar
14
14
  * and, if signatures match, skips the per-resource validation walk.
15
15
  *
16
- * Lives next to the manifest cache (`LocalManifestCacheSource`) but is
17
- * independent of it splitting both for grep-ability and because the
18
- * concerns (URL file content vs. content analyzer verdict) are
19
- * orthogonal.
16
+ * ONE STAMP PER ENTRY, in a directory keyed by a hash of the entry URL.
17
+ * A single file holding a single signature was per-app only because every app
18
+ * used to get its own `.telo` beside its manifest; once the cache root is shared
19
+ * across a workspace, one file means each app overwrites the last — A stamps, B
20
+ * misses and overwrites, forever. That is a permanent 100% miss with no error to
21
+ * show for it, worst in the test suite, where a kernel is spawned per manifest
22
+ * and the cache matters most.
23
+ *
24
+ * A DIRECTORY rather than several records in one file, because two kernels
25
+ * loading different manifests concurrently would otherwise read-modify-write the
26
+ * same JSON and lose each other's entry — and concurrent loads are the normal
27
+ * case, not the exception.
28
+ *
29
+ * NOT under `manifests/`, which holds cached module manifests keyed by transport.
30
+ * A verdict about an entry is not a manifest; filing it there made the directory
31
+ * mean two things.
20
32
  */
21
33
 
22
- const CACHE_SUBDIR = ".telo/manifests";
23
34
 
24
35
  /** File-format version of the analysis stamp envelope. Only bumped when
25
36
  * the on-disk *layout* changes (new fields, restructured payload). The
@@ -30,7 +41,6 @@ const CACHE_SUBDIR = ".telo/manifests";
30
41
  * disk. A hand-maintained integer for that purpose would silently mask
31
42
  * newly-stricter validation until the next manifest edit. */
32
43
  const ANALYSIS_STAMP_FORMAT_VERSION = 1;
33
- const ANALYSIS_STAMP_FILE = `${CACHE_SUBDIR}/.validated.json`;
34
44
 
35
45
  const localRequire = createRequire(import.meta.url);
36
46
 
@@ -128,21 +138,33 @@ export function computeAnalysisSignature(graph: LoadedGraph): string {
128
138
  .digest("hex");
129
139
  }
130
140
 
131
- /** Read the stamped analysis verdict for the entry at `entryDir`, or
132
- * `undefined` when missing / unreadable / format-mismatched. The
133
- * `version` field is the on-disk *format* version; semantic
134
- * invalidation flows through the signature (which embeds package
135
- * versions). A future format change bumps `version` so older kernels
136
- * reading a newer stamp (or vice versa) discard rather than misparse. */
141
+ /** Where one entry's stamp lives: `<analysisDir>/<hash of entry URL>.json`.
142
+ *
143
+ * Keyed by the entry URL rather than by its directory, because two manifests in
144
+ * one directory (an app and its test harness) are two entries with two verdicts,
145
+ * and a shared cache root makes the directory a far weaker discriminator than it
146
+ * used to be. */
147
+ function stampPath(analysisDir: string, entryUrl: string): string {
148
+ const id = createHash("sha256").update(entryUrl).digest("hex").slice(0, 32);
149
+ return path.join(analysisDir, `${id}.json`);
150
+ }
151
+
152
+ /** Read the stamped analysis verdict for `entryUrl`, or `undefined` when
153
+ * missing / unreadable / format-mismatched. The `version` field is the
154
+ * on-disk *format* version; semantic invalidation flows through the
155
+ * signature (which embeds package versions). A future format change bumps
156
+ * `version` so older kernels reading a newer stamp (or vice versa) discard
157
+ * rather than misparse.
158
+ *
159
+ * A pre-workspace-anchor `.telo/manifests/.validated.json` is simply never
160
+ * looked at: it is a file where this layout wants a directory, so neither
161
+ * version of the kernel can misread the other's. */
137
162
  export async function readAnalysisStamp(
138
- entryDir: string,
139
- manifestsDir?: string,
163
+ entryUrl: string,
164
+ analysisDir: string,
140
165
  ): Promise<AnalysisStamp | undefined> {
141
- const stampPath = manifestsDir
142
- ? path.join(manifestsDir, ".validated.json")
143
- : path.join(entryDir, ANALYSIS_STAMP_FILE);
144
166
  try {
145
- const text = await fs.readFile(stampPath, "utf-8");
167
+ const text = await fs.readFile(stampPath(analysisDir, entryUrl), "utf-8");
146
168
  const parsed = JSON.parse(text) as Partial<AnalysisStamp>;
147
169
  if (
148
170
  parsed?.version === ANALYSIS_STAMP_FORMAT_VERSION &&
@@ -160,17 +182,23 @@ export async function readAnalysisStamp(
160
182
  * per-resource validation walk when the manifest set is unchanged.
161
183
  * Idempotent; safe to call after every successful load. */
162
184
  export async function writeAnalysisStamp(
163
- entryDir: string,
185
+ entryUrl: string,
164
186
  signature: string,
165
- manifestsDir?: string,
187
+ analysisDir: string,
166
188
  ): Promise<void> {
167
189
  const stamp: AnalysisStamp = {
168
190
  version: ANALYSIS_STAMP_FORMAT_VERSION,
169
191
  signature,
170
192
  };
171
- const target = manifestsDir
172
- ? path.join(manifestsDir, ".validated.json")
173
- : path.join(entryDir, ANALYSIS_STAMP_FILE);
193
+ const target = stampPath(analysisDir, entryUrl);
174
194
  await fs.mkdir(path.dirname(target), { recursive: true });
175
- await fs.writeFile(target, JSON.stringify(stamp), "utf-8");
195
+ // Temp file + rename, as the controller-source cache does. Two kernels loading
196
+ // the SAME entry concurrently — the normal case for a shared root, where a test
197
+ // suite runs many manifests at once — would otherwise interleave writes to one
198
+ // path. A torn stamp is read as a miss rather than as a wrong verdict, so the
199
+ // cost is a silent re-validation, but rename makes it unrepresentable for the
200
+ // price of one syscall.
201
+ const tmp = `${target}.${process.pid}.tmp`;
202
+ await fs.writeFile(tmp, JSON.stringify(stamp), "utf-8");
203
+ await fs.rename(tmp, target);
176
204
  }
@@ -12,6 +12,7 @@ import { fileURLToPath, pathToFileURL } from "url";
12
12
 
13
13
  import { hostEnv } from "../host-env.js";
14
14
  import { TransportRegistry, defaultTransportRegistry } from "../transports/transport-registry.js";
15
+ import { findWorkspaceRoot } from "../workspace-marker.js";
15
16
 
16
17
  const CACHE_SUBDIR = ".telo/manifests";
17
18
  const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
@@ -51,15 +52,51 @@ function cachePathForUrl(
51
52
  return joinUnder(cacheRoot, ...key.split("/"));
52
53
  }
53
54
 
55
+ /** The PRE-WORKSPACE-ANCHOR manifest cache for an entry: always
56
+ * `<entry-dir>/.telo/manifests`, regardless of marker or env override. `null`
57
+ * for an entry with no local anchor.
58
+ *
59
+ * A single definition because two things read the old location and they must
60
+ * agree: the manifest source serves `telo.yaml` from it, and `moduleDirectoryFor`
61
+ * places that module's LAYERS beside it. Deriving the path twice is how the two
62
+ * halves end up disagreeing — the manifest resolving from the old root while its
63
+ * controller layers are looked for under the new one, which is exactly the
64
+ * offline boot the fallback exists to keep working. */
65
+ export function legacyManifestsDir(entryDir: string): string | null {
66
+ return entryDir ? path.join(entryDir, CACHE_SUBDIR) : null;
67
+ }
68
+
69
+ /** `legacyManifestsDir`, or `null` when it would coincide with `current` — so the
70
+ * fallback is never a second lookup at the same place. */
71
+ export function legacyManifestsDirFallback(
72
+ entryDir: string,
73
+ current: string | null,
74
+ ): string | null {
75
+ const legacy = legacyManifestsDir(entryDir);
76
+ if (legacy === null) return null;
77
+ if (current !== null && path.resolve(legacy) === path.resolve(current)) return null;
78
+ return legacy;
79
+ }
80
+
54
81
  /**
55
- * Reads previously-cached manifest YAMLs from `<entry-dir>/.telo/manifests/`.
56
- * Sits ahead of `RegistrySource` / `HttpSource` in the source chain — a hit
57
- * makes boot hermetic, a miss falls through to the network source unchanged.
82
+ * Reads previously-cached manifest YAMLs from the resolved manifest cache. Sits
83
+ * ahead of `RegistrySource` / `HttpSource` in the source chain — a hit makes boot
84
+ * hermetic, a miss falls through to the network source unchanged.
58
85
  *
59
86
  * Populated by `writeManifestCache` at install time.
87
+ *
88
+ * On a miss it consults the PRE-WORKSPACE-ANCHOR location
89
+ * (`<entry-dir>/.telo/manifests/`) before giving up. Every other cache in `.telo`
90
+ * costs only CPU when it goes cold; this one costs network, so without the
91
+ * fallback the move to a workspace-anchored root would stop a hermetic setup from
92
+ * booting — its `telo install` output stranded at the old path, with the failure
93
+ * surfacing as a registry fetch on a machine that has no route to one. Read-only
94
+ * and one directory deep: writes always go to the current root, so the old copy
95
+ * ages out rather than being maintained.
60
96
  */
61
97
  export class LocalManifestCacheSource implements ManifestSource {
62
- private readonly cacheRoot: string;
98
+ private readonly cacheRoot: string | null;
99
+ private readonly legacyRoot: string | null;
63
100
  private readonly transports: TransportRegistry;
64
101
 
65
102
  constructor(
@@ -70,7 +107,8 @@ export class LocalManifestCacheSource implements ManifestSource {
70
107
  // `manifestsDir` is the resolved manifest-cache directory threaded from a
71
108
  // single `resolveCacheRoot` (honours `TELO_CACHE_DIR`); when absent we fall
72
109
  // back to the entry-anchored default so library/test callers are unchanged.
73
- this.cacheRoot = manifestsDir ?? path.join(entryDir, CACHE_SUBDIR);
110
+ this.cacheRoot = manifestsDir ?? legacyManifestsDir(entryDir);
111
+ this.legacyRoot = legacyManifestsDirFallback(entryDir, this.cacheRoot);
74
112
  this.transports = defaultTransportRegistry(registryUrl);
75
113
  }
76
114
 
@@ -108,7 +146,12 @@ export class LocalManifestCacheSource implements ManifestSource {
108
146
  }
109
147
 
110
148
  private tryMap(url: string): string | null {
111
- const candidate = cachePathForUrl(url, this.cacheRoot, this.transports);
149
+ return this.tryMapIn(url, this.cacheRoot) ?? this.tryMapIn(url, this.legacyRoot);
150
+ }
151
+
152
+ private tryMapIn(url: string, root: string | null): string | null {
153
+ if (root === null) return null;
154
+ const candidate = cachePathForUrl(url, root, this.transports);
112
155
  if (!candidate) return null;
113
156
  // Require a regular file. A directory, dangling symlink, or stat failure
114
157
  // (ENOENT, EACCES, EISDIR-on-component) all fall through as a cache miss
@@ -219,18 +262,30 @@ export function resolveEntryDir(entryPath: string): string | null {
219
262
  }
220
263
 
221
264
  /** The single `.telo` cache root for an entry, resolved once and threaded to
222
- * every consumer (manifest cache, compiled validators, analysis stamp, npm
223
- * install root) so none of them re-derive it or read the env independently.
265
+ * every consumer (manifest cache, compiled validators, analysis stamps, npm
266
+ * install root, cargo target dirs) so none of them re-derive it or read the env
267
+ * independently.
268
+ *
269
+ * Precedence: `TELO_CACHE_DIR` (the relocated root a prebuilt image bakes its
270
+ * deps into) wins; then the directory holding `telo-workspace.yaml`, so every
271
+ * app in one repo shares a cache instead of each carrying its own copy of the
272
+ * same manifests, validators, bundles and npm tree; then `<entry-dir>/.telo`.
273
+ *
274
+ * Anchoring on the marker's LOCATION only — never its `modules:` list, which is
275
+ * release scope — so a manifest in no release subtree (an example, a test
276
+ * fixture) shares the cache exactly as an app does. With no marker anywhere
277
+ * above, this collapses to what it did before the anchor existed, so the file
278
+ * enables the shared cache rather than gating one and deleting it cannot break
279
+ * a build.
224
280
  *
225
- * `TELO_CACHE_DIR` (the relocated root a prebuilt image bakes its deps into)
226
- * wins; otherwise the root sits beside the entry at `<entry-dir>/.telo`.
227
281
  * Returns `null` for an entry with no local anchor — an http(s), `memory://`
228
282
  * or any other non-`file:` scheme — in which case the disk cache is skipped.
229
- * Consumers append the conventional subdirs: `manifests/`, `manifests/__validators/`,
230
- * `npm/`. */
283
+ * Consumers append the conventional subdirs: `manifests/`, `analysis/`,
284
+ * `validators/`, `controller-src/`, `npm/`. */
231
285
  export function resolveCacheRoot(entryPath: string): string | null {
232
286
  const override = hostEnv().TELO_CACHE_DIR;
233
287
  if (override && override.trim()) return path.resolve(override.trim());
234
288
  const entryDir = resolveEntryDir(entryPath);
235
- return entryDir ? path.join(entryDir, ".telo") : null;
289
+ if (!entryDir) return null;
290
+ return path.join(findWorkspaceRoot(entryDir) ?? entryDir, ".telo");
236
291
  }
@@ -176,7 +176,15 @@ export class KernelRuntimeSeam implements RuntimeSeam {
176
176
  // child down in its own `finally`, so this only has to close the channels.
177
177
  const exitCode = (async () => {
178
178
  try {
179
- await child.load(source);
179
+ // The child runs a manifest in the SAME workspace as its parent, so it
180
+ // shares the root the parent already resolved instead of deriving one
181
+ // beside the child manifest. Deriving put a `.telo` in every directory
182
+ // holding a test manifest and rebuilt every controller bundle once per
183
+ // test, since `Test.Suite` runs each test through here.
184
+ //
185
+ // `undefined` means "resolve one yourself" and `null` means "no cache" —
186
+ // a parent with no local anchor must yield the first, not the second.
187
+ await child.load(source, { cacheDir: this.kernel.getCacheRoot() ?? undefined });
180
188
  await child.start();
181
189
  return child.exitCode;
182
190
  } catch (err) {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Finding `telo-workspace.yaml` on disk.
3
+ *
4
+ * Its LOCATION is a general anchor — the directory workspace-relative paths are
5
+ * measured from, the outer bound on how far a walk-up may look, and the root the
6
+ * `.telo` cache is anchored at — while its `modules:` list is release scope. So
7
+ * the finder is deliberately separate from `release/`: `telo run` bounds its
8
+ * `.env` collection with the marker and `resolveCacheRoot` anchors on it, and
9
+ * neither may reach into the release namespace to do so, where `loadWorkspace`
10
+ * (which parses `modules:` and throws when the file is absent) sits one import
11
+ * away.
12
+ *
13
+ * It lives in the KERNEL rather than the CLI because the cache root is resolved
14
+ * here, on a path a CLI-less embedder also takes (a child kernel started through
15
+ * the runtime seam, a test harness). The CLI re-exports it; nothing is duplicated.
16
+ *
17
+ * Parsing the file is the analyzer's (`release/workspace-config.ts`); finding it
18
+ * is the Node half, and this is all of it.
19
+ */
20
+
21
+ import { WORKSPACE_FILENAME } from "@telorun/analyzer";
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+
25
+ export { WORKSPACE_FILENAME };
26
+
27
+ /**
28
+ * Walk up from `from` looking for the marker. Returns the directory holding it,
29
+ * or `undefined` — the file is optional. `telo release` requires one and reports
30
+ * its absence; `telo run` and `resolveCacheRoot` read `undefined` as "no parent
31
+ * lookup", falling back to what they did before the marker existed.
32
+ *
33
+ * `from` is resolved through symlinks first: a manifest reached through a linked
34
+ * directory would otherwise walk the LINK's parents, miss a marker that is right
35
+ * there in the real tree, and silently fall back to a narrower answer.
36
+ */
37
+ export function findWorkspaceRoot(from: string): string | undefined {
38
+ let dir = realPath(path.resolve(from));
39
+ for (;;) {
40
+ if (isFile(path.join(dir, WORKSPACE_FILENAME))) return dir;
41
+ const parent = path.dirname(dir);
42
+ if (parent === dir) return undefined;
43
+ dir = parent;
44
+ }
45
+ }
46
+
47
+ /** The marker must be a FILE. `existsSync` would accept a directory of that name
48
+ * and anchor the whole cache on it, and the Rust half tests `is_file()` — a
49
+ * workspace visible to one kernel and not the other is precisely the divergence
50
+ * a shared marker exists to prevent. */
51
+ function isFile(target: string): boolean {
52
+ try {
53
+ return fs.statSync(target).isFile();
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /** `fs.realpathSync`, falling back to the input when the path does not exist —
60
+ * a manifest path that is wrong is the caller's error to report, not this
61
+ * helper's to convert into a different one. */
62
+ export function realPath(target: string): string {
63
+ try {
64
+ return fs.realpathSync(target);
65
+ } catch {
66
+ return target;
67
+ }
68
+ }