@akanjs/devkit 3.2.0-rc.0 → 3.2.0-rc.10

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 (110) hide show
  1. package/CHANGELOG.md +238 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/createTunnel.test.ts +12 -2
  44. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  45. package/errorCodeReference.test.ts +282 -0
  46. package/errorCodeReference.ts +71 -0
  47. package/errorCodeScan.ts +551 -0
  48. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  49. package/frontendBuild/allRoutesBuilder.ts +24 -6
  50. package/frontendBuild/buildRouteClient.test.ts +43 -0
  51. package/frontendBuild/bundleDefine.ts +2 -0
  52. package/frontendBuild/clientBuildTypes.ts +5 -2
  53. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  54. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  55. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  56. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  57. package/frontendBuild/csrDevBundler.ts +12 -2
  58. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  59. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  60. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  61. package/frontendBuild/csrDevPatcher.ts +47 -7
  62. package/frontendBuild/csrDevTypes.ts +16 -0
  63. package/frontendBuild/cssCompiler.test.ts +63 -1
  64. package/frontendBuild/devBarrelImports.ts +132 -0
  65. package/frontendBuild/devRegistryBundler.ts +19 -4
  66. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  67. package/frontendBuild/nativeCore.test.ts +148 -0
  68. package/frontendBuild/precompressArtifacts.ts +1 -1
  69. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  70. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  71. package/frontendBuild/ssrDevBundler.ts +13 -2
  72. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  73. package/frontendBuild/vendorImportScanner.ts +25 -10
  74. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  75. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  76. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  77. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  78. package/lint/no-import-common-err.grit +31 -0
  79. package/lint/no-init-fetch-in-client.grit +1 -1
  80. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  81. package/maintainabilityBaseline.test.ts +49 -0
  82. package/maintainabilityBaseline.ts +73 -8
  83. package/maintainabilityContract.test.ts +302 -1
  84. package/maintainabilityContract.ts +87 -22
  85. package/maintainabilityExceptionScanner.test.ts +14 -0
  86. package/maintainabilityExceptionScanner.ts +5 -1
  87. package/maintainabilityLimits.ts +12 -0
  88. package/maintainabilityReport.ts +20 -3
  89. package/moduleFieldNames.test.ts +56 -0
  90. package/moduleFieldNames.ts +25 -0
  91. package/package.json +3 -3
  92. package/preview/PreviewChrome.ts +3 -3
  93. package/preview/PreviewView.ts +48 -9
  94. package/preview/previewView.test.ts +154 -3
  95. package/routeJsBudget.test.ts +47 -11
  96. package/routeJsBudget.ts +88 -25
  97. package/transforms/barrelAnalyzer.ts +46 -1
  98. package/transforms/barrelImportsPlugin.ts +61 -45
  99. package/transforms/transforms.test.ts +67 -1
  100. package/typecheck/projectTypecheck.ts +5 -1
  101. package/typecheck/typecheck.proc.ts +3 -2
  102. package/typecheck/typecheckFailure.ts +6 -1
  103. package/workflow/artifacts.ts +25 -7
  104. package/workflow/fieldType.ts +92 -0
  105. package/workflow/index.ts +2 -0
  106. package/workflow/plainName.ts +28 -0
  107. package/workflow/plan.ts +67 -22
  108. package/workflow/source.ts +0 -5
  109. package/workspaceGraph.test.ts +200 -3
  110. package/workspaceGraph.ts +46 -8
@@ -1,9 +1,50 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { PreviewChrome } from "./PreviewChrome";
2
6
  import { PreviewView } from "./PreviewView";
3
7
 
4
8
  const serve = (html: string, status = 200) =>
5
9
  Bun.serve({ port: 0, fetch: () => new Response(html, { status, headers: { "content-type": "text/html" } }) });
6
10
 
