@akanjs/devkit 3.0.0-beta.18 → 3.0.0-beta.19

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 (63) hide show
  1. package/abstractDoc.ts +4 -1
  2. package/akanApp/akanApp.host.ts +23 -9
  3. package/akanConfig/akanConfig.test.ts +55 -30
  4. package/akanConfig/akanConfig.ts +29 -17
  5. package/akanConfig/types.ts +1 -0
  6. package/applicationBuildRunner.test.ts +5 -3
  7. package/applicationBuildRunner.ts +29 -3
  8. package/artifact/routeSeedIndex.test.ts +7 -6
  9. package/capacitorApp.test.ts +2 -2
  10. package/cloud/constants.ts +23 -0
  11. package/cloud/globalConfig.ts +13 -0
  12. package/codeAgent/agent/AkanCodeServices.ts +22 -3
  13. package/codeAgent/agent/CodeAgent.ts +33 -18
  14. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  15. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  16. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  17. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  18. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  19. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  20. package/codeAgent/agent/akanCodeModel.ts +1 -1
  21. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  22. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  23. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  24. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  25. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  26. package/codeAgent/tools/mcpOAuth.test.ts +6 -2
  27. package/dependencyScanner.ts +4 -2
  28. package/devkitUtils.test.ts +1 -1
  29. package/executors.linkFallback.test.ts +103 -0
  30. package/executors.test.ts +8 -0
  31. package/executors.ts +31 -8
  32. package/frontendBuild/autoImportSync.test.ts +13 -0
  33. package/frontendBuild/autoImportSync.ts +19 -1
  34. package/frontendBuild/buildRouteClient.test.ts +52 -1
  35. package/frontendBuild/cssImportResolver.ts +1 -1
  36. package/frontendBuild/fontOptimizer.test.ts +1 -1
  37. package/frontendBuild/frontendBuild.test.ts +19 -17
  38. package/frontendBuild/hmrWatcher.test.ts +1 -1
  39. package/frontendBuild/routeClientBuilder.ts +27 -10
  40. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  41. package/getDirname.test.ts +4 -2
  42. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  43. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  44. package/integration/devStabilityHarness.test.ts +7 -2
  45. package/lint/gritRules.test.ts +2 -1
  46. package/lint/no-deep-internal-import.grit +2 -2
  47. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  48. package/linter.ts +4 -1
  49. package/package.json +2 -2
  50. package/platformTest/BunTestReport.test.ts +60 -0
  51. package/platformTest/BunTestReport.ts +65 -0
  52. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  53. package/platformTest/IdleWatchedProcess.ts +77 -0
  54. package/platformTest/LinuxDockerTarget.ts +120 -0
  55. package/platformTest/PlatformTestRun.test.ts +68 -0
  56. package/platformTest/PlatformTestRun.ts +141 -0
  57. package/platformTest/PlatformTestTarget.ts +120 -0
  58. package/platformTest/TestSnapshot.test.ts +80 -0
  59. package/platformTest/TestSnapshot.ts +107 -0
  60. package/platformTest/WindowsSshTarget.ts +167 -0
  61. package/subspace.test.ts +5 -2
  62. package/subspace.ts +19 -8
  63. package/workflow/rolloutGate.test.ts +1 -1
