@akanjs/devkit 2.4.1-rc.1 → 2.4.1-rc.3

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.
@@ -149,7 +149,6 @@ describe("dev stability integration harness", () => {
149
149
  return;
150
150
  }
151
151
  const mark = host.markLog();
152
- const hmrMark = hmr?.mark() ?? 0;
153
152
 
154
153
  await harness.replaceText("common/marker.ts", "initial-shared-marker", "updated-shared-marker");
155
154
 
@@ -159,13 +158,13 @@ describe("dev stability integration harness", () => {
159
158
  );
160
159
  const generation = plan[1];
161
160
  await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
162
- if (hmr)
163
- await hmr.waitForMessageSince(
164
- hmrMark,
165
- (msg) =>
166
- typeof msg === "object" && msg !== null && "generation" in msg && String(msg.generation) === generation,
167
- );
168
- else await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
161
+ // Asserted backend-side, not through the probe. A shared edit restarts the backend, which closes
162
+ // the socket the probe opened, and the probe is a raw WebSocket that never reconnects. A real
163
+ // browser does: on reconnect it gets a `hello` and reloads when the buildId moved
164
+ // (`akanjs/server/hmr/clientScript.ts`). Requiring a probe message here only held while the client
165
+ // rebuild happened to finish before the restart killed the connection a race this test lost the
166
+ // moment builds moved into a worker process and took ~240ms longer to start.
167
+ await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
169
168
  await harness.waitForHttpText("updated-shared-marker");
170
169
  hmr?.close();
171
170
  });
