@akanjs/devkit 3.1.2-rc.3 → 3.2.0-rc.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.
- package/CHANGELOG.md +348 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +2 -2
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +54 -0
- package/applicationBuildReporter.ts +12 -0
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +77 -14
- package/artifact/implicitRootLayout.test.ts +42 -0
- package/artifact/implicitRootLayout.ts +5 -6
- package/artifact/storeBootstrap.ts +24 -0
- package/cloud/cloudApi.test.ts +70 -7
- package/cloud/cloudApi.ts +10 -4
- package/cloud/constants.ts +3 -0
- package/cloud/globalConfig.test.ts +36 -0
- package/cloud/globalConfig.ts +10 -1
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-page/SKILL.md +30 -2
- package/codeAgent/skills/akan-service/SKILL.md +77 -1
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/declarationReader.ts +211 -0
- package/executors.test.ts +65 -8
- package/executors.ts +5 -3
- package/frameworkGraph.test.ts +121 -0
- package/frameworkGraph.ts +165 -0
- package/frontendBuild/allRoutesBuilder.test.ts +84 -0
- package/frontendBuild/allRoutesBuilder.ts +32 -12
- package/frontendBuild/buildRouteClient.test.ts +192 -2
- package/frontendBuild/bundleDefine.ts +4 -0
- package/frontendBuild/clientBuildTypes.ts +7 -1
- package/frontendBuild/clientEntriesBundler.test.ts +82 -0
- package/frontendBuild/clientEntriesBundler.ts +55 -4
- package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/frontendBuild/fontOptimizer.test.ts +54 -0
- package/frontendBuild/fontOptimizer.ts +5 -4
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/routeClientBuilder.ts +6 -2
- package/frontendBuild/routeModulePreloads.test.ts +59 -0
- package/frontendBuild/routeModulePreloads.ts +52 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
- package/frontendBuild/ssrDevBundler.test.ts +26 -0
- package/frontendBuild/ssrDevBundler.ts +2 -1
- package/frontendBuild/vendorImportScanner.test.ts +71 -0
- package/frontendBuild/vendorImportScanner.ts +80 -0
- package/frontendBuild/vendorNarrowing.test.ts +103 -0
- package/frontendBuild/vendorNarrowing.ts +64 -0
- package/frontendBuild/vendorSpecifiers.ts +13 -0
- package/gitRevision.test.ts +82 -0
- package/gitRevision.ts +128 -0
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
- package/lint/no-daisyui-legacy-class.grit +9 -7
- package/lint/no-deep-internal-import.grit +1 -1
- package/lint/no-js-private-class-method.grit +9 -28
- package/maintainabilityBaseline.ts +7 -2
- package/maintainabilityContract.test.ts +183 -0
- package/maintainabilityContract.ts +145 -30
- package/maintainabilityExceptionScanner.test.ts +13 -2
- package/maintainabilityExceptionScanner.ts +16 -6
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.test.ts +35 -0
- package/maintainabilityReport.ts +29 -8
- package/mobile/nativePublicEnv.test.ts +5 -1
- package/package.json +3 -3
- package/routeJsBudget.test.ts +177 -0
- package/routeJsBudget.ts +222 -0
- package/structureScanner.test.ts +167 -0
- package/structureScanner.ts +164 -0
- package/transforms/barrelAnalyzer.ts +10 -3
- package/transforms/barrelImportsPlugin.ts +13 -7
- package/transforms/serverEnvPlugin.test.ts +1 -1
- package/transforms/serverEnvPlugin.ts +1 -1
- package/transforms/transforms.test.ts +64 -12
- package/transforms/tsconfigPackageResolver.test.ts +12 -0
- package/typeChecker.test.ts +100 -0
- package/typeChecker.ts +14 -2
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +121 -0
- package/workspaceGraph.ts +149 -16
- package/workspaceLayout.ts +1 -0
- /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RouteJsBudget, type RouteJsSize } from "./routeJsBudget";
|
|
4
|
+
import { tempDirs, writeText as write } from "./testHelpers";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-route-js-");
|
|
7
|
+
const size = (routeId: string, brotliKB: number): RouteJsSize => ({
|
|
8
|
+
routeId,
|
|
9
|
+
files: 1,
|
|
10
|
+
rawBytes: brotliKB * 3 * 1024,
|
|
11
|
+
brotliBytes: brotliKB * 1024,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("RouteJsBudget", () => {
|
|
15
|
+
test("measures a route by the brotli sidecar of each file it loads, and a file with none by its raw bytes", async () => {
|
|
16
|
+
const artifactDir = await makeTempRoot();
|
|
17
|
+
await write(path.join(artifactDir, "client/rsc.js"), "r".repeat(3000));
|
|
18
|
+
await write(path.join(artifactDir, "client/rsc.js.br"), "b".repeat(900));
|
|
19
|
+
await write(path.join(artifactDir, "client/chunks/tiny.js"), "t".repeat(200));
|
|
20
|
+
await write(path.join(artifactDir, "client/memo.js"), "m".repeat(5000));
|
|
21
|
+
await write(path.join(artifactDir, "client/memo.js.br"), "b".repeat(1200));
|
|
22
|
+
|
|
23
|
+
const sizes = await RouteJsBudget.measure(artifactDir, {
|
|
24
|
+
"/:lang": ["/_akan/client/rsc.js", "/_akan/client/chunks/tiny.js"],
|
|
25
|
+
"/:lang/memo": ["/_akan/client/rsc.js", "/_akan/client/chunks/tiny.js", "/_akan/client/memo.js"],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(sizes).toEqual([
|
|
29
|
+
{ routeId: "/:lang/memo", files: 3, rawBytes: 8200, brotliBytes: 2300 },
|
|
30
|
+
{ routeId: "/:lang", files: 2, rawBytes: 3200, brotliBytes: 1100 },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("fails only a route past its entry plus the larger of 1% and 4KB, and leaves a route without one to the report", () => {
|
|
35
|
+
const budget = RouteJsBudget.parse(
|
|
36
|
+
JSON.stringify({ routeJsBrotliKB: { "/:lang": 100, "/:lang/memo": 120, "/:lang/feed": 500 } }),
|
|
37
|
+
);
|
|
38
|
+
const sizes = [
|
|
39
|
+
{ ...size("/:lang", 100), brotliBytes: 100 * 1024 + 4096 },
|
|
40
|
+
{ ...size("/:lang/memo", 120), brotliBytes: 120 * 1024 + 4097 },
|
|
41
|
+
{ ...size("/:lang/feed", 500), brotliBytes: 500 * 1024 + 5000 },
|
|
42
|
+
size("/x", 900),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
expect(RouteJsBudget.limitOf(500)).toBe(500 * 1024 + 5120);
|
|
46
|
+
expect(budget.overages(sizes)).toEqual([
|
|
47
|
+
{ routeId: "/:lang/memo", brotliBytes: 120 * 1024 + 4097, budgetKB: 120, limitBytes: 120 * 1024 + 4096 },
|
|
48
|
+
]);
|
|
49
|
+
expect(budget.unbudgeted(sizes)).toEqual(["/x"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("an update adds a new route, lowers an entry the route fits under, never raises one, and drops a gone route", () => {
|
|
53
|
+
const committed = RouteJsBudget.parse(
|
|
54
|
+
JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang": 100, "/:lang/memo": 120, "/:lang/gone": 50 } }),
|
|
55
|
+
);
|
|
56
|
+
const updated = committed.tightenedBy(
|
|
57
|
+
[
|
|
58
|
+
{ ...size("/:lang", 90), brotliBytes: 90 * 1024 - 300 },
|
|
59
|
+
size("/:lang/memo", 130),
|
|
60
|
+
size("/:lang/new", 70),
|
|
61
|
+
size("/:lang/Zeta", 10),
|
|
62
|
+
],
|
|
63
|
+
"local",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(Object.fromEntries(updated.kbByRoute)).toEqual({
|
|
67
|
+
"/:lang": 90,
|
|
68
|
+
"/:lang/memo": 120,
|
|
69
|
+
"/:lang/new": 70,
|
|
70
|
+
"/:lang/Zeta": 10,
|
|
71
|
+
});
|
|
72
|
+
expect(updated.stringify()).toBe(
|
|
73
|
+
`{\n "env": "local",\n "routeJsBrotliKB": {\n "/:lang": 90,\n "/:lang/Zeta": 10,\n "/:lang/memo": 120,\n "/:lang/new": 70\n }\n}\n`,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("names the environment a budget was recorded for when the build is for another", () => {
|
|
78
|
+
const budget = RouteJsBudget.parse(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang": 100 } }));
|
|
79
|
+
|
|
80
|
+
expect(budget.envNote("main")).toBe("");
|
|
81
|
+
expect(budget.envNote("local")).toBe(" (recorded for main, built for local)");
|
|
82
|
+
expect(RouteJsBudget.parse(JSON.stringify({ routeJsBrotliKB: {} })).envNote("local")).toBe("");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("refuses a file that is not a map of route ids to whole KB", () => {
|
|
86
|
+
expect(() => RouteJsBudget.parse("{", "apps/shop/akan.budget.json")).toThrow(
|
|
87
|
+
"apps/shop/akan.budget.json is not valid JSON.",
|
|
88
|
+
);
|
|
89
|
+
for (const text of ["{}", '{"routeJsBrotliKB":[]}', '{"routeJsBrotliKB":{"/":1.5}}', '{"routeJsBrotliKB":{"/":0}}'])
|
|
90
|
+
expect(() => RouteJsBudget.parse(text)).toThrow('"routeJsBrotliKB" maps each route id to a whole number of KB.');
|
|
91
|
+
expect(() => RouteJsBudget.parse('{"env":1,"routeJsBrotliKB":{}}')).toThrow('"env" names the environment');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("check", () => {
|
|
95
|
+
const sizes = [{ ...size("/:lang/memo", 120), brotliBytes: 130 * 1024 }, size("/:lang/new", 70)];
|
|
96
|
+
const setup = async (text?: string) => {
|
|
97
|
+
const budgetPath = path.join(await makeTempRoot(), "akan.budget.json");
|
|
98
|
+
if (text !== undefined) await write(budgetPath, text);
|
|
99
|
+
return { budgetPath, source: "apps/shop/akan.budget.json", environment: "local" };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
test("enforce fails a route over its limit and a file it cannot read", async () => {
|
|
103
|
+
const over = await setup(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120 } }));
|
|
104
|
+
await expect(RouteJsBudget.check(sizes, { ...over, mode: "enforce" })).rejects.toThrow(
|
|
105
|
+
"1 route over the JS budget in apps/shop/akan.budget.json (recorded for main, built for local):",
|
|
106
|
+
);
|
|
107
|
+
const broken = await setup("{");
|
|
108
|
+
await expect(RouteJsBudget.check(sizes, { ...broken, mode: "enforce" })).rejects.toThrow("is not valid JSON");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("report warns of an overage and a file it cannot read, and fails neither", async () => {
|
|
112
|
+
const over = await setup(JSON.stringify({ env: "main", routeJsBrotliKB: { "/:lang/memo": 120 } }));
|
|
113
|
+
expect(await RouteJsBudget.check(sizes, { ...over, mode: "report" })).toEqual({
|
|
114
|
+
summary:
|
|
115
|
+
"1 route(s) over apps/shop/akan.budget.json (recorded for main, built for local), reported only: /:lang/memo",
|
|
116
|
+
warnings: [
|
|
117
|
+
{
|
|
118
|
+
file: "apps/shop/akan.budget.json",
|
|
119
|
+
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",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
const broken = await setup("{");
|
|
126
|
+
expect(await RouteJsBudget.check(sizes, { ...broken, mode: "report" })).toEqual({
|
|
127
|
+
summary: "apps/shop/akan.budget.json is not valid JSON, not checked",
|
|
128
|
+
warnings: [
|
|
129
|
+
{
|
|
130
|
+
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",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
expect(await RouteJsBudget.check(sizes, { ...(await setup()), mode: "report" })).toEqual({
|
|
137
|
+
summary: "no akan.budget.json",
|
|
138
|
+
});
|
|
139
|
+
const within = await setup(JSON.stringify({ routeJsBrotliKB: { "/:lang/memo": 130 } }));
|
|
140
|
+
expect(await RouteJsBudget.check(sizes, { ...within, mode: "report" })).toEqual({
|
|
141
|
+
summary: "within apps/shop/akan.budget.json, 1 route(s) without an entry",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("update writes the file for this build's environment, rewriting one it cannot read", async () => {
|
|
146
|
+
const broken = await setup("{");
|
|
147
|
+
expect(await RouteJsBudget.check(sizes, { ...broken, mode: "update" })).toEqual({
|
|
148
|
+
summary: "apps/shop/akan.budget.json rewritten (apps/shop/akan.budget.json is not valid JSON)",
|
|
149
|
+
});
|
|
150
|
+
const written = RouteJsBudget.parse(await Bun.file(broken.budgetPath).text());
|
|
151
|
+
expect(written.env).toBe("local");
|
|
152
|
+
expect(Object.fromEntries(written.kbByRoute)).toEqual({ "/:lang/memo": 130, "/:lang/new": 70 });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("update keeps an entry its route outgrew, and still fails the route", async () => {
|
|
156
|
+
const over = await setup(JSON.stringify({ routeJsBrotliKB: { "/:lang/memo": 120 } }));
|
|
157
|
+
await expect(RouteJsBudget.check(sizes, { ...over, mode: "update" })).rejects.toThrow("/:lang/memo");
|
|
158
|
+
expect(RouteJsBudget.parse(await Bun.file(over.budgetPath).text()).kbByRoute.get("/:lang/memo")).toBe(120);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("names each route over budget, its limit, and how to resolve it", () => {
|
|
163
|
+
expect(
|
|
164
|
+
RouteJsBudget.formatOverages(
|
|
165
|
+
[{ routeId: "/:lang/memo", brotliBytes: 128_000, budgetKB: 120, limitBytes: 126_976 }],
|
|
166
|
+
"apps/shop/akan.budget.json",
|
|
167
|
+
" (recorded for main, built for local)",
|
|
168
|
+
),
|
|
169
|
+
).toBe(
|
|
170
|
+
[
|
|
171
|
+
"1 route over the JS budget in apps/shop/akan.budget.json (recorded for main, built for local):",
|
|
172
|
+
" /: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.",
|
|
174
|
+
].join("\n"),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
package/routeJsBudget.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { toError } from "akanjs/common";
|
|
3
|
+
import { FontPruner } from "./frontendBuild/fontPruner";
|
|
4
|
+
import { JsonFormatter } from "./jsonFormatter";
|
|
5
|
+
|
|
6
|
+
export interface RouteJsSize {
|
|
7
|
+
routeId: string;
|
|
8
|
+
files: number;
|
|
9
|
+
rawBytes: number;
|
|
10
|
+
brotliBytes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RouteJsOverage {
|
|
14
|
+
routeId: string;
|
|
15
|
+
brotliBytes: number;
|
|
16
|
+
budgetKB: number;
|
|
17
|
+
limitBytes: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type RouteJsBudgetMode = "enforce" | "update" | "report" | "off";
|
|
21
|
+
|
|
22
|
+
export interface RouteJsBudgetWarning {
|
|
23
|
+
file: string;
|
|
24
|
+
message: string;
|
|
25
|
+
fix: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RouteJsBudgetStatus {
|
|
29
|
+
summary: string;
|
|
30
|
+
warnings?: RouteJsBudgetWarning[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RouteJsBudgetCheck {
|
|
34
|
+
mode: Exclude<RouteJsBudgetMode, "off">;
|
|
35
|
+
budgetPath: string;
|
|
36
|
+
source: string;
|
|
37
|
+
environment: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class RouteJsBudget {
|
|
41
|
+
static readonly fileName = "akan.budget.json";
|
|
42
|
+
static readonly section = "routeJsBrotliKB";
|
|
43
|
+
static readonly tableRows = 10;
|
|
44
|
+
//? BundleSizeGate's slack: bundles inline `AKAN_PUBLIC_*`, so a local build measures over 1KB off a `--env main` one.
|
|
45
|
+
static readonly #relativeSlack = 0.01;
|
|
46
|
+
static readonly #absoluteSlack = 4096;
|
|
47
|
+
|
|
48
|
+
//? A file under 1 KB has no `.br` sidecar (precompressArtifacts skips it), so it is served, and counted, raw.
|
|
49
|
+
static async measure(artifactDir: string, modulesByRoute: Record<string, string[]>): Promise<RouteJsSize[]> {
|
|
50
|
+
const sizeByUrl = new Map<string, { raw: number; brotli: number }>();
|
|
51
|
+
const sizeOf = async (url: string) => {
|
|
52
|
+
const file = path.join(artifactDir, url.replace(/^\/_akan\//, ""));
|
|
53
|
+
const raw = Bun.file(file).size;
|
|
54
|
+
const sidecar = Bun.file(`${file}.br`);
|
|
55
|
+
return { raw, brotli: (await sidecar.exists()) ? sidecar.size : raw };
|
|
56
|
+
};
|
|
57
|
+
for (const url of new Set(Object.values(modulesByRoute).flat())) sizeByUrl.set(url, await sizeOf(url));
|
|
58
|
+
return Object.entries(modulesByRoute)
|
|
59
|
+
.map(([routeId, urls]) => ({
|
|
60
|
+
routeId,
|
|
61
|
+
files: urls.length,
|
|
62
|
+
rawBytes: urls.reduce((total, url) => total + (sizeByUrl.get(url)?.raw ?? 0), 0),
|
|
63
|
+
brotliBytes: urls.reduce((total, url) => total + (sizeByUrl.get(url)?.brotli ?? 0), 0),
|
|
64
|
+
}))
|
|
65
|
+
.sort((a, b) => b.brotliBytes - a.brotliBytes || RouteJsBudget.#byCodeUnit(a.routeId, b.routeId));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static async load(filePath: string, source = filePath): Promise<RouteJsBudget | null> {
|
|
69
|
+
const file = Bun.file(filePath);
|
|
70
|
+
if (!(await file.exists())) return null;
|
|
71
|
+
return RouteJsBudget.parse(await file.text(), source);
|
|
72
|
+
}
|
|
73
|
+
|
|
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> {
|
|
79
|
+
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());
|
|
82
|
+
const overages = budget?.overages(sizes) ?? [];
|
|
83
|
+
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"}` };
|
|
87
|
+
if (unreadable)
|
|
88
|
+
return {
|
|
89
|
+
summary: `${unreadable}, not checked`,
|
|
90
|
+
warnings: [
|
|
91
|
+
{
|
|
92
|
+
file: source,
|
|
93
|
+
message: `${unreadable}; akan build fails on it`,
|
|
94
|
+
fix: "correct it, or rewrite it with akan build <app> --update-budget",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
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` : ""}` };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static async #loadFor(mode: RouteJsBudgetCheck["mode"], budgetPath: string, source: string) {
|
|
112
|
+
try {
|
|
113
|
+
return { committed: await RouteJsBudget.load(budgetPath, source), unreadable: null };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (mode === "enforce") throw error;
|
|
116
|
+
return { committed: null, unreadable: toError(error).message.replace(/\.$/, "") };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static parse(text: string, source = RouteJsBudget.fileName): RouteJsBudget {
|
|
121
|
+
let json: unknown;
|
|
122
|
+
try {
|
|
123
|
+
json = JSON.parse(text);
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error(`${source} is not valid JSON.`);
|
|
126
|
+
}
|
|
127
|
+
const record =
|
|
128
|
+
typeof json === "object" && json !== null && !Array.isArray(json) ? (json as Record<string, unknown>) : {};
|
|
129
|
+
const section = record[RouteJsBudget.section];
|
|
130
|
+
const entries = typeof section === "object" && section !== null && !Array.isArray(section) ? section : null;
|
|
131
|
+
if (!entries || !Object.values(entries).every((kb) => Number.isInteger(kb) && (kb as number) > 0))
|
|
132
|
+
throw new Error(`${source}: "${RouteJsBudget.section}" maps each route id to a whole number of KB.`);
|
|
133
|
+
if (record.env !== undefined && typeof record.env !== "string")
|
|
134
|
+
throw new Error(`${source}: "env" names the environment the entries were measured for.`);
|
|
135
|
+
return new RouteJsBudget(new Map(Object.entries(entries as Record<string, number>)), record.env);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
static kbOf(bytes: number) {
|
|
139
|
+
return Math.ceil(bytes / 1024);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
static limitOf(budgetKB: number) {
|
|
143
|
+
const bytes = budgetKB * 1024;
|
|
144
|
+
return bytes + Math.max(Math.ceil(bytes * RouteJsBudget.#relativeSlack), RouteJsBudget.#absoluteSlack);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static formatTable(sizes: RouteJsSize[]): string[] {
|
|
148
|
+
const shown = sizes.slice(0, RouteJsBudget.tableRows);
|
|
149
|
+
const width = Math.max(...shown.map(({ routeId }) => routeId.length));
|
|
150
|
+
const { formatBytes } = FontPruner;
|
|
151
|
+
return [
|
|
152
|
+
"Route JS, largest first (brotli / raw):",
|
|
153
|
+
...shown.map(
|
|
154
|
+
({ routeId, brotliBytes, rawBytes }) =>
|
|
155
|
+
` ${routeId.padEnd(width)} ${formatBytes(brotliBytes).padStart(8)} ${formatBytes(rawBytes).padStart(8)}`,
|
|
156
|
+
),
|
|
157
|
+
...(sizes.length > shown.length ? [` …and ${sizes.length - shown.length} more`] : []),
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static formatOverages(overages: RouteJsOverage[], source: string, envNote = ""): string {
|
|
162
|
+
const bytes = RouteJsBudget.#bytes;
|
|
163
|
+
return [
|
|
164
|
+
`${overages.length} route${overages.length === 1 ? "" : "s"} over the JS budget in ${source}${envNote}:`,
|
|
165
|
+
...overages.map(
|
|
166
|
+
({ routeId, brotliBytes, budgetKB, limitBytes }) =>
|
|
167
|
+
` ${routeId}: ${bytes(brotliBytes)} bytes brotli, over its limit of ${bytes(limitBytes)} (${budgetKB}KB + the larger of 1% and 4KB)`,
|
|
168
|
+
),
|
|
169
|
+
"Shrink what the route loads. If the growth is intended, raise its entry by hand in the same change.",
|
|
170
|
+
].join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
static #bytes(value: number) {
|
|
174
|
+
return value.toLocaleString("en-US");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static #byCodeUnit(a: string, b: string) {
|
|
178
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
constructor(
|
|
182
|
+
readonly kbByRoute: ReadonlyMap<string, number> = new Map(),
|
|
183
|
+
readonly env?: string,
|
|
184
|
+
) {}
|
|
185
|
+
|
|
186
|
+
overages(sizes: RouteJsSize[]): RouteJsOverage[] {
|
|
187
|
+
return sizes.flatMap(({ routeId, brotliBytes }) => {
|
|
188
|
+
const budgetKB = this.kbByRoute.get(routeId);
|
|
189
|
+
if (budgetKB === undefined) return [];
|
|
190
|
+
const limitBytes = RouteJsBudget.limitOf(budgetKB);
|
|
191
|
+
return brotliBytes > limitBytes ? [{ routeId, brotliBytes, budgetKB, limitBytes }] : [];
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
envNote(environment: string) {
|
|
196
|
+
return this.env && this.env !== environment ? ` (recorded for ${this.env}, built for ${environment})` : "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
unbudgeted(sizes: RouteJsSize[]): string[] {
|
|
200
|
+
return sizes.filter(({ routeId }) => !this.kbByRoute.has(routeId)).map(({ routeId }) => routeId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
tightenedBy(sizes: RouteJsSize[], env?: string): RouteJsBudget {
|
|
204
|
+
return new RouteJsBudget(
|
|
205
|
+
new Map(
|
|
206
|
+
sizes.map(({ routeId, brotliBytes }) => {
|
|
207
|
+
const measured = RouteJsBudget.kbOf(brotliBytes);
|
|
208
|
+
return [routeId, Math.min(this.kbByRoute.get(routeId) ?? measured, measured)];
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
env ?? this.env,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
stringify(): string {
|
|
216
|
+
const entries = [...this.kbByRoute].sort(([a], [b]) => RouteJsBudget.#byCodeUnit(a, b));
|
|
217
|
+
return JsonFormatter.stringify(
|
|
218
|
+
{ ...(this.env ? { env: this.env } : {}), [RouteJsBudget.section]: Object.fromEntries(entries) },
|
|
219
|
+
RouteJsBudget.fileName,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { StructureScanner } from "./structureScanner";
|
|
4
|
+
|
|
5
|
+
const measure = (source: string, file = "unit.ts") =>
|
|
6
|
+
new StructureScanner().measure(
|
|
7
|
+
ts.createSourceFile(
|
|
8
|
+
file,
|
|
9
|
+
source,
|
|
10
|
+
ts.ScriptTarget.Latest,
|
|
11
|
+
true,
|
|
12
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
13
|
+
),
|
|
14
|
+
);
|
|
15
|
+
const namesOf = (source: string) => measure(source).functions.map(({ name }) => name);
|
|
16
|
+
|
|
17
|
+
describe("StructureScanner functions", () => {
|
|
18
|
+
test("names a function by the classes and functions around it, and counts the lines it spans", () => {
|
|
19
|
+
const { functions } = measure(`export class Host {
|
|
20
|
+
#children = new Map();
|
|
21
|
+
constructor() {}
|
|
22
|
+
get status() {
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
set status(value: number) {}
|
|
26
|
+
async start() {
|
|
27
|
+
const tick = () => {
|
|
28
|
+
return 1;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
static #restart = function named() {};
|
|
32
|
+
}
|
|
33
|
+
export function build() {}
|
|
34
|
+
export default function () {}
|
|
35
|
+
`);
|
|
36
|
+
|
|
37
|
+
expect(functions).toEqual([
|
|
38
|
+
{ name: "Host.constructor", line: 3, lineCount: 1 },
|
|
39
|
+
{ name: "Host.get status", line: 4, lineCount: 3 },
|
|
40
|
+
{ name: "Host.set status", line: 7, lineCount: 1 },
|
|
41
|
+
{ name: "Host.start", line: 8, lineCount: 5 },
|
|
42
|
+
{ name: "Host.start.tick", line: 9, lineCount: 3 },
|
|
43
|
+
{ name: "Host.named", line: 13, lineCount: 1 },
|
|
44
|
+
{ name: "build", line: 15, lineCount: 1 },
|
|
45
|
+
{ name: "default", line: 16, lineCount: 1 },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("names an anonymous function after what its call is bound to, else after the call", () => {
|
|
50
|
+
expect(
|
|
51
|
+
namesOf(`const rows = items.map((item) => item).filter(Boolean);
|
|
52
|
+
const View = memo(() => null);
|
|
53
|
+
const routes = { "/*": answering(async (req) => req), list: (() => []) as unknown };
|
|
54
|
+
describe("suite", () => {
|
|
55
|
+
useEffect(() => {});
|
|
56
|
+
holder.onExit = () => {};
|
|
57
|
+
});
|
|
58
|
+
export class ShowCommand extends command("show", ({ public: target }) => ({
|
|
59
|
+
show: target().exec(async function (format) {}),
|
|
60
|
+
})) {}
|
|
61
|
+
export default () => {};
|
|
62
|
+
`),
|
|
63
|
+
).toEqual([
|
|
64
|
+
"rows",
|
|
65
|
+
"View",
|
|
66
|
+
"routes./*",
|
|
67
|
+
"routes.list",
|
|
68
|
+
"describe()",
|
|
69
|
+
"describe().useEffect()",
|
|
70
|
+
"describe().onExit",
|
|
71
|
+
"ShowCommand.command()",
|
|
72
|
+
"ShowCommand.command().show",
|
|
73
|
+
"default",
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("names a repeat like the first, whatever stands before it", () => {
|
|
78
|
+
expect(namesOf("test('a', () => {});\ntest('b', () => {});\ntest('c', () => {});\n")).toEqual([
|
|
79
|
+
"test()",
|
|
80
|
+
"test()",
|
|
81
|
+
"test()",
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("names an object literal's members after what the literal is bound to", () => {
|
|
86
|
+
expect(
|
|
87
|
+
namesOf(`const h = { foo() {} };
|
|
88
|
+
const k = { foo() {} };
|
|
89
|
+
const routes = { api: { list: () => [] } };
|
|
90
|
+
export function make() {
|
|
91
|
+
return { setX() {} };
|
|
92
|
+
}
|
|
93
|
+
register({ onDone() {} });
|
|
94
|
+
`),
|
|
95
|
+
).toEqual(["h.foo", "k.foo", "routes.api.list", "make", "make.setX", "register().onDone"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("names an inline JSX callback after its attribute, and a function child children", () => {
|
|
99
|
+
const { functions } = measure(
|
|
100
|
+
`export const Board = () => (
|
|
101
|
+
<List onClick={() => {}} render={(row) => row}>
|
|
102
|
+
{(row) => row}
|
|
103
|
+
</List>
|
|
104
|
+
);
|
|
105
|
+
`,
|
|
106
|
+
"Board.tsx",
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(functions.map(({ name }) => name)).toEqual(["Board", "Board.onClick", "Board.render", "Board.children"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("skips a signature without a body", () => {
|
|
113
|
+
expect(
|
|
114
|
+
namesOf(
|
|
115
|
+
"export function overload(a: string): void;\nexport function overload(a: unknown) {}\ninterface I { m(): void }\n",
|
|
116
|
+
),
|
|
117
|
+
).toEqual(["overload"]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("StructureScanner classes", () => {
|
|
122
|
+
test("counts every member of a class or class expression, named like a function", () => {
|
|
123
|
+
const { classes } = measure(`export class Exception extends Error {
|
|
124
|
+
static BadRequest = class BadRequestException extends Exception {
|
|
125
|
+
constructor() {
|
|
126
|
+
super();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
readonly code = 1;;
|
|
130
|
+
#secret = 2;
|
|
131
|
+
get status() {
|
|
132
|
+
return 3;
|
|
133
|
+
}
|
|
134
|
+
set status(value: number) {}
|
|
135
|
+
[key: string]: unknown;
|
|
136
|
+
}
|
|
137
|
+
const Mixin = class extends Base {};
|
|
138
|
+
export default class {}
|
|
139
|
+
`);
|
|
140
|
+
|
|
141
|
+
expect(classes).toEqual([
|
|
142
|
+
{ name: "Exception", line: 1, memberCount: 5 },
|
|
143
|
+
{ name: "Exception.BadRequestException", line: 2, memberCount: 1 },
|
|
144
|
+
{ name: "Mixin", line: 15, memberCount: 0 },
|
|
145
|
+
{ name: "default", line: 16, memberCount: 0 },
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("counts an overloaded method once, and neither an index signature nor a static block", () => {
|
|
150
|
+
const { classes } = measure(`export abstract class Store {
|
|
151
|
+
static {
|
|
152
|
+
Store.ready = true;
|
|
153
|
+
}
|
|
154
|
+
[key: string]: unknown;
|
|
155
|
+
constructor(name: string);
|
|
156
|
+
constructor(name: string, id?: number) {}
|
|
157
|
+
load(id: string): void;
|
|
158
|
+
load(ids: string[]): void;
|
|
159
|
+
load(arg: unknown) {}
|
|
160
|
+
static load() {}
|
|
161
|
+
abstract save(): void;
|
|
162
|
+
}
|
|
163
|
+
`);
|
|
164
|
+
|
|
165
|
+
expect(classes).toEqual([{ name: "Store", line: 1, memberCount: 4 }]);
|
|
166
|
+
});
|
|
167
|
+
});
|