11
+ const browserTestMs = PreviewView.startTimeoutMs + 30_000;
12
+ const slowChromeRuns = PreviewView.isAvailable("chrome") && process.platform !== "win32";
13
+
14
+ const withSlowChrome = async (body: string, startSeconds = 3) => {
15
+ const dir = await mkdtemp(path.join(tmpdir(), "akan-slow-chrome-"));
16
+ const chrome = path.join(dir, "chrome");
17
+ const chromeLog = path.join(dir, "chrome.log");
18
+ //? Bun.WebView sends the browser's stderr to /dev/null, which would hide why it died.
19
+ await writeFile(
20
+ chrome,
21
+ `#!/bin/sh\nexec 2>>"${chromeLog}"\nsleep ${startSeconds}\nexec "${PreviewChrome.executable()}" "$@"\n`,
22
+ );
23
+ await chmod(chrome, 0o755);
24
+ const script = `const { PreviewView } = await import(${JSON.stringify(path.join(import.meta.dir, "PreviewView.ts"))});\n${body}`;
25
+ //? Chrome is spawned once per process, so only a fresh process takes the slow executable.
26
+ const child = Bun.spawn([process.execPath, "-e", script], {
27
+ env: { ...process.env, [PreviewChrome.pathEnvKey]: chrome },
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ timeout: PreviewView.startTimeoutMs + 15_000,
31
+ });
32
+ try {
33
+ const [out, err, code] = await Promise.all([
34
+ new Response(child.stdout).text(),
35
+ new Response(child.stderr).text(),
36
+ child.exited,
37
+ ]);
38
+ const chromeErr = await Bun.file(chromeLog)
39
+ .text()
40
+ .catch(() => "");
41
+ return { code, out: out.trim(), log: `${err}\nChrome's stderr:\n${chromeErr}` };
42
+ } finally {
43
+ child.kill();
44
+ await rm(dir, { recursive: true, force: true });
45
+ }
46
+ };
47
+
7
48
  describe("PreviewView", () => {
8
49
  test("reports whether this Bun build can drive a headless page at all", () => {
9
50
  expect(typeof PreviewView.available).toBe("boolean");
@@ -29,7 +70,7 @@ describe("PreviewView", () => {
29
70
  server.stop(true);
30
71
  }
31
72
  },
32
- 30_000,
73
+ browserTestMs,
33
74
  );
34
75
 
35
76
  test.skipIf(!PreviewView.available)(
@@ -56,7 +97,7 @@ describe("PreviewView", () => {
56
97
  server.stop(true);
57
98
  }
58
99
  },
59
- 30_000,
100
+ browserTestMs,
60
101
  );
61
102
 
62
103
  test.skipIf(!PreviewView.available)(
@@ -71,6 +112,116 @@ describe("PreviewView", () => {
71
112
  server.stop(true);
72
113
  }
73
114
  },
74
- 30_000,
115
+ browserTestMs,
116
+ );
117
+
118
+ test.skipIf(!slowChromeRuns)(
119
+ "starts the page's bound once the browser is up, however long Chrome takes to spawn",
120
+ async () => {
121
+ const server = serve("<!doctype html><title>Up</title><body>up</body>");
122
+ try {
123
+ const { code, out, log } = await withSlowChrome(
124
+ `const probe = await PreviewView.probe("http://localhost:${server.port}/", { engine: "chrome", timeoutMs: 1_500 });
125
+ console.log(probe.text);`,
126
+ );
127
+ expect(out, log).toBe("up");
128
+ expect(code, log).toBe(0);
129
+ } finally {
130
+ server.stop(true);
131
+ }
132
+ },
133
+ browserTestMs,
134
+ );
135
+
136
+ test.skipIf(!slowChromeRuns)(
137
+ "gives a later page only its own bound for a browser that missed its start, and recovers once it is up",
138
+ async () => {
139
+ const { code, out, log } = await withSlowChrome(
140
+ `const open = () =>
141
+ PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
142
+ () => "opened",
143
+ (error) => error.message,
144
+ );
145
+ console.log(await open());
146
+ console.log(await open());
147
+ let late = await open();
148
+ for (let tries = 0; late.startsWith("Timed out starting") && tries < 100; tries++) late = await open();
149
+ console.log(late);
150
+ Bun.WebView.closeAll();
151
+ await Bun.sleep(500);
152
+ console.log(await open());
153
+ Bun.WebView.closeAll();`,
154
+ 6,
155
+ );
156
+ expect(out, log).toBe(
157
+ [
158
+ "Timed out starting the chrome browser after 1.5 s.",
159
+ "Timed out starting the chrome browser after 1 s.",
160
+ "opened",
161
+ "Timed out starting the chrome browser after 1.5 s.",
162
+ ].join("\n"),
163
+ );
164
+ expect(code, log).toBe(0);
165
+ },
166
+ browserTestMs,
167
+ );
168
+
169
+ test.skipIf(!slowChromeRuns)(
170
+ "gives a browser killed after it missed its start the full bound for its next start",
171
+ async () => {
172
+ const { code, out, log } = await withSlowChrome(
173
+ `const open = () =>
174
+ PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
175
+ () => "opened",
176
+ (error) => error.message,
177
+ );
178
+ console.log(await open());
179
+ const killed = open();
180
+ Bun.WebView.closeAll();
181
+ console.log(await killed);
182
+ Bun.WebView.closeAll();`,
183
+ 6,
184
+ );
185
+ expect(out, log).toBe(
186
+ [
187
+ "Timed out starting the chrome browser after 1.5 s.",
188
+ "Timed out starting the chrome browser after 1.5 s.",
189
+ ].join("\n"),
190
+ );
191
+ expect(code, log).toBe(0);
192
+ },
193
+ browserTestMs,
194
+ );
195
+
196
+ test.skipIf(!slowChromeRuns)(
197
+ "starts a new browser for the page right after one that the browser died on",
198
+ async () => {
199
+ const { code, out, log } = await withSlowChrome(
200
+ `const { promise: loading, resolve } = Promise.withResolvers();
201
+ const server = Bun.serve({
202
+ port: 0,
203
+ fetch: (req) =>
204
+ new URL(req.url).pathname === "/hang"
205
+ ? (resolve(), new Promise(() => undefined))
206
+ : new Response("<!doctype html><body>up</body>", { headers: { "content-type": "text/html" } }),
207
+ });
208
+ const open = (path) =>
209
+ PreviewView.probe(\`http://localhost:\${server.port}\${path}\`, { engine: "chrome" }).then(
210
+ (probe) => probe.text,
211
+ (error) => error.message,
212
+ );
213
+ const killed = open("/hang");
214
+ await loading;
215
+ Bun.WebView.closeAll();
216
+ console.error(await killed);
217
+ console.log(await open("/"));
218
+ server.stop(true);
219
+ Bun.WebView.closeAll();`,
220
+ 0,
221
+ );
222
+ expect(out, log).toBe("up");
223
+ expect(code, log).toBe(0);
224
+ },
225
+ browserTestMs,
75
226
  );
76
227
  });
@@ -96,30 +96,50 @@ describe("RouteJsBudget", () => {
96
96
  const setup = async (text?: string) => {
97
97
  const budgetPath = path.join(await makeTempRoot(), "akan.budget.json");
98
98
  if (text !== undefined) await write(budgetPath, text);
99
- return { budgetPath, source: "apps/shop/akan.budget.json", environment: "local" };
99
+ return { app: "shop", budgetPath, source: "apps/shop/akan.budget.json", environment: "local" };
100
100
  };
101
101
 
102
- test("enforce fails a route over its limit and a file it cannot read", async () => {
102
+ test("enforce fails a route over its limit, a route with no entry, a file it cannot read, and an app with no file", async () => {
103
103
  const over = await setup(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120 } }));
104
104
  await expect(RouteJsBudget.check(sizes, { ...over, mode: "enforce" })).rejects.toThrow(
105
105
  "1 route over the JS budget in apps/shop/akan.budget.json (recorded for main, built for local):",
106
106
  );
107
+ await expect(RouteJsBudget.check(sizes, { ...over, mode: "enforce" })).rejects.toThrow(
108
+ "in apps/shop/akan.budget.json by hand.\n1 route with no entry in apps/shop/akan.budget.json",
109
+ );
110
+ const missing = await setup(JSON.stringify({ routeJsBrotliKB: { "/:lang/memo": 130 } }));
111
+ await expect(RouteJsBudget.check(sizes, { ...missing, mode: "enforce" })).rejects.toThrow(
112
+ [
113
+ "1 route with no entry in apps/shop/akan.budget.json, which --check-budget checks every route against:",
114
+ " /:lang/new",
115
+ "Record it in the same change: `akan build shop --update-budget` adds each new route at its measured size.",
116
+ ].join("\n"),
117
+ );
107
118
  const broken = await setup("{");
108
119
  await expect(RouteJsBudget.check(sizes, { ...broken, mode: "enforce" })).rejects.toThrow("is not valid JSON");
120
+ await expect(RouteJsBudget.check(sizes, { ...(await setup()), mode: "enforce" })).rejects.toThrow(
121
+ "--check-budget found no apps/shop/akan.budget.json: record one with `akan build shop --update-budget`.",
122
+ );
109
123
  });
110
124
 
111
- test("report warns of an overage and a file it cannot read, and fails neither", async () => {
125
+ test("report warns of an overage, a route with no entry and a file it cannot read, and fails none", async () => {
126
+ const unbudgetedWarning = {
127
+ file: "apps/shop/akan.budget.json",
128
+ message: "/:lang/new has no entry; akan build --check-budget fails on it",
129
+ fix: "record it with akan build shop --update-budget",
130
+ };
112
131
  const over = await setup(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120 } }));
113
132
  expect(await RouteJsBudget.check(sizes, { ...over, mode: "report" })).toEqual({
114
133
  summary:
115
- "1 route(s) over apps/shop/akan.budget.json (recorded for main, built for local), reported only: /:lang/memo",
134
+ "1 route(s) over apps/shop/akan.budget.json (recorded for main, built for local), reported only: /:lang/memo; 1 route(s) without an entry",
116
135
  warnings: [
117
136
  {
118
137
  file: "apps/shop/akan.budget.json",
119
138
  message:
120
- "/:lang/memo loads 133,120 bytes brotli, over its limit of 126,976 (recorded for main, built for local); akan build fails on it",
121
- fix: "shrink what the route loads, or raise its entry by hand in the same change",
139
+ "/:lang/memo loads 133,120 bytes brotli, over its limit of 126,976 (recorded for main, built for local); akan build --check-budget fails on it",
140
+ fix: "shrink what the route loads, or raise its entry with akan build shop --raise-budget",
122
141
  },
142
+ unbudgetedWarning,
123
143
  ],
124
144
  });
125
145
  const broken = await setup("{");
@@ -128,8 +148,8 @@ describe("RouteJsBudget", () => {
128
148
  warnings: [
129
149
  {
130
150
  file: "apps/shop/akan.budget.json",
131
- message: "apps/shop/akan.budget.json is not valid JSON; akan build fails on it",
132
- fix: "correct it, or rewrite it with akan build <app> --update-budget",
151
+ message: "apps/shop/akan.budget.json is not valid JSON; akan build --check-budget fails on it",
152
+ fix: "correct it, or rewrite it with akan build shop --update-budget",
133
153
  },
134
154
  ],
135
155
  });
@@ -138,7 +158,8 @@ describe("RouteJsBudget", () => {
138
158
  });
139
159
  const within = await setup(JSON.stringify({ routeJsBrotliKB: { "/:lang/memo": 130 } }));
140
160
  expect(await RouteJsBudget.check(sizes, { ...within, mode: "report" })).toEqual({
141
- summary: "within apps/shop/akan.budget.json, 1 route(s) without an entry",
161
+ summary: "within apps/shop/akan.budget.json; 1 route(s) without an entry",
162
+ warnings: [unbudgetedWarning],
142
163
  });
143
164
  });
144
165
 
@@ -157,20 +178,35 @@ describe("RouteJsBudget", () => {
157
178
  await expect(RouteJsBudget.check(sizes, { ...over, mode: "update" })).rejects.toThrow("/:lang/memo");
158
179
  expect(RouteJsBudget.parse(await Bun.file(over.budgetPath).text()).kbByRoute.get("/:lang/memo")).toBe(120);
159
180
  });
181
+
182
+ test("raise re-records every route at its measured size and names the entries it raised", async () => {
183
+ const over = await setup(
184
+ JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120, "/:lang/new": 90 } }),
185
+ );
186
+ expect(await RouteJsBudget.check(sizes, { ...over, mode: "raise" })).toEqual({
187
+ summary: "apps/shop/akan.budget.json re-recorded at each route's measured size, raised: /:lang/memo",
188
+ });
189
+ const written = RouteJsBudget.parse(await Bun.file(over.budgetPath).text());
190
+ expect(written.env).toBe("local");
191
+ expect(Object.fromEntries(written.kbByRoute)).toEqual({ "/:lang/memo": 130, "/:lang/new": 70 });
192
+ expect(await RouteJsBudget.check(sizes, { ...over, mode: "enforce" })).toEqual({
193
+ summary: "within apps/shop/akan.budget.json",
194
+ });
195
+ });
160
196
  });