@@ -469,6 +468,9 @@ describe("dev resource budgets", () => {
469
468
 
470
469
  const idleTotal = await DevStabilityHarness.processTreeRssBytes(host.proc.pid);
471
470
  const idleWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, { excludeBuilder: true });
471
+ const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
472
+ // Nothing should be building at idle, so the disposable worker must not be resident.
473
+ expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
472
474
  // Measured ~670MB for this fixture; the headroom covers machine variance, not a reintroduced
473
475
  // eager import (the cheapest of those is ~30MB, and the devkit barrel cycle was 236MB).
474
476
  expect(idleTotal).toBeLessThan(1_000 * MB);
@@ -482,23 +484,104 @@ describe("dev resource budgets", () => {
482
484
  const afterSave = host.logs.join("").slice(mark);
483
485
  expect(afterSave).toMatch(/csr-rebundle skipped/);
484
486
  expect(afterSave).not.toMatch(/csr-rebundle ok/);
485
- // The debounced CSS rebuild is still writing after `pages-rebundle ok`, and Bun drops a watcher
486
- // event that lands in the same window as a write burst (`06-watcher-dropped-event.md`). Saving
487
- // again before this save's last artifact write lands loses the next edit outright, which showed
488
- // up as this test hanging for the full 90s wait on iteration 2.
487
+ // Bun drops a watcher event that lands in the same window as a write burst
488
+ // (`06-watcher-dropped-event.md`), so saving again before this generation has fully settled loses
489
+ // the next edit outright this test hanging for the full 90s wait on iteration 2. Waiting for
490
+ // the builder alone is not enough: the backend is still applying the reload after that, and it
491
+ // writes too. Wait for the backend to finish, then leave the drop window (measured under 200ms).
489
492
  await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
493
+ await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
494
+ await Bun.sleep(300);
490
495
  }
491
496
 
492
497
  // Each in-place reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry
493
498
  // never evicts — so without a recycle the worker grows for the life of the process.
494
499
  await host.waitForLogSince(start, /rolling recycle worker reason=pages-reload-accumulation/, WAIT_MS);
495
500
 
496
- // The dev host, gateway, replica and rsc worker must all stay flat across saves. The builder is
497
- // still expected to grow — `Bun.build` retains native arenas that no GC reclaims, which the
498
- // bounded-builder work addresses — so it is excluded here rather than silently tolerated.
501
+ // The dev host, gateway, replica and rsc worker must all stay flat across saves.
499
502
  const afterWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, {
500
503
  excludeBuilder: true,
501
504
  });
502
505
  expect(afterWithoutBuilder - idleWithoutBuilder).toBeLessThan(120 * MB);
506
+
507
+ // And so must the builder. It used to be excluded from this budget because `Bun.build` retains
508
+ // native arenas no GC reclaims, which made it grow ~120MB per save on this fixture; every build
509
+ // that scales per save now runs in a process that exits, so its memory goes back to the OS.
510
+ const afterBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
511
+ expect(afterBuilder?.pid).toBe(idleBuilder?.pid);
512
+ expect((afterBuilder?.rssBytes ?? 0) - (idleBuilder?.rssBytes ?? 0)).toBeLessThan(30 * MB);
513
+ // The worker is transient: three generations built, and none of them is still around.
514
+ expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
515
+ });
516
+
517
+ budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
518
+ const harness = await createHarness();
519
+ // Deliberately *below* this fixture's post-boot builder. Moving every per-save build into a
520
+ // disposable worker means the builder no longer grows into a ceiling, so a ceiling it is already
521
+ // over is the only way left to drive the recycle path end to end — and it is also the case the
522
+ // escape hatch exists for: an app whose boot floor simply does not fit under the limit.
523
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
524
+ const start = host.markLog();
525
+ await harness.waitForHttpText("initial-client-marker", WAIT_MS);
526
+
527
+ // One save is enough: the builder reports its rss as soon as the batch drains, and the host arms
528
+ // the recycle from that report. The old pid comes from the log rather than from `ps`, so this does
529
+ // not race the swap it is about to observe.
530
+ const firstSave = host.markLog();
531
+ await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, "marker-1");
532
+ await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
533
+
534
+ // The host decides, the builder drains rather than being killed, and the replacement comes up.
535
+ const recycleLog = await host.waitForLogSince(
536
+ start,
537
+ /recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
538
+ WAIT_MS,
539
+ );
540
+ await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
541
+ await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
542
+ await host.waitForLogSince(start, /builder ready after restart/, WAIT_MS);
543
+ // The backend read `base-artifact.json` once at boot, so the replacement has to re-announce what
544
+ // it booted with or the backend keeps serving the artifact of the builder that just exited.
545
+ await host.waitForLogSince(start, /announced boot state after recycle/, WAIT_MS);
546
+
547
+ const recycled = await DevStabilityHarness.builderProcess(host.proc.pid);
548
+ expect(recycled).not.toBeNull();
549
+ expect(String(recycled?.pid)).not.toBe(recycleLog[1]);
550
+
551
+ // And the dev server is still a dev server: the replacement watches, rebuilds and serves.
552
+ //
553
+ // Waiting for readiness above is load-bearing, not padding. The watcher is installed at the end of
554
+ // the boot build, so a save during the recycle is seen by neither builder — this test lost one
555
+ // exactly that way. The settle wait on top is for Bun's dropped-event bug: the recycle boot writes
556
+ // a burst of artifacts, and a save inside that window is never reported at all
557
+ // (`06-watcher-dropped-event.md`).
558
+ await Bun.sleep(500);
559
+ const attempts: string[] = [];
560
+ for (let attempt = 1; attempt <= 4; attempt++) {
561
+ const mark = host.markLog();
562
+ await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-recycle-${attempt}`);
563
+ const seen = await host
564
+ .waitForLogSince(mark, /pages-rebundle ok/, 15_000)
565
+ .then(() => true)
566
+ .catch(() => false);
567
+ attempts.push(`${attempt}=${seen ? "rebuilt" : "silent"}`);
568
+ if (seen) break;
569
+ await Bun.sleep(750);
570
+ }
571
+ console.info(
572
+ `[recycle-guard] ${recycleLog[2]}; builder ${recycleLog[1]} -> ${recycled?.pid} at ${Math.round((recycled?.rssBytes ?? 0) / MB)}MiB; post-recycle saves: ${attempts.join(" ")}`,
573
+ );
574
+ expect(attempts.join(" ")).toMatch(/rebuilt/);
575
+ await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
576
+
577
+ // A replacement that is still over the ceiling proves the ceiling cannot be met, and the host has
578
+ // to stop rather than recycle forever. Two reports inside the minimum interval is the threshold.
579
+ for (let i = 1; i <= 3; i++) {
580
+ await Bun.sleep(500);
581
+ const mark = host.markLog();
582
+ await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}`);
583
+ await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
584
+ }
585
+ await host.waitForLogSince(start, /ceiling cannot be met for this app/, WAIT_MS);
503
586
  });
504
587
  });
@@ -281,7 +281,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
281
281
  const decoder = new TextDecoder();
282
282
  const reader = stream.getReader();
