@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
@@ -0,0 +1,551 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ import { MaintainabilityLimits } from "./maintainabilityLimits";
5
+
6
+ export interface ErrorCodeRow {
7
+ code: string | null;
8
+ byStatus: boolean;
9
+ statusCode: number | null;
10
+ error: string;
11
+ forwarded: boolean;
12
+ wire: boolean;
13
+ file: string;
14
+ }
15
+
16
+ const byStatus = Symbol("byStatus");
17
+
18
+ type Value = string | number | undefined | typeof byStatus;
19
+ type Values = readonly Value[] | null;
20
+ type Env = ReadonlyMap<string, Binding>;
21
+ type Pins = ReadonlyMap<ts.ParameterDeclaration, Value>;
22
+
23
+ interface Binding {
24
+ values: Values;
25
+ source: { expression: ts.Expression; env: Env } | null;
26
+ }
27
+
28
+ interface Call {
29
+ args: readonly ts.Expression[];
30
+ env: Env;
31
+ }
32
+
33
+ interface Evaluation {
34
+ codes: Values;
35
+ statuses: Values;
36
+ }
37
+
38
+ type ClassNode = ts.ClassDeclaration | ts.ClassExpression;
39
+
40
+ const unwrapped = (expression: ts.Expression) => {
41
+ let current = expression;
42
+ while (
43
+ ts.isParenthesizedExpression(current) ||
44
+ ts.isAsExpression(current) ||
45
+ ts.isSatisfiesExpression(current) ||
46
+ ts.isNonNullExpression(current)
47
+ )
48
+ current = current.expression;
49
+ return current;
50
+ };
51
+
52
+ const isClassNode = (node: ts.Node | undefined): node is ClassNode =>
53
+ !!node && (ts.isClassDeclaration(node) || ts.isClassExpression(node));
54
+
55
+ const statusCodeOf = (property: ts.ObjectLiteralElementLike): ts.Expression | null => {
56
+ if (ts.isPropertyAssignment(property) && property.name.getText() === "statusCode") return property.initializer;
57
+ if (ts.isShorthandPropertyAssignment(property) && property.name.text === "statusCode") return property.name;
58
+ return null;
59
+ };
60
+
61
+ const byCodeUnit = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
62
+
63
+ export class ErrorCodeScan {
64
+ readonly #dir: string;
65
+ readonly #roots: ReadonlySet<string>;
66
+ readonly #program: ts.Program;
67
+ readonly #checker: ts.TypeChecker;
68
+ readonly #akanError: ts.ClassDeclaration;
69
+ readonly #statuses: ReadonlyMap<number, readonly [code: string, reason: string]>;
70
+ readonly #callSites = new Map<ClassNode, Call[]>();
71
+ readonly #descends = new Map<ClassNode, boolean>();
72
+
73
+ constructor(akanjsDir: string) {
74
+ this.#dir = akanjsDir;
75
+ this.#roots = ErrorCodeScan.#rootsOf(akanjsDir);
76
+ const configPath = path.join(akanjsDir, "tsconfig.json");
77
+ const config = ts.sys.fileExists(configPath) ? ts.readConfigFile(configPath, ts.sys.readFile).config : {};
78
+ const parsed = ts.parseJsonConfigFileContent(config, ts.sys, akanjsDir, undefined, configPath);
79
+ this.#program = ts.createProgram({
80
+ rootNames: [...this.#roots].map((file) => path.join(akanjsDir, file)),
81
+ options: { ...parsed.options, noEmit: true },
82
+ });
83
+ this.#checker = this.#program.getTypeChecker();
84
+ const base = this.#program.getSourceFile(path.join(akanjsDir, "base", "akanError.ts"));
85
+ const akanError = base?.statements.find(
86
+ (statement): statement is ts.ClassDeclaration =>
87
+ ts.isClassDeclaration(statement) && statement.name?.text === "AkanError",
88
+ );
89
+ if (!akanError) throw new Error(`${akanjsDir}/base/akanError.ts declares no AkanError class`);
90
+ this.#akanError = akanError;
91
+ this.#statuses = ErrorCodeScan.#statusTableOf(akanError);
92
+ }
93
+
94
+ get statuses() {
95
+ return this.#statuses;
96
+ }
97
+
98
+ rows(): ErrorCodeRow[] {
99
+ const classes = this.#classes();
100
+ for (const call of this.#newExpressions()) {
101
+ const target = this.#classOf(call.expression);
102
+ if (target && classes.includes(target))
103
+ this.#callSites.set(target, [
104
+ ...(this.#callSites.get(target) ?? []),
105
+ { args: call.arguments ?? [], env: new Map() },
106
+ ]);
107
+ }
108
+ const rows = new Map<string, ErrorCodeRow>();
109
+ for (const node of classes) {
110
+ const forwardedOf = this.#forwardedOf(node);
111
+ const toJsonHasCode = this.#toJsonHasCode(node);
112
+ const file = this.#relative(node.getSourceFile().fileName);
113
+ for (const { codes, statuses } of this.#evaluationsOf(node))
114
+ for (const code of codes ?? [null])
115
+ for (const statusCode of statuses ?? [null]) {
116
+ const forwarded = forwardedOf(code);
117
+ const row: ErrorCodeRow = {
118
+ code: typeof code === "string" ? code : null,
119
+ byStatus: code === byStatus,
120
+ statusCode: typeof statusCode === "number" ? statusCode : null,
121
+ error: this.#nameOf(node),
122
+ forwarded,
123
+ wire: !forwarded || toJsonHasCode,
124
+ file,
125
+ };
126
+ rows.set(`${row.code}|${row.byStatus}|${row.statusCode}|${row.error}|${row.file}`, row);
127
+ }
128
+ }
129
+ return [...rows.values()].sort(ErrorCodeScan.#byCode);
130
+ }
131
+
132
+ #evaluationsOf(node: ClassNode): Evaluation[] {
133
+ const sites = this.#callSites.get(node) ?? [];
134
+ if (node === this.#akanError) return sites.map((call) => this.#akanErrorCall(call));
135
+ const free = this.#evaluate(node, null);
136
+ if (free.codes !== null && free.statuses !== null) return this.#perCode(node, free);
137
+ return sites.length ? sites.map((call) => this.#evaluate(node, call)) : [free];
138
+ }
139
+
140
+ #perCode(node: ClassNode, free: Evaluation): Evaluation[] {
141
+ const codes = free.codes ?? [];
142
+ if (codes.length < 2 || (free.statuses?.length ?? 0) < 2) return [free];
143
+ const parameter = this.#freeParametersOf(node).find((each) => {
144
+ const values = this.#literalsOf(this.#checker.getTypeAtLocation(each.name));
145
+ return values?.length === codes.length && values.every((value) => codes.includes(value));
146
+ });
147
+ if (!parameter) return [free];
148
+ return codes.map((code) => this.#evaluate(node, null, new Map([[parameter, code]])));
149
+ }
150
+
151
+ #freeParametersOf(node: ClassNode): readonly ts.ParameterDeclaration[] {
152
+ for (
153
+ let current: ClassNode | null = node;
154
+ current && current !== this.#akanError;
155
+ current = this.#baseOf(current)
156
+ ) {
157
+ const ctor = current.members.find(
158
+ (member): member is ts.ConstructorDeclaration => ts.isConstructorDeclaration(member) && !!member.body,
159
+ );
160
+ if (ctor) return ctor.parameters;
161
+ }
162
+ return [];
163
+ }
164
+
165
+ #evaluate(node: ClassNode, call: Call | null, pins: Pins = new Map()): Evaluation {
166
+ const base = this.#baseOf(node);
167
+ const ctor = node.members.find(
168
+ (member): member is ts.ConstructorDeclaration => ts.isConstructorDeclaration(member) && !!member.body,
169
+ );
170
+ if (!ctor) {
171
+ if (base === this.#akanError) return this.#akanErrorCall(call);
172
+ return base ? this.#evaluate(base, call, pins) : { codes: null, statuses: null };
173
+ }
174
+ const env = new Map<string, Binding>();
175
+ ctor.parameters.forEach((parameter, idx) => {
176
+ if (!ts.isIdentifier(parameter.name)) return;
177
+ env.set(parameter.name.text, this.#bindingOf(parameter, idx, call, env, pins));
178
+ });
179
+ const superCall = ctor.body?.statements
180
+ .map((statement) => (ts.isExpressionStatement(statement) ? unwrapped(statement.expression) : null))
181
+ .find(
182
+ (expression): expression is ts.CallExpression =>
183
+ !!expression && ts.isCallExpression(expression) && expression.expression.kind === ts.SyntaxKind.SuperKeyword,
184
+ );
185
+ if (!superCall) return { codes: null, statuses: null };
186
+ const passed: Call = { args: superCall.arguments, env };
187
+ if (base === this.#akanError) return this.#akanErrorCall(passed);
188
+ return base ? this.#evaluate(base, passed) : { codes: null, statuses: null };
189
+ }
190
+
191
+ #bindingOf(parameter: ts.ParameterDeclaration, idx: number, call: Call | null, env: Env, pins: Pins): Binding {
192
+ if (call === null) {
193
+ const pinned: Values = pins.has(parameter) ? [pins.get(parameter)] : null;
194
+ return { values: pinned ?? this.#literalsOf(this.#checker.getTypeAtLocation(parameter.name)), source: null };
195
+ }
196
+ if (parameter.dotDotDotToken || call.args.slice(0, idx + 1).some(ts.isSpreadElement))
197
+ return { values: null, source: null };
198
+ const expression = call.args[idx] ?? parameter.initializer;
199
+ if (!expression) return { values: [undefined], source: null };
200
+ const scope = call.args[idx] ? call.env : env;
201
+ return { values: this.#value(expression, scope), source: { expression, env: scope } };
202
+ }
203
+
204
+ #akanErrorCall(call: Call | null): Evaluation {
205
+ if (!call) return { codes: null, statuses: null };
206
+ const [code, , options] = call.args;
207
+ return { codes: code ? this.#value(code, call.env) : null, statuses: this.#statusOf(options, call.env) };
208
+ }
209
+
210
+ #statusOf(options: ts.Expression | undefined, env: Env): Values {
211
+ if (!options) return [500];
212
+ const inner = unwrapped(options);
213
+ const binding = ts.isIdentifier(inner) ? env.get(inner.text) : undefined;
214
+ if (binding?.source) return this.#statusOf(binding.source.expression, binding.source.env);
215
+ if (binding?.values?.every((each) => each === undefined)) return [500];
216
+ if (!ts.isObjectLiteralExpression(inner)) return this.#holdsStatusCode(inner) ? null : [500];
217
+ for (const property of [...inner.properties].reverse()) {
218
+ if (ts.isSpreadAssignment(property)) {
219
+ if (this.#holdsStatusCode(property.expression)) return null;
220
+ continue;
221
+ }
222
+ const initializer = statusCodeOf(property);
223
+ if (initializer) return this.#value(initializer, env)?.map((each) => (each === undefined ? 500 : each)) ?? null;
224
+ }
225
+ return [500];
226
+ }
227
+
228
+ #holdsStatusCode(expression: ts.Expression) {
229
+ const type = this.#checker.getNonNullableType(this.#checker.getTypeAtLocation(expression));
230
+ return !!type.getProperty("statusCode");
231
+ }
232
+
233
+ #value(expression: ts.Expression, env: Env): Values {
234
+ const inner = unwrapped(expression);
235
+ if (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner)) return [inner.text];
236
+ if (ts.isNumericLiteral(inner)) return [Number(inner.text)];
237
+ if (ts.isConditionalExpression(inner)) {
238
+ const holds = this.#holds(inner.condition, env);
239
+ if (holds !== null) return this.#value(holds ? inner.whenTrue : inner.whenFalse, env);
240
+ const [whenTrue, whenFalse] = [this.#value(inner.whenTrue, env), this.#value(inner.whenFalse, env)];
241
+ return whenTrue && whenFalse ? [...new Set([...whenTrue, ...whenFalse])] : null;
242
+ }
243
+ if (ts.isIdentifier(inner)) {
244
+ const binding = env.get(inner.text);
245
+ if (binding) return binding.values;
246
+ if (inner.text === "undefined") return [undefined];
247
+ }
248
+ if (ts.isElementAccessExpression(inner)) return this.#lookup(inner, env);
249
+ if (ts.isCallExpression(inner) && this.#isCodeOfStatus(inner.expression)) {
250
+ const [status] = inner.arguments;
251
+ const statuses = status ? this.#value(status, env) : null;
252
+ const known = statuses?.filter((each): each is number => typeof each === "number") ?? [];
253
+ return statuses && known.length === statuses.length ? known.map((each) => this.#codeOf(each)) : [byStatus];
254
+ }
255
+ return this.#literalsOf(this.#checker.getTypeAtLocation(inner));
256
+ }
257
+
258
+ #holds(condition: ts.Expression, env: Env): boolean | null {
259
+ const inner = unwrapped(condition);
260
+ if (!ts.isBinaryExpression(inner)) return null;
261
+ const operator = inner.operatorToken.kind;
262
+ const equal = operator === ts.SyntaxKind.EqualsEqualsEqualsToken;
263
+ if (!equal && operator !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return null;
264
+ const [left, right] = [this.#value(inner.left, env), this.#value(inner.right, env)];
265
+ if (left?.length !== 1 || right?.length !== 1) return null;
266
+ return (left[0] === right[0]) === equal;
267
+ }
268
+
269
+ #lookup(access: ts.ElementAccessExpression, env: Env): Values {
270
+ const table = this.#tableOf(access.expression);
271
+ const keys = this.#value(access.argumentExpression, env);
272
+ if (!table || !keys) return null;
273
+ const values = new Set<Value>();
274
+ for (const key of keys) {
275
+ const entry = table.properties.find(
276
+ (property): property is ts.PropertyAssignment =>
277
+ ts.isPropertyAssignment(property) &&
278
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) || ts.isNumericLiteral(property.name)) &&
279
+ property.name.text === String(key),
280
+ );
281
+ const found = entry ? this.#value(entry.initializer, new Map()) : null;
282
+ if (!found) return null;
283
+ for (const value of found) values.add(value);
284
+ }
285
+ return [...values];
286
+ }
287
+
288
+ #tableOf(expression: ts.Expression): ts.ObjectLiteralExpression | null {
289
+ const symbol = this.#checker.getSymbolAtLocation(unwrapped(expression));
290
+ for (const declaration of symbol?.declarations ?? []) {
291
+ if (!ts.isPropertyDeclaration(declaration) || !declaration.initializer) continue;
292
+ if (!(ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Readonly)) continue;
293
+ const initializer = unwrapped(declaration.initializer);
294
+ if (ts.isObjectLiteralExpression(initializer)) return initializer;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ #codeOf(statusCode: number) {
300
+ return this.#statuses.get(statusCode)?.[0] ?? `http${statusCode}`;
301
+ }
302
+
303
+ #isCodeOfStatus(callee: ts.Expression) {
304
+ return (
305
+ ts.isPropertyAccessExpression(callee) &&
306
+ callee.name.text === "codeOfStatus" &&
307
+ this.#classOf(callee.expression) === this.#akanError
308
+ );
309
+ }
310
+
311
+ #literalsOf(type: ts.Type): Values {
312
+ const members = type.isUnion() ? type.types : [type];
313
+ const values: Value[] = [];
314
+ for (const member of members) {
315
+ if (member.isStringLiteral() || member.isNumberLiteral()) values.push(member.value);
316
+ else if (!(member.flags & ts.TypeFlags.Undefined)) return null;
317
+ }
318
+ return values.length ? values : null;
319
+ }
320
+
321
+ #forwardedOf(node: ClassNode): (code: Value | null) => boolean {
322
+ for (let current: ClassNode | null = node; current; current = this.#baseOf(current)) {
323
+ const getter = current.members.find(
324
+ (member): member is ts.GetAccessorDeclaration =>
325
+ ts.isGetAccessorDeclaration(member) && member.name.getText() === "forwarded",
326
+ );
327
+ if (!getter) continue;
328
+ const [statement, ...rest] = getter.body?.statements ?? [];
329
+ const returned =
330
+ statement && !rest.length && ts.isReturnStatement(statement) && statement.expression
331
+ ? unwrapped(statement.expression)
332
+ : null;
333
+ if (returned?.kind === ts.SyntaxKind.TrueKeyword) return () => true;
334
+ if (returned?.kind === ts.SyntaxKind.FalseKeyword) return () => false;
335
+ const byCode = returned ? ErrorCodeScan.#codeComparisonOf(returned) : null;
336
+ if (byCode) return byCode;
337
+ if (returned && ErrorCodeScan.#readsRestoredState(returned)) return () => true;
338
+ throw new Error(
339
+ `${this.#nameOf(current)}'s forwarded getter returns more than a literal true or false, or this.code compared to a literal`,
340
+ );
341
+ }
342
+ return () => false;
343
+ }
344
+
345
+ //? `wireCodeOf(this) === undefined`: only a copy restored from the wire holds that state, and this scan reads `new` sites.
346
+ static #readsRestoredState(expression: ts.Expression) {
347
+ if (!ts.isBinaryExpression(expression)) return false;
348
+ if (expression.operatorToken.kind !== ts.SyntaxKind.EqualsEqualsEqualsToken) return false;
349
+ const read = unwrapped(expression.left);
350
+ const compared = unwrapped(expression.right);
351
+ return (
352
+ ts.isCallExpression(read) &&
353
+ read.arguments.length === 1 &&
354
+ read.arguments[0]?.kind === ts.SyntaxKind.ThisKeyword &&
355
+ ts.isIdentifier(compared) &&
356
+ compared.text === "undefined"
357
+ );
358
+ }
359
+
360
+ static #codeComparisonOf(expression: ts.Expression): ((code: Value | null) => boolean) | null {
361
+ if (!ts.isBinaryExpression(expression)) return null;
362
+ const operator = expression.operatorToken.kind;
363
+ const equal = operator === ts.SyntaxKind.EqualsEqualsEqualsToken;
364
+ if (!equal && operator !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return null;
365
+ const isThisCode = (side: ts.Expression) => {
366
+ const property = unwrapped(side);
367
+ return (
368
+ ts.isPropertyAccessExpression(property) &&
369
+ property.expression.kind === ts.SyntaxKind.ThisKeyword &&
370
+ property.name.text === "code"
371
+ );
372
+ };
373
+ const literalOf = (side: ts.Expression) => {
374
+ const literal = unwrapped(side);
375
+ return ts.isStringLiteralLike(literal) ? literal.text : null;
376
+ };
377
+ const compared = isThisCode(expression.left)
378
+ ? literalOf(expression.right)
379
+ : isThisCode(expression.right)
380
+ ? literalOf(expression.left)
381
+ : null;
382
+ if (compared === null) return null;
383
+ return (code) => (code === compared) === equal;
384
+ }
385
+
386
+ #toJsonHasCode(node: ClassNode): boolean {
387
+ for (let current: ClassNode | null = node; current; current = this.#baseOf(current)) {
388
+ if (current === this.#akanError) return true;
389
+ const method = current.members.find(
390
+ (member): member is ts.MethodDeclaration =>
391
+ ts.isMethodDeclaration(member) && member.name.getText() === "toJSON",
392
+ );
393
+ if (!method) continue;
394
+ const returned = method.body?.statements.find(ts.isReturnStatement)?.expression;
395
+ const base = this.#baseOf(current);
396
+ return !!returned && this.#carriesCode(returned, base);
397
+ }
398
+ return true;
399
+ }
400
+
401
+ #carriesCode(expression: ts.Expression, base: ClassNode | null): boolean {
402
+ const inner = unwrapped(expression);
403
+ if (ts.isConditionalExpression(inner))
404
+ return this.#carriesCode(inner.whenTrue, base) || this.#carriesCode(inner.whenFalse, base);
405
+ if (ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression))
406
+ return inner.expression.expression.kind === ts.SyntaxKind.SuperKeyword && !!base && this.#toJsonHasCode(base);
407
+ if (!ts.isObjectLiteralExpression(inner)) return false;
408
+ return inner.properties.some((property) =>
409
+ ts.isSpreadAssignment(property)
410
+ ? this.#carriesCode(property.expression, base)
411
+ : property.name?.getText() === "code",
412
+ );
413
+ }
414
+
415
+ #classes(): ClassNode[] {
416
+ const classes: ClassNode[] = [this.#akanError];
417
+ const visit = (node: ts.Node) => {
418
+ if (isClassNode(node) && node !== this.#akanError && this.#descendsFromAkanError(node)) classes.push(node);
419
+ ts.forEachChild(node, visit);
420
+ };
421
+ for (const sourceFile of this.#ownSources()) visit(sourceFile);
422
+ return classes;
423
+ }
424
+
425
+ #descendsFromAkanError(node: ClassNode): boolean {
426
+ const known = this.#descends.get(node);
427
+ if (known !== undefined) return known;
428
+ this.#descends.set(node, false);
429
+ const base = this.#baseOf(node);
430
+ const descends = !!base && (base === this.#akanError || this.#descendsFromAkanError(base));
431
+ this.#descends.set(node, descends);
432
+ return descends;
433
+ }
434
+
435
+ #baseOf(node: ClassNode): ClassNode | null {
436
+ const extended = node.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0];
437
+ return extended ? this.#classOf(extended.expression) : null;
438
+ }
439
+
440
+ #classOf(expression: ts.Expression): ClassNode | null {
441
+ let symbol = this.#checker.getSymbolAtLocation(expression);
442
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = this.#checker.getAliasedSymbol(symbol);
443
+ for (const declaration of symbol?.declarations ?? []) {
444
+ if (isClassNode(declaration)) return declaration;
445
+ if (ts.isPropertyDeclaration(declaration) && declaration.initializer) {
446
+ const initializer = unwrapped(declaration.initializer);
447
+ if (isClassNode(initializer)) return initializer;
448
+ }
449
+ }
450
+ return null;
451
+ }
452
+
453
+ #nameOf(node: ClassNode) {
454
+ const { parent } = node;
455
+ if (ts.isPropertyDeclaration(parent) && isClassNode(parent.parent) && parent.parent.name)
456
+ return `${parent.parent.name.text}.${parent.name.getText()}`;
457
+ return node.name?.text ?? "(anonymous)";
458
+ }
459
+
460
+ #newExpressions() {
461
+ const found: ts.NewExpression[] = [];
462
+ const visit = (node: ts.Node) => {
463
+ if (ts.isNewExpression(node)) found.push(node);
464
+ ts.forEachChild(node, visit);
465
+ };
466
+ for (const sourceFile of this.#ownSources()) visit(sourceFile);
467
+ return found;
468
+ }
469
+
470
+ #ownSources() {
471
+ return this.#program.getSourceFiles().filter((sourceFile) => this.#roots.has(this.#relative(sourceFile.fileName)));
472
+ }
473
+
474
+ #relative(fileName: string) {
475
+ return path.relative(this.#dir, fileName).split(path.sep).join("/");
476
+ }
477
+
478
+ //? Tracked files only, unlike the gate: an untracked scratch file would otherwise shift the page CI compares.
479
+ static #sourcesOf(dir: string) {
480
+ const listed = Bun.spawnSync(["git", "ls-files", "-z", "--", "."], { cwd: dir, stdout: "pipe", stderr: "pipe" });
481
+ if (listed.exitCode !== 0) throw new Error(`git ls-files failed in ${dir}: ${listed.stderr.toString().trim()}`);
482
+ return listed.stdout
483
+ .toString()
484
+ .split("\0")
485
+ .filter((file) => file && !file.includes("node_modules/") && MaintainabilityLimits.isPackageSource(file));
486
+ }
487
+
488
+ //? Parsing every source costs seconds; a file that names no AkanError descendant can neither declare nor build one.
489
+ static #rootsOf(dir: string) {
490
+ const texts = ErrorCodeScan.#sourcesOf(dir)
491
+ .filter((file) => existsSync(path.join(dir, file)))
492
+ .map((file) => [file, readFileSync(path.join(dir, file), "utf8")] as const);
493
+ const edges = texts.flatMap(([, text]) => [...ErrorCodeScan.#extendsIn(text)]);
494
+ const names = new Set(["AkanError"]);
495
+ for (let grown = true; grown; ) {
496
+ grown = false;
497
+ for (const [name, base] of edges) {
498
+ if (!names.has(base) || names.has(name)) continue;
499
+ names.add(name);
500
+ grown = true;
501
+ }
502
+ }
503
+ const escaped = [...names].map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
504
+ const mention = new RegExp(`(?<![\\w$])(?:${escaped.join("|")})(?![\\w$])`);
505
+ return new Set(texts.filter(([, text]) => mention.test(text)).map(([file]) => file));
506
+ }
507
+
508
+ static *#extendsIn(text: string): Generator<readonly [name: string, base: string]> {
509
+ for (const head of text.matchAll(/\bclass\s+([A-Za-z_$][\w$]*)\s*/g)) {
510
+ const [matched, name] = head;
511
+ if (!name) continue;
512
+ let at = head.index + matched.length;
513
+ for (let depth = 0; at < text.length && (text[at] === "<" || depth > 0); at++) {
514
+ if (text[at] === "<") depth++;
515
+ else if (text[at] === ">" && text[at - 1] !== "=") depth--;
516
+ }
517
+ const heritage = /\s*extends\s+([A-Za-z_$][\w$]*)/y;
518
+ heritage.lastIndex = at;
519
+ const extended: RegExpExecArray | null = heritage.exec(text);
520
+ const base = extended?.[1];
521
+ if (base) yield [name, base];
522
+ }
523
+ }
524
+
525
+ static #statusTableOf(akanError: ts.ClassDeclaration) {
526
+ const table = new Map<number, readonly [string, string]>();
527
+ const property = akanError.members.find(
528
+ (member): member is ts.PropertyDeclaration =>
529
+ ts.isPropertyDeclaration(member) && member.name.getText() === "#statuses",
530
+ );
531
+ const initializer = property?.initializer ? unwrapped(property.initializer) : null;
532
+ if (!initializer || !ts.isObjectLiteralExpression(initializer))
533
+ throw new Error("AkanError holds no #statuses table to name a status's code by");
534
+ for (const entry of initializer.properties) {
535
+ if (!ts.isPropertyAssignment(entry) || !ts.isArrayLiteralExpression(entry.initializer)) continue;
536
+ const [code, reason] = entry.initializer.elements;
537
+ if (code && reason && ts.isStringLiteral(code) && ts.isStringLiteral(reason))
538
+ table.set(Number(entry.name.getText()), [code.text, reason.text]);
539
+ }
540
+ return table;
541
+ }
542
+
543
+ static #byCode(a: ErrorCodeRow, b: ErrorCodeRow) {
544
+ return (
545
+ byCodeUnit(a.code ?? "~", b.code ?? "~") ||
546
+ Number(a.byStatus) - Number(b.byStatus) ||
547
+ (a.statusCode ?? 0) - (b.statusCode ?? 0) ||
548
+ byCodeUnit(a.error, b.error)
549
+ );
550
+ }
551
+ }
@@ -32,14 +32,18 @@ const writeApp = async () => {
32
32
  );