161
197
 
162
198
  test("names each route over budget, its limit, and how to resolve it", () => {
163
199
  expect(
164
200
  RouteJsBudget.formatOverages(
165
201
  [{ routeId: "/:lang/memo", brotliBytes: 128_000, budgetKB: 120, limitBytes: 126_976 }],
166
- "apps/shop/akan.budget.json",
202
+ { app: "shop", source: "apps/shop/akan.budget.json" },
167
203
  " (recorded for main, built for local)",
168
204
  ),
169
205
  ).toBe(
170
206
  [
171
207
  "1 route over the JS budget in apps/shop/akan.budget.json (recorded for main, built for local):",
172
208
  " /:lang/memo: 128,000 bytes brotli, over its limit of 126,976 (120KB + the larger of 1% and 4KB)",
173
- "Shrink what the route loads. If the growth is intended, raise its entry by hand in the same change.",
209
+ 'Shrink what the route loads. If the growth is intended, record it in the same change: `akan build shop --raise-budget` re-records every route at its measured size, or raise the route\'s entry under "routeJsBrotliKB" in apps/shop/akan.budget.json by hand.',
174
210
  ].join("\n"),
175
211
  );
176
212
  });
package/routeJsBudget.ts CHANGED
@@ -17,7 +17,7 @@ export interface RouteJsOverage {
17
17
  limitBytes: number;
18
18
  }