283
283
  try {
284
- while (true) {
284
+ for (;;) {
285
285
  const { done, value } = await reader.read();
286
286
  if (done) break;
287
287
  logs.push(decoder.decode(value, { stream: true }));
@@ -460,6 +460,12 @@ export const dictionary = serviceDictionary(["en", "ko"])
460
460
  * process, whose growth across saves is `Bun.build` native arena retention rather than a leak the
461
461
  * other processes could be blamed for.
462
462
  */
463
+ // Matched precisely, not by directory: the disposable build worker lives at
464
+ // `incrementalBuilder/buildBatch.proc.ts`, so a substring match on `incrementalBuilder` would count
465
+ // the worker as the watcher and make every builder measurement depend on spawn timing.
466
+ static readonly #builderCmd = "incrementalBuilder.proc";
467
+ static readonly #buildWorkerCmd = "buildBatch.proc";
468
+
463
469
  static async processTreeRssBytes(
464
470
  rootPid: number,
465
471
  { excludeBuilder = false }: { excludeBuilder?: boolean } = {},
@@ -471,11 +477,31 @@ export const dictionary = serviceDictionary(["en", "ko"])
471
477
  .filter((row) => pids.has(row.pid))
472
478
  // `bun run akan …` is the npm-script shell wrapper, not a dev process.
473
479
  .filter((row) => !row.cmd.startsWith("bash -lc") && !row.cmd.includes("cli/build.ts"))
474
- .filter((row) => !excludeBuilder || !row.cmd.includes("incrementalBuilder"))
480
+ .filter((row) => !excludeBuilder || !row.cmd.includes(DevStabilityHarness.#builderCmd))
475
481
  .reduce((total, row) => total + row.rssKb * 1024, 0)
476
482
  );
477
483
  }
478
484
 
485
+ /**
486
+ * The long-lived builder process on its own: its RSS is what bundler-arena retention used to move,
487
+ * and its pid is what changes when the host recycles it, so a bounded-builder assertion needs both.
488
+ */
489
+ static async builderProcess(rootPid: number): Promise<{ pid: number; rssBytes: number } | null> {
490
+ return DevStabilityHarness.#findProcess(rootPid, DevStabilityHarness.#builderCmd);
491
+ }
492
+
493
+ /** The disposable per-generation build worker, which should only exist while a build is running. */
494
+ static async buildWorkerProcess(rootPid: number): Promise<{ pid: number; rssBytes: number } | null> {
495
+ return DevStabilityHarness.#findProcess(rootPid, DevStabilityHarness.#buildWorkerCmd);
496
+ }
497
+
498
+ static async #findProcess(rootPid: number, cmdIncludes: string) {
499
+ const rows = await DevStabilityHarness.#psRows();
500
+ const pids = DevStabilityHarness.#collectDescendants(rows, rootPid);
501
+ const found = rows.find((row) => pids.has(row.pid) && row.cmd.includes(cmdIncludes));
502
+ return found ? { pid: found.pid, rssBytes: found.rssKb * 1024 } : null;
503
+ }
504
+
479
505
  /** Pids of `rootPid` and everything under it, deepest first, so callers can signal children before parents. */
480
506
  static async descendantPids(rootPid: number | undefined): Promise<number[]> {
481
507
  if (!rootPid) return [];
@@ -0,0 +1 @@
1
+ process.send?.({ type: "done", value: 42 });
@@ -0,0 +1,11 @@
1
+ const started = Date.now();
2
+ let got: unknown = null;
3
+ const proc = Bun.spawn(["bun", `${import.meta.dir}/child.ts`], {
4
+ stdio: ["ignore", "inherit", "inherit"],
5
+ serialization: "advanced",
6
+ ipc: (m) => { got = m; },
7
+ });
8
+ const timeout = new Promise((r) => setTimeout(() => r("TIMEOUT"), 4000));
9
+ const outcome = await Promise.race([proc.exited, timeout]);
10
+ console.info(JSON.stringify({ outcome, got, ms: Date.now() - started }));
11
+ if (outcome === "TIMEOUT") proc.kill();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.1-rc.1",
3
+ "version": "2.4.1-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "2.4.1-rc.1",
47
+ "akanjs": "2.4.1-rc.3",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "daisyui": "5.5.23",
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { PackageExportsMap } from "@akanjs/devkit/packageExportsMap";
5
+
6
+ // Guards the published shape of this package, which the monorepo cannot exercise on its own.
7
+ //
8
+ // Inside the monorepo Bun resolves `@akanjs/devkit/executors` through the root tsconfig `paths`
9
+ // (`@akanjs/devkit/*` -> `pkgs/@akanjs/devkit/*`), and that resolver probes extensions and directory
10
+ // indexes. A published consumer has no such mapping: it goes through this package's `exports` map,
11
+ // whose targets are matched *exactly*. So `"./*": "./*"` type-checked, built, and passed every test
12
+ // here while every subpath import failed at runtime for anyone installing the tarball:
13
+ //
14
+ // error: Cannot find module '@akanjs/devkit/executors' from
15
+ // '<consumer>/node_modules/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts'
16
+ //
17
+ // `PackageRunner.verifyDistPackage` runs the same check against the built dist tree of every
18
+ // publishable package at release time; these tests keep this package honest on every run.
19
+
20
+ const packageDir = import.meta.dir;
21
+ const exportsMap = await PackageExportsMap.from(packageDir);
22
+
23
+ /** Every facet the root barrel re-exports, as the subpath a consumer would import. */
24
+ const barrelFacets = async (): Promise<string[]> => {
25
+ const barrel = await Bun.file(path.join(packageDir, "index.ts")).text();
26
+ return [...barrel.matchAll(/^export (?:type )?\* from "\.\/([^"]+)";$/gm)].map((match) => `./${match[1]}`);
27
+ };
28
+
29
+ /** Every `@akanjs/devkit/<subpath>` specifier written anywhere in the two packages that use them. */
30
+ const importedSubpaths = async (): Promise<string[]> => {
31
+ const repoRoot = path.resolve(packageDir, "../../..");
32
+ const glob = new Bun.Glob("pkgs/@akanjs/{cli,devkit}/**/*.{ts,tsx}");
33
+ const found = new Set<string>();
34
+ for await (const relative of glob.scan({ cwd: repoRoot })) {
35
+ if (relative.includes("node_modules/") || relative.includes("/dist/")) continue;
36
+ const source = await Bun.file(path.join(repoRoot, relative)).text();
37
+ for (const match of source.matchAll(/"@akanjs\/devkit\/([a-zA-Z0-9_./-]+)"/g)) found.add(`./${match[1]}`);
38
+ }
39
+ return [...found].sort();
40
+ };
41
+
42
+ describe("published exports map", () => {
43
+ test("resolves every facet the root barrel re-exports", async () => {
44
+ const facets = await barrelFacets();
45
+ expect(facets.length).toBeGreaterThan(30);
46
+ expect(exportsMap.findUnreachable(facets)).toEqual([]);
47
+ });
48
+
49
+ test("resolves every subpath the monorepo actually imports", async () => {
50
+ const subpaths = await importedSubpaths();
51
+ expect(subpaths.length).toBeGreaterThan(20);
52
+ expect(exportsMap.findUnreachable(subpaths)).toEqual([]);
53
+ });
54
+
55
+ test("covers both facet shapes and keeps explicit extensions intact", () => {
56
+ // A single wildcard cannot serve all three: `./*` -> `./*.ts` reaches bare files, directory
57
+ // facets need their own literal entry, and `./*.ts` -> `./*.ts` keeps an already-suffixed
58
+ // specifier from becoming `./cloud/cloudApi.ts.ts`.
59
+ expect(exportsMap.resolve("./executors")).toBe("./executors.ts");
60
+ expect(exportsMap.resolve("./frontendBuild")).toBe("./frontendBuild/index.ts");
61
+ expect(exportsMap.resolve("./cloud/cloudApi.ts")).toBe("./cloud/cloudApi.ts");
62
+ expect(exportsMap.resolve("./package.json")).toBe("./package.json");
63
+ });
64
+
65
+ test("every directory facet has a literal entry, since the wildcard cannot probe index.ts", async () => {
66
+ const facets = await barrelFacets();
67
+ const missing = facets.filter(
68
+ (subpath) =>
69
+ existsSync(path.join(packageDir, subpath, "index.ts")) && exportsMap.resolve(subpath) !== `${subpath}/index.ts`,
70
+ );
71
+ expect(missing).toEqual([]);
72
+ });
73
+ });
@@ -0,0 +1,96 @@
1
+ import { statSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export interface UnreachableSubpath {
5
+ subpath: string;
6
+ /** The path the `exports` map yields, or null when no entry matches the subpath at all. */
7
+ target: string | null;
8
+ }
9
+
10
+ /**
11
+ * The subset of Node's `exports` resolution that Bun applies to a published package.
12
+ *
13
+ * Exports targets are matched **exactly**: no extension is appended and no `index.ts` is probed. That
14
+ * is invisible inside this monorepo, where `@akanjs/devkit/*` and `akanjs/*` resolve through the root
15
+ * tsconfig `paths` instead — a resolver that *does* probe both. A map of `{"./*": "./*"}` therefore
16
+ * type-checks, builds, and passes every test while every subpath import fails for anyone who installs
17
+ * the tarball. This class exists so that gap can be asserted against before publishing.
18
+ */
19
+ export class PackageExportsMap {
20
+ /** Reads `<packageDir>/package.json` and builds the map from its `exports` field. */
21
+ static async from(packageDir: string) {
22
+ const manifest = (await Bun.file(path.join(packageDir, "package.json")).json()) as { exports?: unknown };
23
+ return new PackageExportsMap(packageDir, manifest.exports);
24
+ }
25
+ /** Picks the target a runtime import would follow, walking conditional objects in Bun's order. */
26
+ static #runtimeTargetOf(value: unknown): string | null {
27
+ if (typeof value === "string") return value;
28
+ if (Array.isArray(value)) {
29
+ // Bun takes the first entry and stops; it does not fall through on a missing file.
30
+ for (const entry of value) {
31
+ const target = PackageExportsMap.#runtimeTargetOf(entry);
32
+ if (target) return target;
33
+ }
34
+ return null;
35
+ }
36
+ if (!value || typeof value !== "object") return null;
37
+ const conditions = value as Record<string, unknown>;
38
+ for (const condition of ["bun", "import", "default", "require", "types"]) {
39
+ if (!(condition in conditions)) continue;
40
+ const target = PackageExportsMap.#runtimeTargetOf(conditions[condition]);
41
+ if (target) return target;
42
+ }
43
+ return null;
44
+ }
45
+ #packageDir: string;
46
+ #literals = new Map<string, string>();
47
+ #patterns: { prefix: string; suffix: string; target: string }[] = [];
48
+ constructor(packageDir: string, exportsField: unknown) {
49
+ this.#packageDir = packageDir;
50
+ if (!exportsField || typeof exportsField !== "object") return;
51
+ for (const [key, value] of Object.entries(exportsField as Record<string, unknown>)) {
52
+ if (!key.startsWith(".")) continue; // a bare conditional map has no subpaths to check
53
+ const target = PackageExportsMap.#runtimeTargetOf(value);
54
+ if (!target) continue;
55
+ const star = key.indexOf("*");
56
+ if (star === -1) this.#literals.set(key, target);
57
+ else this.#patterns.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), target });
58
+ }
59
+ // Node picks the most specific pattern: longest prefix first, then longest suffix. So `./*.ts`
60
+ // wins over `./*`, which is what keeps an already-suffixed specifier from gaining a second `.ts`.
61
+ this.#patterns.sort((a, b) => b.prefix.length - a.prefix.length || b.suffix.length - a.suffix.length);
62
+ }
63
+ /** Returns the target an `exports` lookup yields, or null when the subpath is unexported. */
64
+ resolve(subpath: string): string | null {
65
+ const literal = this.#literals.get(subpath);
66
+ if (literal) return literal;
67
+ for (const { prefix, suffix, target } of this.#patterns) {
68
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
69
+ if (subpath.length < prefix.length + suffix.length) continue;
70
+ return target.replace("*", subpath.slice(prefix.length, subpath.length - suffix.length));
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * Resolves a subpath and reports whether the target it yields is a readable file.
76
+ *
77
+ * A directory does not count. `{"./*": "./*"}` maps `./commandDecorators` onto the directory of
78
+ * that name, which exists but is not a module — an `existsSync` check here reports such a subpath
79
+ * as reachable while the import still fails.
80
+ */
81
+ resolveToFile(subpath: string): { target: string | null; exists: boolean } {
82
+ const target = this.resolve(subpath);
83
+ if (!target) return { target: null, exists: false };
84
+ const stat = statSync(path.join(this.#packageDir, target), { throwIfNoEntry: false });
85
+ return { target, exists: !!stat?.isFile() };
86
+ }
87
+ /** Returns the given subpaths that no consumer could import, in input order. */
88
+ findUnreachable(subpaths: Iterable<string>): UnreachableSubpath[] {
89
+ const unreachable: UnreachableSubpath[] = [];
90
+ for (const subpath of subpaths) {
91
+ const { target, exists } = this.resolveToFile(subpath);
92
+ if (!exists) unreachable.push({ subpath, target });
93
+ }
94
+ return unreachable;
95
+ }
96
+ }