@@ -333,13 +333,14 @@ describe("RoutesManifestArtifactSerializer", () => {
333
333
  describe("HmrChangeClassifier", () => {
334
334
  test("classifies code, css, config, and ignored files", () => {
335
335
  const classifier = new HmrChangeClassifier();
336
- expect(classifier.classify("/repo/apps/demo/page/_index.tsx")).toBe("code");
337
- expect(classifier.classify("/repo/apps/demo/page/styles.css")).toBe("css");
338
- expect(classifier.classify("/repo/apps/demo/akan.config.ts")).toBe("config");
339
- expect(classifier.classify("/repo/apps/demo/.DS_Store")).toBe("ignore");
340
- expect(classifier.classify(`/repo/apps/demo/node_modules/pkg/index.ts`)).toBe("ignore");
341
- expect(classifier.classify(`/repo/apps/demo/.akan/generated/page.tsx`)).toBe("ignore");
342
- expect(classifier.classify("/repo/apps/demo/public/logo.png")).toBe("ignore");
336
+ const app = path.resolve("/repo/apps/demo");
337
+ expect(classifier.classify(path.join(app, "page/_index.tsx"))).toBe("code");
338
+ expect(classifier.classify(path.join(app, "page/styles.css"))).toBe("css");
339
+ expect(classifier.classify(path.join(app, "akan.config.ts"))).toBe("config");
340
+ expect(classifier.classify(path.join(app, ".DS_Store"))).toBe("ignore");
341
+ expect(classifier.classify(path.join(app, "node_modules/pkg/index.ts"))).toBe("ignore");
342
+ expect(classifier.classify(path.join(app, ".akan/generated/page.tsx"))).toBe("ignore");
343
+ expect(classifier.classify(path.join(app, "public/logo.png"))).toBe("ignore");
343
344
  });
344
345
  });
345
346
 
@@ -394,16 +395,16 @@ describe("DevGeneratedIndexSync", () => {
394
395
 
395
396
  describe("DevChangePlanner", () => {
396
397
  test("classifies server, client, shared, and generated barrel changes", () => {
397
- const root = "/repo";
398
+ const root = path.resolve("/repo");
398
399
  const planner = new DevChangePlanner({ workspaceRoot: root });
399
- const generatedIndex = `${root}/libs/shared/common/index.ts`;
400
+ const generatedIndex = path.join(root, "libs/shared/common/index.ts");
400
401
  const plan = planner.plan({
401
402
  generation: 7,
402
403
  files: [
403
- `${root}/libs/shared/lib/admin/admin.service.ts`,
404
- `${root}/libs/shared/lib/admin/Admin.Template.tsx`,
405
- `${root}/libs/shared/lib/admin/admin.constant.ts`,
406
- `${root}/libs/shared/common/foo.ts`,
404
+ path.join(root, "libs/shared/lib/admin/admin.service.ts"),
405
+ path.join(root, "libs/shared/lib/admin/Admin.Template.tsx"),
406
+ path.join(root, "libs/shared/lib/admin/admin.constant.ts"),
407
+ path.join(root, "libs/shared/common/foo.ts"),
407
408
  ],
408
409
  kinds: ["code"],
409
410
  generatedFiles: [generatedIndex],
@@ -429,21 +430,22 @@ describe("DevChangePlanner", () => {
429
430
  });
430
431
 
431
432
  test("recycles builder for macro-backed dictionary and signal metadata changes", () => {
432
- const root = "/repo";
433
+ const root = path.resolve("/repo");
433
434
  const planner = new DevChangePlanner({ workspaceRoot: root });
435
+ const dictionaryFile = path.join(root, "apps/demo/lib/_demo/demo.dictionary.ts");
434
436
  const dictionaryPlan = planner.plan({
435
437
  generation: 3,
436
- files: [`${root}/apps/demo/lib/_demo/demo.dictionary.ts`],
438
+ files: [dictionaryFile],
437
439
  kinds: ["code"],
438
440
  });
439
441
  const signalPlan = planner.plan({
440
442
  generation: 4,
441
- files: [`${root}/libs/shared/lib/admin/admin.signal.ts`],
443
+ files: [path.join(root, "libs/shared/lib/admin/admin.signal.ts")],
442
444
  kinds: ["code"],
443
445
  });
444
446
 
445
447
  expect(dictionaryPlan.actions).toEqual(["rebuild-client", "restart-backend", "restart-builder"]);
446
- expect(dictionaryPlan.reasonByFile[`${root}/apps/demo/lib/_demo/demo.dictionary.ts`]).toContain("runtime-metadata");
448
+ expect(dictionaryPlan.reasonByFile[dictionaryFile]).toContain("runtime-metadata");
447
449
  expect(signalPlan.actions).toContain("restart-builder");
448
450
  });
449
451
  });
@@ -161,7 +161,7 @@ describe("HmrWatcher", () => {
161
161
  TEST_TIMEOUT_MS,
162
162
  );
163
163
 
164
- test.skipIf(process.getuid?.() === 0)(
164
+ test.skipIf(process.getuid?.() === 0 || process.platform === "win32")(
165
165
  "warns at startup when part of the tree cannot be read",
166
166
  async () => {
167
167
  const root = await makeRoot();
@@ -28,6 +28,15 @@ const SSR_CLIENT_ALIAS_EXTERNALS = [
28
28
  export interface BuildRouteClientOptions {
29
29
  app: App;
30
30
  seeds: string[];
31
+ /**
32
+ * Seeds of every route, so a build that has to run at all bundles the whole app's client entries.
33
+ *
34
+ * A page loads entries first built for other routes, and entries from two `Bun.build` runs each carry
35
+ * their own copy of every app and lib module — one page evaluated a lib's module-scope registration
36
+ * twice. The whole app costs about what one route did, since every entry already imports the client
37
+ * barrel, and content-hashed chunk names keep unchanged chunks at the URLs the browser already holds.
38
+ */
39
+ graphSeeds?: string[];
31
40
  artifact: BaseBuildArtifact;
32
41
  knownEntries?: Set<string>;
33
42
  routeId?: string;
@@ -39,13 +48,13 @@ export interface BuildRouteClientOptions {
39
48
  * `AllRoutesBuilder` passes every route's entries at once so they share one `Bun.build`. Chunk
40
49
  * splitting is scoped to a single invocation, so a dependency reachable from entries spread across
41
50
  * several invocations is emitted once per invocation — mermaid landed in `apps/akan` four times that
42
- * way. Dev keeps one build per route and leaves this unset.
51
+ * way. Dev passes `graphSeeds` instead, for the same reason.
43
52
  */
44
53
  entries?: string[];
45
54
  }
46
55
 
47
56
  export interface BuildRouteClientResult {
48
- /** Newly-emitted manifest rows keyed by `${absEntry}#${exportName}`. */
57
+ /** Newly-emitted manifest rows keyed by `${absEntry}#${exportName}`, for every entry this build bundled. */
49
58
  manifestDelta: ClientManifest;
50
59
  /** Newly-emitted ssrManifest rows keyed by entry URL. */
51
60
  ssrManifestDelta: SsrManifest;
@@ -53,7 +62,7 @@ export interface BuildRouteClientResult {
53
62
  newEntries: string[];
54
63
  /** Absolute paths of all `"use client"` leaves discovered from this route's seeds. */
55
64
  discoveredEntries?: string[];
56
- /** Absolute source files included in the browser bundles for new entries. */
65
+ /** Absolute source files included in the browser bundles for this route's bundled entries. */
57
66
  clientDeps: string[];
58
67
  /** Absolute source files included in the browser bundle, grouped by original client entry. */
59
68
  clientDepsByEntry?: Record<string, string[]>;
@@ -67,6 +76,7 @@ interface BootstrapEntries {
67
76
  export class RouteClientBuilder {
68
77
  #app: App;
69
78
  #seeds: string[];
79
+ #graphSeeds?: string[];
70
80
  #knownEntries: Set<string>;
71
81
  #command: "build" | "start";
72
82
  #discovery?: ClientEntryDiscovery;
@@ -75,6 +85,7 @@ export class RouteClientBuilder {
75
85
  constructor(options: BuildRouteClientOptions) {
76
86
  this.#app = options.app;
77
87
  this.#seeds = options.seeds;
88
+ this.#graphSeeds = options.graphSeeds;
78
89
  this.#knownEntries = options.knownEntries ?? new Set<string>();
79
90
  this.#command = options.command ?? "start";
80
91
  this.#discovery = options.discovery;
@@ -82,17 +93,18 @@ export class RouteClientBuilder {
82
93
  }
83
94
 
84
95
  async build(): Promise<BuildRouteClientResult> {
85
- const discovered =
86
- this.#entries ??
87
- (await (this.#discovery ?? (await GraphClientEntryDiscovery.create(this.#app))).discover(this.#seeds));
88
- const entries = discovered.filter((e) => !this.#knownEntries.has(e));
89
- if (entries.length === 0) return this.#emptyResult(discovered);
96
+ const discovered = this.#entries ?? (await (await this.#getDiscovery()).discover(this.#seeds));
97
+ if (discovered.every((e) => this.#knownEntries.has(e))) return this.#emptyResult(discovered);
98
+ const entries = this.#graphSeeds
99
+ ? [...new Set([...discovered, ...(await (await this.#getDiscovery()).discover(this.#graphSeeds))])].sort()
100
+ : discovered.filter((e) => !this.#knownEntries.has(e));
90
101
 
91
102
  const bootstrapEntries = await this.#createBootstrapEntries(entries);
92
103
  const browserBundle = await this.#buildBrowserBundle(bootstrapEntries);
93
104
  const ssrBundle = await this.#buildSsrBundle(bootstrapEntries);
94
105
 
95
106
  const acceptedEntries = new Set(entries);
107
+ const routeEntries = new Set(discovered);
96
108
  const manifestDelta: ClientManifest = {};
97
109
  const ssrModuleMap: SsrManifest["moduleMap"] = {};
98
110
  const clientDeps = new Set<string>();
@@ -122,19 +134,24 @@ export class RouteClientBuilder {
122
134
  for (const dep of browserBundle.entryDepsByAbsPath.get(buildEntry) ?? []) deps.add(path.resolve(dep));
123
135
  const sortedDeps = [...deps].sort();
124
136
  clientDepsByEntry[originalEntry] = sortedDeps;
125
- for (const dep of sortedDeps) clientDeps.add(dep);
137
+ if (routeEntries.has(originalEntry)) for (const dep of sortedDeps) clientDeps.add(dep);
126
138
  }
127
139
 
128
140
  return {
129
141
  manifestDelta,
130
142
  ssrManifestDelta: { moduleLoading: null, moduleMap: ssrModuleMap },
131
- newEntries: entries,
143
+ newEntries: entries.filter((e) => !this.#knownEntries.has(e)),
132
144
  discoveredEntries: discovered,
133
145
  clientDeps: [...clientDeps].sort(),
134
146
  clientDepsByEntry,
135
147
  };
136
148
  }
137
149
 
150
+ async #getDiscovery(): Promise<ClientEntryDiscovery> {
151
+ this.#discovery ??= await GraphClientEntryDiscovery.create(this.#app);
152
+ return this.#discovery;
153
+ }
154
+
138
155
  #emptyResult(discoveredEntries: string[] = []): BuildRouteClientResult {
139
156
  return {
140
157
  manifestDelta: {},
@@ -249,11 +249,12 @@ describe("SourceMtimeIndex", () => {
249
249
  describe("a directory it cannot read", () => {
250
250
  /**
251
251
  * `chmod 000` does not stop root, so these would assert the opposite of what they mean when the suite
252
- * runs as root (CI containers commonly do).
252
+ * runs as root (CI containers commonly do). On Windows it only sets the read-only attribute, which leaves
253
+ * a directory listable.
253
254
  */
254
- const asRoot = process.getuid?.() === 0;
255
+ const cannotLock = process.getuid?.() === 0 || process.platform === "win32";
255
256
 
256
- test.skipIf(asRoot)("is reported as a gap instead of silently skipped", async () => {
257
+ test.skipIf(cannotLock)("is reported as a gap instead of silently skipped", async () => {
257
258
  const root = await makeRoot();
258
259
  await seed(root, "open/a.ts");
259
260
  const hidden = await seed(root, "locked/b.ts");
@@ -269,7 +270,7 @@ describe("SourceMtimeIndex", () => {
269
270
  expect(index.coverageGaps).toEqual([{ path: path.dirname(hidden), code: "EACCES" }]);
270
271
  });
271
272
 
272
- test.skipIf(asRoot)("becomes visible again once it is readable, and clears the gap", async () => {
273
+ test.skipIf(cannotLock)("becomes visible again once it is readable, and clears the gap", async () => {
273
274
  const root = await makeRoot();
274
275
  const hidden = await seed(root, "locked/b.ts");
275
276
  const locked = path.dirname(hidden);
@@ -287,7 +288,7 @@ describe("SourceMtimeIndex", () => {
287
288
  expect(await index.collectChanges()).toEqual([hidden]);
288
289
  });
289
290
 
290
- test.skipIf(asRoot)("does not report the files under it as deleted while it is unreadable", async () => {
291
+ test.skipIf(cannotLock)("does not report the files under it as deleted while it is unreadable", async () => {
291
292
  const root = await makeRoot();
292
293
  const abs = await seed(root, "locked/b.ts");
293
294
  const locked = path.dirname(abs);
@@ -1,12 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
3
4
  import { getDirname } from "./getDirname";
4
5
 
5
6
  describe("getDirname", () => {
6
7
  test("converts file URLs to filesystem paths", () => {
7
- const dirname = getDirname("file:///tmp/akan%20workspace/index.ts");
8
+ const filePath = path.resolve("/tmp/akan workspace/index.ts");
9
+ const dirname = getDirname(pathToFileURL(filePath).href);
8
10
 
9
- expect(dirname).toBe(path.join("/tmp", "akan workspace"));
11
+ expect(dirname).toBe(path.dirname(filePath));
10
12
  });
11
13
 
12
14
  test("keeps Windows drive paths valid when running on Windows", () => {
@@ -1,12 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
2
3
  import { DevChangePlanner } from "../frontendBuild";
3
4
  import { prepareDevWatchBatch } from "./devWatchBatch";
4
5
 
5
6
  describe("prepareDevWatchBatch", () => {
6
7
  test("includes generated indexes in the same invalidate generation", () => {
7
- const root = "/repo";
8
- const changedFile = `${root}/libs/shared/common/foo.ts`;
9
- const generatedIndex = `${root}/libs/shared/common/index.ts`;
8
+ const root = path.resolve("/repo");
9
+ const changedFile = path.join(root, "libs/shared/common/foo.ts");
10
+ const generatedIndex = path.join(root, "libs/shared/common/index.ts");
10
11
  const prepared = prepareDevWatchBatch({
11
12
  generation: 12,
12
13
  batch: { files: [changedFile], kinds: new Set(["code"]) },
@@ -25,9 +26,9 @@ describe("prepareDevWatchBatch", () => {
25
26
  test.each(["common", "srvkit", "ui", "webkit"])(
26
27
  "keeps %s facet add/delete generated index in the same generation",
27
28
  (facet) => {
28
- const root = "/repo";
29
- const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
30
- const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
29
+ const root = path.resolve("/repo");
30
+ const changedFile = path.join(root, "libs/shared", facet, "tmpExample.ts");
31
+ const generatedIndex = path.join(root, "libs/shared", facet, "index.ts");
31
32
  const prepared = prepareDevWatchBatch({
32
33
  generation: 20,
33
34
  batch: { files: [changedFile], kinds: new Set(["code"]) },
@@ -100,6 +100,7 @@ class IncrementalBuilder {
100
100
  app: this.#app,
101
101
  routeId: msg.routeId,
102
102
  seeds: msg.seeds,
103
+ graphSeeds: msg.graphSeeds,
103
104
  artifact: this.#artifact,
104
105
  knownEntries: new Set<string>(msg.knownEntries),
105
106
  discovery: this.#discovery,
@@ -42,8 +42,13 @@ describe("dev stability harness port allocation", () => {
42
42
 
43
43
  expect(new Set(ports).size).toBe(ports.length);
44
44
  // Forward-only, so a harness cannot be handed a port a previous host in this process may still be
45
- // holding while it shuts down — which is the collision the random offset kept producing.
46
- expect(ports).toEqual([...ports].sort((a, b) => a - b));
45
+ // holding while it shuts down — which is the collision the random offset kept producing. The cursor starts
46
+ // at a pid-seeded slot and wraps at the end of the band, so forward is measured around the band, and a step
47
+ // back would read as most of a lap.
48
+ const band = DevStabilityHarness.portOffsetMax - DevStabilityHarness.portOffsetMin;
49
+ const steps = ports.slice(1).map((port, idx) => (port - ports[idx] + band) % band);
50
+ expect(steps.every((step) => step > 0 && step % DevStabilityHarness.portOffsetStride === 0)).toBe(true);
51
+ expect(steps.reduce((sum, step) => sum + step, 0)).toBeLessThan(band);
47
52
  });
48
53
 
49
54
  test("returns the same port on every call instead of re-deriving it", async () => {
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { Linter } from "../linter";
7
8
 
8
9
  /**
9
10
  * Fixture tests for the GritQL plugins in this folder.
@@ -21,7 +22,7 @@ import path from "node:path";
21
22
  const lintDir = import.meta.dir;
22
23
  const fixturesDir = path.join(lintDir, "__fixtures__");
23
24
  const workspaceRoot = path.resolve(lintDir, "../../../..");
24
- const biomeBin = path.join(workspaceRoot, "node_modules/.bin/biome");
25
+ const biomeBin = path.join(workspaceRoot, "node_modules", ".bin", Linter.biomeBinName);
25
26
 
26
27
  interface BiomeDiagnostic {
27
28
  severity: string;
@@ -15,7 +15,7 @@ or {
15
15
  JsModuleSource() as $source where {
16
16
  $source <: within JsImport(),
17
17
  not $filename <: r".*\.(?:test|spec)\.tsx?",
18
- $filename <: r".*(?:apps|libs)/[^/]+/lib/[^/]+/[^/]+\.(?:ts|tsx)",
18
+ $filename <: r".*(?:apps|libs)[/\\][^/\\]+[/\\]lib[/\\][^/\\]+[/\\][^/\\]+\.(?:ts|tsx)",
19
19
  $source <: r"\"\.\./\.\./.*\"",
20
20
  register_diagnostic(
21
21
  span = $source,
@@ -25,7 +25,7 @@ or {
25
25
  JsModuleSource() as $source where {
26
26
  $source <: within JsImport(),
27
27
  not $filename <: r".*\.(?:test|spec)\.tsx?",
28
- $filename <: r".*/lib/.+\.tsx",
28
+ $filename <: r".*[/\\]lib[/\\].+\.tsx",
29
29
  $source <: r"\"\.\./.*\"",
30
30
  register_diagnostic(
31
31
  span = $source,
@@ -14,7 +14,8 @@ language js(typescript, jsx)
14
14
  $base <: contains `endpoint`,
15
15
  $className <: r"([A-Z][a-zA-Z0-9]*)Endpoint"($Cap)
16
16
  },
17
- $filename <: r".*/([a-z][a-zA-Z0-9]*)\.signal\.ts"($model),
17
+ // `$filename` carries backslashes on Windows, so a separator here must accept both.
18
+ $filename <: r".*[/\\]([a-z][a-zA-Z0-9]*)\.signal\.ts"($model),
18
19
  or {
19
20
  $key <: $model,
20
21
  $key <: r"(?:light|create|update|remove|view|edit|merge)([A-Z][a-zA-Z0-9]*)"($keyCap) where {
package/linter.ts CHANGED
@@ -112,6 +112,9 @@ const asBiomeReport = (candidate: string): BiomeReport | null => {
112
112
  const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
113
113
 
114
114
  export class Linter {
115
+ //? Bun links a package bin as `<name>.exe` (plus a `.bunx` stub) on Windows, and a bare `<name>` elsewhere.
116
+ static readonly biomeBinName = process.platform === "win32" ? "biome.exe" : "biome";
117
+
115
118
  lintRoot: string;
116
119
  configPath: string;
117
120
  #biomeBin: string;
@@ -119,7 +122,7 @@ export class Linter {
119
122
  constructor(cwdPath: string) {
120
123
  this.lintRoot = this.#findBiomeRootPath(cwdPath);
121
124
  this.configPath = Linter.#configPathIn(this.lintRoot) ?? path.join(this.lintRoot, "biome.json");
122
- const localBiomeBin = path.join(this.lintRoot, "node_modules/.bin/biome");
125
+ const localBiomeBin = path.join(this.lintRoot, "node_modules", ".bin", Linter.biomeBinName);
123
126
  this.#biomeBin = existsSync(localBiomeBin) ? localBiomeBin : "biome";
124
127
  }
125
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-beta.18",
3
+ "version": "3.0.0-beta.19",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@inquirer/prompts": "^8.4.3",
49
49
  "@tailwindcss/node": "^4.3.0",
50
50
  "@trapezedev/project": "^7.1.4",
51
- "akanjs": "3.0.0-beta.18",
51
+ "akanjs": "3.0.0-beta.19",
52
52
  "chalk": "^5.6.2",
53
53
  "commander": "^14.0.3",
54
54
  "dayjs": "^1.11.20",
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BunTestReport } from "./BunTestReport";
3
+
4
+ describe("BunTestReport", () => {
5
+ test("reads the final summary and attributes each failure to the file header above it", () => {
6
+ const report = new BunTestReport(
7
+ [
8
+ "bun test v1.4.2 (744846f84)",
9
+ "",
10
+ "server\\artifact\\routeSeedIndex.test.ts:",
11
+ "(pass) route seed index > keeps seeds [0.40ms]",
12
+ "(fail) route seed index > restores relative seed paths [1.20ms]",
13
+ "",
14
+ "lib/linter.test.ts:",
15
+ "\u001b[31m(fail) Linter > finds biome [3.00ms]\u001b[0m",
16
+ "",
17
+ "2 tests failed:",
18
+ "(fail) route seed index > restores relative seed paths [1.20ms]",
19
+ "(fail) Linter > finds biome [3.00ms]",
20
+ "",
21
+ " 1 pass",
22
+ " 3 skip",
23
+ " 2 fail",
24
+ " 1 error",
25
+ "Ran 6 tests across 2 files. [120.00ms]",
26
+ ].join("\r\n"),
27
+ );
28
+
29
+ expect(report.hasSummary).toBe(true);
30
+ expect([report.pass, report.fail, report.skip, report.errors, report.files]).toEqual([1, 2, 3, 1, 2]);
31
+ expect(report.failures).toEqual([
32
+ { file: "server/artifact/routeSeedIndex.test.ts", name: "route seed index > restores relative seed paths" },
33
+ { file: "lib/linter.test.ts", name: "Linter > finds biome" },
34
+ ]);
35
+ expect(report.passed).toBe(false);
36
+ });
37
+
38
+ test("a run with no summary is not passed, and counts what it saw before it stopped", () => {
39
+ const report = new BunTestReport(
40
+ [
41
+ "server/akanApp.test.ts:",
42
+ "(pass) AkanApp > boots [1ms]",
43
+ "(pass) AkanApp > relays [2ms]",
44
+ "panic(main thread)",
45
+ ].join("\n"),
46
+ );
47
+
48
+ expect(report.hasSummary).toBe(false);
49
+ expect(report.passed).toBe(false);
50
+ expect(report.pass).toBe(2);
51
+ expect(report.lastFile).toBe("server/akanApp.test.ts");
52
+ });
53
+
54
+ test("a clean summary passes", () => {
55
+ const report = new BunTestReport(" 4 pass\n 0 fail\nRan 4 tests across 1 file. [12.00ms]\n");
56
+
57
+ expect(report.passed).toBe(true);
58
+ expect(report.files).toBe(1);
59
+ });
60
+ });
@@ -0,0 +1,65 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+
3
+ export interface BunTestFailure {
4
+ file: string | null;
5
+ name: string;
6
+ }
7
+
8
+ export class BunTestReport {
9
+ static readonly #fileHeaderPattern = /^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/;
10
+ //? After the last file, bun repeats every failure under an "N tests failed:" line — those belong to no header.
11
+ static readonly #relistPattern = /^\d+ tests? failed:$/;
12
+ static readonly #durationSuffix = /\s+\[[\d.]+m?s\]$/;
13
+
14
+ readonly pass: number;
15
+ readonly fail: number;
16
+ readonly skip: number;
17
+ readonly errors: number;
18
+ readonly files: number;
19
+ readonly hasSummary: boolean;
20
+ readonly failures: BunTestFailure[];
21
+ readonly lastFile: string | null;
22
+
23
+ constructor(output: string) {
24
+ const lines = stripVTControlCharacters(output).split(/\r?\n/);
25
+ let currentFile: string | null = null;
26
+ let lastFile: string | null = null;
27
+ let relisted = false;
28
+ const failures = new Map<string, BunTestFailure>();
29
+ for (const line of lines) {
30
+ if (BunTestReport.#relistPattern.test(line)) relisted = true;
31
+ const header = BunTestReport.#fileHeaderPattern.exec(line);
32
+ if (header?.[1] && !relisted) {
33
+ currentFile = header[1].replaceAll("\\", "/");
34
+ lastFile = currentFile;
35
+ continue;
36
+ }
37
+ if (!line.startsWith("(fail) ")) continue;
38
+ const name = line.slice("(fail) ".length).replace(BunTestReport.#durationSuffix, "");
39
+ if (!failures.has(name)) failures.set(name, { file: relisted ? null : currentFile, name });
40
+ }
41
+ const ran = lines.findLast((line) => /^Ran \d+ tests? across \d+ files?/.test(line));
42
+ this.hasSummary = !!ran;
43
+ this.files = ran ? Number(/across (\d+) files?/.exec(ran)?.[1] ?? 0) : 0;
44
+ this.failures = [...failures.values()];
45
+ this.lastFile = lastFile;
46
+ this.pass = ran
47
+ ? BunTestReport.#lastCount(lines, /^\s*(\d+) pass$/)
48
+ : lines.filter((line) => line.startsWith("(pass) ")).length;
49
+ this.fail = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) fail$/) : this.failures.length;
50
+ this.skip = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) skip$/) : 0;
51
+ this.errors = ran ? BunTestReport.#lastCount(lines, /^\s*(\d+) errors?$/) : 0;
52
+ }
53
+
54
+ get passed() {
55
+ return this.hasSummary && this.fail === 0 && this.errors === 0;
56
+ }
57
+
58
+ static #lastCount(lines: string[], pattern: RegExp) {
59
+ for (const line of lines.toReversed()) {
60
+ const count = pattern.exec(line)?.[1];
61
+ if (count) return Number(count);
62
+ }
63
+ return 0;
64
+ }
65
+ }
@@ -0,0 +1,64 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { IdleWatchedProcess } from "./IdleWatchedProcess";
6
+
7
+ const roots: string[] = [];
8
+ const makeLogPath = async () => {
9
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-idle-watch-"));
10
+ roots.push(root);
11
+ return path.join(root, "nested", "run.log");
12
+ };
13
+
14
+ afterAll(async () => {
15
+ await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
16
+ });
17
+
18
+ describe("IdleWatchedProcess", () => {
19
+ test("tees the output of a command that finishes into the log and reports its exit code", async () => {
20
+ const logPath = await makeLogPath();
21
+ const result = await new IdleWatchedProcess(
22
+ [process.execPath, "--eval", "console.log('out'); console.error('err'); process.exit(3)"],
23
+ { logPath, idleMs: 10_000 },
24
+ ).run();
25
+
26
+ expect(result.exitCode).toBe(3);
27
+ expect(result.hung).toBe(false);
28
+ expect(result.output).toContain("out");
29
+ expect(result.output).toContain("err");
30
+ expect(await readFile(logPath, "utf8")).toContain("out");
31
+ });
32
+
33
+ test("gives up on a command that goes quiet, after asking the caller to clean up", async () => {
34
+ const logPath = await makeLogPath();
35
+ let cleanedUp = false;
36
+ const result = await new IdleWatchedProcess(
37
+ [process.execPath, "--eval", "console.log('started'); setInterval(() => {}, 1000)"],
38
+ {
39
+ logPath,
40
+ idleMs: 400,
41
+ onIdle: async () => {
42
+ cleanedUp = true;
43
+ },
44
+ },
45
+ ).run();
46
+
47
+ expect(result.hung).toBe(true);
48
+ expect(result.exitCode).toBeNull();
49
+ expect(cleanedUp).toBe(true);
50
+ expect(result.output).toContain("started");
51
+ });
52
+
53
+ test("a command that keeps talking is not idle, but still stops at the total budget", async () => {
54
+ const logPath = await makeLogPath();
55
+ const result = await new IdleWatchedProcess(
56
+ [process.execPath, "--eval", "setInterval(() => console.log('tick'), 50)"],
57
+ { logPath, idleMs: 5_000, totalMs: 500 },
58
+ ).run();
59
+
60
+ expect(result.hung).toBe(false);
61
+ expect(result.timedOut).toBe(true);
62
+ expect(result.exitCode).toBeNull();
63
+ });
64
+ });
@@ -0,0 +1,77 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface IdleWatchedResult {
5
+ exitCode: number | null;
6
+ hung: boolean;
7
+ timedOut: boolean;
8
+ output: string;
9
+ ms: number;
10
+ }
11
+
12
+ interface IdleWatchedOptions {
13
+ logPath: string;
14
+ idleMs: number;
15
+ totalMs?: number;
16
+ cwd?: string;
17
+ env?: Record<string, string | undefined>;
18
+ onIdle?: () => Promise<void>;
19
+ onOutput?: (text: string) => void;
20
+ }
21
+
22
+ /**
23
+ * Runs a command with its output teed into a log, and gives up on it once the output goes quiet.
24
+ * Silence is the only reliable hang signal: a runner stuck in a synchronous loop (Bun 1.4.2's Windows
25
+ * websocket crash spun at 100% CPU) never lets bun's own per-test timeout fire.
26
+ */
27
+ export class IdleWatchedProcess {
28
+ readonly #command: string[];
29
+ readonly #options: IdleWatchedOptions;
30
+
31
+ constructor(command: string[], options: IdleWatchedOptions) {
32
+ this.#command = command;
33
+ this.#options = options;
34
+ }
35
+
36
+ async run(): Promise<IdleWatchedResult> {
37
+ const { logPath, idleMs, totalMs, cwd, env, onIdle, onOutput } = this.#options;
38
+ await mkdir(path.dirname(logPath), { recursive: true });
39
+ const log = Bun.file(logPath).writer();
40
+ const startedAt = performance.now();
41
+ let lastOutputAt = startedAt;
42
+ let output = "";
43
+ let hung = false;
44
+ let timedOut = false;
45
+ const proc = Bun.spawn(this.#command, { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
46
+ const pump = async (stream: ReadableStream<Uint8Array>) => {
47
+ const decoder = new TextDecoder();
48
+ for await (const chunk of stream) {
49
+ lastOutputAt = performance.now();
50
+ const text = decoder.decode(chunk, { stream: true });
51
+ output += text;
52
+ log.write(chunk);
53
+ onOutput?.(text);
54
+ }
55
+ };
56
+ const pumping = Promise.all([pump(proc.stdout), pump(proc.stderr)]);
57
+ const watch = setInterval(
58
+ () => {
59
+ const now = performance.now();
60
+ if (now - lastOutputAt >= idleMs) hung = true;
61
+ else if (totalMs !== undefined && now - startedAt >= totalMs) timedOut = true;
62
+ else return;
63
+ clearInterval(watch);
64
+ void (async () => {
65
+ await onIdle?.().catch(() => undefined);
66
+ proc.kill();
67
+ })();
68
+ },
69
+ Math.max(50, Math.min(1000, idleMs / 4)),
70
+ );
71
+ const exitCode = await proc.exited;
72
+ clearInterval(watch);
73
+ await pumping;
74
+ await log.end();
75
+ return { exitCode: hung || timedOut ? null : exitCode, hung, timedOut, output, ms: performance.now() - startedAt };
76
+ }
77
+ }