19
19
 
20
- export type RouteJsBudgetMode = "enforce" | "update" | "report" | "off";
20
+ export type RouteJsBudgetMode = "enforce" | "update" | "raise" | "report" | "off";
21
21
 
22
22
  export interface RouteJsBudgetWarning {
23
23
  file: string;
@@ -30,8 +30,20 @@ export interface RouteJsBudgetStatus {
30
30
  warnings?: RouteJsBudgetWarning[];
31
31
  }
32
32
 
33
+ export interface RouteJsBudgetFlags {
34
+ checkBudget?: boolean;
35
+ updateBudget?: boolean;
36
+ raiseBudget?: boolean;
37
+ }
38
+
39
+ interface BudgetTarget {
40
+ app: string;
41
+ source: string;
42
+ }
43
+
33
44
  export interface RouteJsBudgetCheck {
34
45
  mode: Exclude<RouteJsBudgetMode, "off">;
46
+ app: string;
35
47
  budgetPath: string;
36
48
  source: string;
37
49
  environment: string;
@@ -65,47 +77,85 @@ export class RouteJsBudget {
65
77
  .sort((a, b) => b.brotliBytes - a.brotliBytes || RouteJsBudget.#byCodeUnit(a.routeId, b.routeId));
66
78
  }
67
79
 
80
+ static modeOf({ checkBudget = false, updateBudget = false, raiseBudget = false }: RouteJsBudgetFlags) {
81
+ if ([checkBudget, updateBudget, raiseBudget].filter(Boolean).length > 1)
82
+ throw new Error("Pass at most one of --check-budget, --update-budget and --raise-budget.");
83
+ return checkBudget ? "enforce" : updateBudget ? "update" : raiseBudget ? "raise" : "report";
84
+ }
85
+
68
86
  static async load(filePath: string, source = filePath): Promise<RouteJsBudget | null> {
69
87
  const file = Bun.file(filePath);
70
88
  if (!(await file.exists())) return null;
71
89
  return RouteJsBudget.parse(await file.text(), source);
72
90
  }
73
91
 
74
- //? `report` runs inside `akan verify`, where an overage or a broken file is no code error to fix in the edit loop.
75
- static async check(
76
- sizes: RouteJsSize[],
77
- { mode, budgetPath, source, environment }: RouteJsBudgetCheck,
78
- ): Promise<RouteJsBudgetStatus> {
92
+ //? A deploy build and `akan verify`'s edit loop only report: `enforce` (`--check-budget`, run by CI) is the gate.
93
+ static async check(sizes: RouteJsSize[], check: RouteJsBudgetCheck): Promise<RouteJsBudgetStatus> {
94
+ const { mode, app, budgetPath, source, environment } = check;
79
95
  const { committed, unreadable } = await RouteJsBudget.#loadFor(mode, budgetPath, source);
80
- const budget = mode === "update" ? (committed ?? new RouteJsBudget()).tightenedBy(sizes, environment) : committed;
81
- if (mode === "update" && budget) await Bun.write(budgetPath, budget.stringify());
96
+ if (mode === "enforce" && !committed)
97
+ throw new Error(`--check-budget found no ${source}: record one with \`akan build ${app} --update-budget\`.`);
98
+ const budget =
99
+ mode === "raise"
100
+ ? new RouteJsBudget().tightenedBy(sizes, environment)
101
+ : mode === "update"
102
+ ? (committed ?? new RouteJsBudget()).tightenedBy(sizes, environment)
103
+ : committed;
104
+ if ((mode === "update" || mode === "raise") && budget) await Bun.write(budgetPath, budget.stringify());
82
105
  const overages = budget?.overages(sizes) ?? [];
106
+ const unbudgeted = budget?.unbudgeted(sizes) ?? [];
83
107
  const envNote = committed?.envNote(environment) ?? "";
84
- if (overages.length && mode !== "report") throw new Error(RouteJsBudget.formatOverages(overages, source, envNote));
85
- const unbudgeted = budget?.unbudgeted(sizes).length ?? 0;
86
- if (mode === "update") return { summary: `${source} ${unreadable ? `rewritten (${unreadable})` : "updated"}` };
108
+ const target = { app, source };
109
+ const failures = [
110
+ ...(overages.length && mode !== "report" ? [RouteJsBudget.formatOverages(overages, target, envNote)] : []),
111
+ ...(unbudgeted.length && mode === "enforce" ? [RouteJsBudget.formatUnbudgeted(unbudgeted, target)] : []),
112
+ ];
113
+ if (failures.length) throw new Error(failures.join("\n"));
114
+ if (mode === "update" || mode === "raise")
115
+ return { summary: RouteJsBudget.#writtenSummary(check, unreadable, committed, budget) };
87
116
  if (unreadable)
88
117
  return {
89
118
  summary: `${unreadable}, not checked`,
90
119
  warnings: [
91
120
  {
92
121
  file: source,
93
- message: `${unreadable}; akan build fails on it`,
94
- fix: "correct it, or rewrite it with akan build <app> --update-budget",
122
+ message: `${unreadable}; akan build --check-budget fails on it`,
123
+ fix: `correct it, or rewrite it with akan build ${app} --update-budget`,
95
124
  },
96
125
  ],
97
126
  };
98
127
  if (!budget) return { summary: `no ${RouteJsBudget.fileName}` };
99
- if (overages.length)
100
- return {
101
- summary: `${overages.length} route(s) over ${source}${envNote}, reported only: ${overages.map(({ routeId }) => routeId).join(", ")}`,
102
- warnings: overages.map(({ routeId, brotliBytes, limitBytes }) => ({
103
- file: source,
104
- message: `${routeId} loads ${RouteJsBudget.#bytes(brotliBytes)} bytes brotli, over its limit of ${RouteJsBudget.#bytes(limitBytes)}${envNote}; akan build fails on it`,
105
- fix: "shrink what the route loads, or raise its entry by hand in the same change",
106
- })),
107
- };
108
- return { summary: `within ${source}${envNote}${unbudgeted ? `, ${unbudgeted} route(s) without an entry` : ""}` };
128
+ const warnings = [
129
+ ...overages.map(({ routeId, brotliBytes, limitBytes }) => ({
130
+ file: source,
131
+ message: `${routeId} loads ${RouteJsBudget.#bytes(brotliBytes)} bytes brotli, over its limit of ${RouteJsBudget.#bytes(limitBytes)}${envNote}; akan build --check-budget fails on it`,
132
+ fix: `shrink what the route loads, or raise its entry with akan build ${app} --raise-budget`,
133
+ })),
134
+ ...unbudgeted.map((routeId) => ({
135
+ file: source,
136
+ message: `${routeId} has no entry; akan build --check-budget fails on it`,
137
+ fix: `record it with akan build ${app} --update-budget`,
138
+ })),
139
+ ];
140
+ const verdict = overages.length
141
+ ? `${overages.length} route(s) over ${source}${envNote}, reported only: ${overages.map(({ routeId }) => routeId).join(", ")}`
142
+ : `within ${source}${envNote}`;
143
+ const unbudgetedNote = unbudgeted.length ? `; ${unbudgeted.length} route(s) without an entry` : "";
144
+ return { summary: `${verdict}${unbudgetedNote}`, ...(warnings.length ? { warnings } : {}) };
145
+ }
146
+
147
+ static #writtenSummary(
148
+ { mode, source }: RouteJsBudgetCheck,
149
+ unreadable: string | null,
150
+ committed: RouteJsBudget | null,
151
+ written: RouteJsBudget | null,
152
+ ) {
153
+ if (unreadable) return `${source} rewritten (${unreadable})`;
154
+ if (mode === "update") return `${source} updated`;
155
+ const raised = [...(written?.kbByRoute ?? [])].filter(
156
+ ([routeId, kb]) => kb > (committed?.kbByRoute.get(routeId) ?? kb),
157
+ );
158
+ return `${source} re-recorded at each route's measured size${raised.length ? `, raised: ${raised.map(([routeId]) => routeId).join(", ")}` : ""}`;
109
159
  }
110
160
 
111
161
  static async #loadFor(mode: RouteJsBudgetCheck["mode"], budgetPath: string, source: string) {
@@ -158,18 +208,31 @@ export class RouteJsBudget {
158
208
  ];
159
209
  }
160
210
 
161
- static formatOverages(overages: RouteJsOverage[], source: string, envNote = ""): string {
211
+ static formatOverages(overages: RouteJsOverage[], target: BudgetTarget, envNote = ""): string {
162
212
  const bytes = RouteJsBudget.#bytes;
213
+ const { source } = target;
163
214
  return [
164
215
  `${overages.length} route${overages.length === 1 ? "" : "s"} over the JS budget in ${source}${envNote}:`,
165
216
  ...overages.map(
166
217
  ({ routeId, brotliBytes, budgetKB, limitBytes }) =>
167
218
  ` ${routeId}: ${bytes(brotliBytes)} bytes brotli, over its limit of ${bytes(limitBytes)} (${budgetKB}KB + the larger of 1% and 4KB)`,
168
219
  ),
169
- "Shrink what the route loads. If the growth is intended, raise its entry by hand in the same change.",
220
+ RouteJsBudget.#overageFix(target),
221
+ ].join("\n");
222
+ }
223
+
224
+ static formatUnbudgeted(routeIds: string[], { app, source }: BudgetTarget): string {
225
+ return [
226
+ `${routeIds.length} route${routeIds.length === 1 ? "" : "s"} with no entry in ${source}, which --check-budget checks every route against:`,
227
+ ...routeIds.map((routeId) => ` ${routeId}`),
228
+ `Record ${routeIds.length === 1 ? "it" : "them"} in the same change: \`akan build ${app} --update-budget\` adds each new route at its measured size.`,
170
229
  ].join("\n");
171
230
  }
172
231
 
232
+ static #overageFix({ app, source }: BudgetTarget) {
233
+ return `Shrink what the route loads. If the growth is intended, record it in the same change: \`akan build ${app} --raise-budget\` re-records every route at its measured size, or raise the route's entry under "${RouteJsBudget.section}" in ${source} by hand.`;
234
+ }
235
+
173
236
  static #bytes(value: number) {
174
237
  return value.toLocaleString("en-US");
175
238
  }
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { Logger } from "akanjs/common";
3
4
  import { parseNamedList } from "./moduleSyntax";
@@ -41,6 +42,8 @@ export class BarrelAnalyzer {
41
42
  readonly #logger = new Logger("BarrelAnalyzer");
42
43
  readonly #opts: BarrelAnalyzerOptions;
43
44
  readonly #cache = new Map<string, Promise<BarrelExportMap | null>>();
45
+ readonly #read = new Map<string, Set<string>>();
46
+ readonly #realDirs = new Map<string, string>();
44
47
  readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
45
48
  readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
46
49
 
@@ -56,12 +59,44 @@ export class BarrelAnalyzer {
56
59
  return promise;
57
60
  }
58
61
 
62
+ async filesOf(pkgName: string): Promise<string[]> {
63
+ await this.analyze(pkgName);
64
+ return [...(this.#read.get(pkgName) ?? [])];
65
+ }
66
+
67
+ //? A file beside one the analysis read counts too: created, it can take over a star export's specifier (`Foo.tsx`
68
+ //? beside `Foo/index.tsx`) with no read file changed. Both sides by realpath: a watcher names a symlinked lib's
69
+ //? files by their real path, where the walk went through the link.
70
+ forget(files: string[]): Map<string, Promise<BarrelExportMap | null>> {
71
+ const dirs = new Set(files.map((file) => realDirOf(file)));
72
+ const forgotten = new Map<string, Promise<BarrelExportMap | null>>();
73
+ for (const [pkgName, read] of this.#read) {
74
+ const analysis = this.#cache.get(pkgName);
75
+ if (!analysis || ![...read].some((file) => dirs.has(this.#realDirOf(file)))) continue;
76
+ forgotten.set(pkgName, analysis);
77
+ this.#cache.delete(pkgName);
78
+ this.#read.delete(pkgName);
79
+ }
80
+ return forgotten;
81
+ }
82
+
83
+ #realDirOf(file: string): string {
84
+ let dir = this.#realDirs.get(file);
85
+ if (dir === undefined) {
86
+ dir = realDirOf(file);
87
+ this.#realDirs.set(file, dir);
88
+ }
89
+ return dir;
90
+ }
91
+
59
92
  async #analyzeSafe(pkgName: string): Promise<BarrelExportMap | null> {
60
93
  try {
61
94
  const pkg = await this.#opts.resolvePackage(pkgName);
62
95
  if (!pkg) return null;
63
96
  const map: BarrelExportMap = new Map();
64
- await this.#walk(pkg.entryFile, pkg, map, new Set());
97
+ const read = new Set<string>();
98
+ this.#read.set(pkgName, read);
99
+ await this.#walk(pkg.entryFile, pkg, map, read);
65
100
  return map;
66
101
  } catch (err) {
67
102
  this.#logger.error(`analyze failed for ${pkgName}: ${(err as Error).message}`);
@@ -170,6 +205,16 @@ export class BarrelAnalyzer {
170
205
  }
171
206
  }
172
207
 
208
+ const realDirOf = (file: string): string => {
209
+ const dir = path.dirname(path.resolve(file));
210
+ try {
211
+ return fs.realpathSync.native(dir);
212
+ } catch {
213
+ // A directory removed with the file keeps its own path.
214
+ return dir;
215
+ }
216
+ };
217
+
173
218
  const isRelative = (spec: string): boolean => {
174
219
  return spec.startsWith("./") || spec.startsWith("../") || spec === "." || spec === "..";
175
220
  };