33
33
  await write(
34
34
  path.join(appDir, "page/memo.tsx"),
35
- 'import { Memo } from "../ui/Memo";\nexport default function Page() {\n return <Memo />;\n}\n',
35
+ 'import { System } from "akanjs/ui/System";\nimport { Memo } from "../ui/Memo";\nexport default function Page() {\n return <><Memo /><System.ThemeToggle /></>;\n}\n',
36
+ );
37
+ await write(
38
+ path.join(appDir, "page/shell.tsx"),
39
+ 'import { CSR } from "akanjs/ui/System/CSR";\nexport default function Page() {\n return <CSR />;\n}\n',
36
40
  );
37
41
  const app = {
38
42
  name: "demo",
39
43
  cwdPath: appDir,
40
44
  dist: { cwdPath: path.join(root, "dist/apps/demo") },
41
45
  workspace: { workspaceRoot: root },
42
- getPageKeys: async () => ["./_index.tsx", "./memo.tsx"],
46
+ getPageKeys: async () => ["./_index.tsx", "./memo.tsx", "./shell.tsx"],
43
47
  getConfig: async () => ({ basePaths: [], optimizeImports: [], barrelImports: [] }),
44
48
  getTsConfig: async () => ({ compilerOptions: { paths } }),
45
49
  getPublicEnv: () => ({}),
@@ -51,12 +55,12 @@ const writeApp = async () => {
51
55
  };
52
56
 
53
57
  describe("AllRoutesBuilder", () => {
54
- test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules", async () => {
58
+ test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules without System's CSR and MCP App providers unless the page imports one", async () => {
55
59
  const app = await writeApp();
56
60
  const routes = new AllRoutesBuilder(app as never, "build");
57
61
  const { manifest, manifestPath } = await routes.build();
58
62
 
59
- expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo"]));
63
+ expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo", "/:lang/shell"]));
60
64
  expect(await Bun.file(manifestPath).json()).not.toHaveProperty("modulePreloads");
61
65
 
62
66
  const runtimeChunks = { runtime: ["/_akan/client/rsc.js"], vendors: { react: ["/_akan/client/react.js"] } };
@@ -70,8 +74,14 @@ describe("AllRoutesBuilder", () => {
70
74
  const [home] = chunksOf("apps/demo/ui/Home.tsx#Home");
71
75
  const [memo] = chunksOf("apps/demo/ui/Memo.tsx#Memo");
72
76
  const shell = chunksOf("akanjs/ui/System/Client.tsx#Client");
77
+ const [csrShell] = chunksOf("akanjs/ui/System/CSR.tsx#CSR");
78
+ const [mcpAppShell] = chunksOf("akanjs/ui/System/App.tsx#AppProvider");
79
+ const [themeToggle] = chunksOf("akanjs/ui/System/ThemeToggle.tsx#ThemeToggle");
73
80
  const modules = preloads.modulesByRoute();
74
81
  expect(shell.length).toBeGreaterThan(0);
82
+ expect([csrShell, mcpAppShell, themeToggle]).not.toContain(undefined);
83
+ expect(modules["/:lang/memo"]).toContain(themeToggle);
84
+ expect(modules["/:lang/shell"]).toContain(csrShell);
75
85
  for (const [routeId, own, other] of [
76
86
  ["/:lang", home, memo],
77
87
  ["/:lang/memo", memo, home],
@@ -79,6 +89,8 @@ describe("AllRoutesBuilder", () => {
79
89
  expect(modules[routeId]?.slice(0, 2)).toEqual(["/_akan/client/rsc.js", "/_akan/client/react.js"]);
80
90
  expect(modules[routeId]).toEqual(expect.arrayContaining([own, ...shell]));
81
91
  expect(modules[routeId]).not.toContain(other);
92
+ expect(modules[routeId]).not.toContain(csrShell);
93
+ expect(modules[routeId]).not.toContain(mcpAppShell);
82
94
  }
83
95
  }, 30_000);
84
96
  });
@@ -1,6 +1,11 @@
1
1
  import path from "node:path";
2
2
  import type { RoutesManifest } from "akanjs/server";
3
- import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
3
+ import {
4
+ isImplicitRootLayoutKey,
5
+ type PageEntry,
6
+ ROOT_LAYOUT_SYSTEM,
7
+ resolveSsrPageEntriesForApp,
8
+ } from "../artifact/implicitRootLayout";
4
9
  import { computeRouteSeedIndex, type RouteSeedIndex } from "../artifact/routeSeedIndex";
5
10
  import type { App } from "../commandDecorators";
6
11
  import type { ClientEntryDiscovery } from "./clientBuildTypes";
@@ -26,7 +31,7 @@ export class AllRoutesBuilder {
26
31
  };
27
32
  #knownSet = new Set<string>();
28
33
  #routeIds: string[] = [];
29
- #entriesByRoute = new Map<string, string[]>();
34
+ #ssrEntriesByRoute = new Map<string, string[]>();
30
35
  #externalsByChunkUrl: Record<string, string[]> = {};
31
36
  #discovery: ClientEntryDiscovery | null = null;
32
37
 
@@ -41,15 +46,17 @@ export class AllRoutesBuilder {
41
46
  const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
42
47
  const seedIndex = computeRouteSeedIndex(pageEntries);
43
48
  this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
44
- this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
49
+ const discovery = await GraphClientEntryDiscovery.create(this.#app);
50
+ this.#discovery = discovery;
51
+ const importsAs = await this.#ssrImportsAs(discovery, pageEntries);
45
52
 
46
53
  // Every route's entries go into one Bun.build: chunk splitting only dedupes within a single build.
47
54
  const allEntries = new Set<string>();
48
55
  for (const entry of seedIndex.entries) {
49
56
  const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
50
- const routeEntries = await this.#discovery.discover(seeds);
57
+ const routeEntries = await discovery.discover(seeds);
51
58
  for (const discovered of routeEntries) allEntries.add(discovered);
52
- this.#entriesByRoute.set(entry.routeId, routeEntries);
59
+ this.#ssrEntriesByRoute.set(entry.routeId, await discovery.discover(seeds, { importsAs }));
53
60
  this.#routeIds.push(entry.routeId);
54
61
  }
55
62
  this.#app.verbose(`[build-all] ${allEntries.size} client entries across ${this.#routeIds.length} routes`);
@@ -71,10 +78,21 @@ export class AllRoutesBuilder {
71
78
  return { manifest, manifestPath, seedIndex };
72
79
  }
73
80
 
81
+ async #ssrImportsAs(discovery: GraphClientEntryDiscovery, pageEntries: PageEntry[]) {
82
+ const { specifier, ssrShell, importsNoSsrPageRenders } = ROOT_LAYOUT_SYSTEM;
83
+ const importsAs = new Map<string, ReadonlyMap<string, string | null>>();
84
+ const toShell = new Map([[specifier, ssrShell]]);
85
+ for (const { key, moduleAbsPath } of pageEntries)
86
+ if (isImplicitRootLayoutKey(key)) importsAs.set(path.resolve(moduleAbsPath), toShell);
87
+ const system = await discovery.resolveImport(specifier, this.#app.cwdPath);
88
+ if (system) importsAs.set(system, new Map(importsNoSsrPageRenders.map((spec) => [spec, null])));
89
+ return importsAs;
90
+ }
91
+
74
92
  async writeModulePreloads(runtimeChunks: RuntimeChunks): Promise<RouteModulePreloads> {
75
93
  const preloads = new RouteModulePreloads({
76
94
  runtimeChunks,
77
- entriesByRoute: this.#entriesByRoute,
95
+ entriesByRoute: this.#ssrEntriesByRoute,
78
96
  clientManifest: this.#merged.clientManifest,
79
97
  externalsByChunkUrl: this.#externalsByChunkUrl,
80
98
  workspaceRoot: this.#app.workspace.workspaceRoot,