@akanjs/devkit 3.2.0-rc.1 → 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.
- package/CHANGELOG.md +233 -11
- package/agentAssets.ts +1 -1
- package/akanApp/akanApp.host.test.ts +127 -0
- package/akanApp/akanApp.host.ts +50 -57
- package/akanApp/devHostPolicy.test.ts +11 -11
- package/akanApp/devHostPolicy.ts +2 -4
- package/akanContext.ts +2 -5
- package/akanMcpContract.ts +21 -10
- package/akanMcpToolError.ts +16 -0
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +497 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +65 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.test.ts +4 -0
- package/artifact/implicitRootLayout.ts +13 -1
- package/artifact/storeBootstrap.ts +3 -2
- package/bareErrorScanner.ts +126 -0
- package/biome.base.json +2 -1
- package/codeAgent/skills/akan-debug/SKILL.md +7 -3
- package/codeAgent/skills/akan-err/SKILL.md +7 -0
- package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
- package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/codeAgent/skills/akan-validate/SKILL.md +1 -0
- package/codeAgent/tools/McpClient.ts +28 -6
- package/codeAgent/tools/McpToolPack.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/cliError.ts +72 -0
- package/commandDecorators/command.ts +186 -96
- package/commandDecorators/commandDecorators.test.ts +240 -17
- package/commandDecorators/exitCodeError.ts +6 -7
- package/commandDecorators/index.ts +1 -0
- package/commandDecorators/targetMeta.ts +3 -0
- package/conventionSet.test.ts +5 -2
- package/csrE2e/csrE2eHarness.fixture.ts +1 -1
- package/errorCodeReference.test.ts +282 -0
- package/errorCodeReference.ts +71 -0
- package/errorCodeScan.ts +551 -0
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
- package/frontendBuild/clientEntryDiscovery.ts +53 -9
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/precompressArtifacts.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
- package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
- package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
- package/lint/no-import-common-err.grit +31 -0
- package/lint/no-init-fetch-in-client.grit +1 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/maintainabilityBaseline.test.ts +49 -0
- package/maintainabilityBaseline.ts +73 -8
- package/maintainabilityContract.test.ts +302 -1
- package/maintainabilityContract.ts +87 -22
- package/maintainabilityExceptionScanner.test.ts +14 -0
- package/maintainabilityExceptionScanner.ts +5 -1
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.ts +20 -3
- package/moduleFieldNames.test.ts +56 -0
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewView.ts +32 -11
- package/preview/previewView.test.ts +76 -9
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/typecheck/projectTypecheck.ts +5 -1
- package/typecheck/typecheck.proc.ts +3 -2
- package/typecheck/typecheckFailure.ts +6 -1
- package/workflow/artifacts.ts +25 -7
- package/workflow/fieldType.ts +92 -0
- package/workflow/index.ts +2 -0
- package/workflow/plainName.ts +28 -0
- package/workflow/plan.ts +67 -22
- package/workflow/source.ts +0 -5
- package/workspaceGraph.test.ts +200 -3
- package/workspaceGraph.ts +46 -8
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { ApiDeclarationPrinter } from "./apiDeclarationPrinter";
|
|
3
|
+
|
|
4
|
+
export interface ApiTarget {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
origin: string;
|
|
8
|
+
file: ts.SourceFile | null;
|
|
9
|
+
statements: ts.Statement[];
|
|
10
|
+
thirdParty: string | null;
|
|
11
|
+
members: ts.Symbol[] | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ImportBinding {
|
|
15
|
+
specifier: string;
|
|
16
|
+
imported: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//* Statements come from the emitted `.d.ts`, which also holds what the emit adds, such as a mixin class's `X_base`.
|
|
20
|
+
export class ApiTargets {
|
|
21
|
+
readonly #program: ts.Program;
|
|
22
|
+
readonly #checker: ts.TypeChecker;
|
|
23
|
+
readonly #emitted: ReadonlyMap<string, string>;
|
|
24
|
+
readonly #parsed = new Map<string, ts.SourceFile>();
|
|
25
|
+
readonly #indexes = new Map<ts.SourceFile, Map<string, ts.Statement[]>>();
|
|
26
|
+
readonly #imports = new Map<ts.SourceFile, Map<string, ImportBinding>>();
|
|
27
|
+
readonly #modules = new Map<string, ts.SourceFile | null>();
|
|
28
|
+
readonly #moduleExports = new Map<ts.SourceFile, Map<string, ts.Symbol>>();
|
|
29
|
+
readonly #references = new Map<string, ApiTarget[]>();
|
|
30
|
+
|
|
31
|
+
constructor(program: ts.Program, emitted: ReadonlyMap<string, string>) {
|
|
32
|
+
this.#program = program;
|
|
33
|
+
this.#checker = program.getTypeChecker();
|
|
34
|
+
this.#emitted = emitted;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
exportsOf(sourceFile: ts.SourceFile) {
|
|
38
|
+
const moduleSymbol = this.#checker.getSymbolAtLocation(sourceFile);
|
|
39
|
+
return moduleSymbol ? this.#checker.getExportsOfModule(moduleSymbol) : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
typeTextOf(symbol: ts.Symbol) {
|
|
43
|
+
const target = symbol.flags & ts.SymbolFlags.Alias ? this.#checker.getAliasedSymbol(symbol) : symbol;
|
|
44
|
+
return this.#checker.typeToString(
|
|
45
|
+
this.#checker.getTypeOfSymbol(target),
|
|
46
|
+
undefined,
|
|
47
|
+
ts.TypeFormatFlags.NoTruncation,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ofSymbol(symbol: ts.Symbol): ApiTarget[] {
|
|
52
|
+
const target = symbol.flags & ts.SymbolFlags.Alias ? this.#checker.getAliasedSymbol(symbol) : symbol;
|
|
53
|
+
const targets = new Map<string, ApiTarget>();
|
|
54
|
+
for (const declaration of target.declarations ?? []) {
|
|
55
|
+
const resolved = this.#ofDeclaration(declaration);
|
|
56
|
+
if (resolved && !targets.has(resolved.id)) targets.set(resolved.id, resolved);
|
|
57
|
+
}
|
|
58
|
+
return [...targets.values()];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
expand(targets: ApiTarget[], seen = new Set<string>()): ApiTarget[] {
|
|
62
|
+
return targets.flatMap((target) => {
|
|
63
|
+
if (!target.members) return [target];
|
|
64
|
+
if (seen.has(target.id)) return [];
|
|
65
|
+
seen.add(target.id);
|
|
66
|
+
return this.expand(
|
|
67
|
+
target.members.flatMap((member) => this.ofSymbol(member)),
|
|
68
|
+
seen,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
referencesOf(target: ApiTarget): ApiTarget[] {
|
|
74
|
+
const cached = this.#references.get(target.id);
|
|
75
|
+
if (cached) return cached;
|
|
76
|
+
const file = target.file;
|
|
77
|
+
const found = file
|
|
78
|
+
? target.statements.flatMap((statement) =>
|
|
79
|
+
this.#referencesIn(ApiDeclarationPrinter.prepared(statement), file, target.origin),
|
|
80
|
+
)
|
|
81
|
+
: [];
|
|
82
|
+
const references = this.expand(found);
|
|
83
|
+
this.#references.set(target.id, references);
|
|
84
|
+
return references;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#ofDeclaration(declaration: ts.Declaration): ApiTarget | null {
|
|
88
|
+
if (ts.isSourceFile(declaration)) {
|
|
89
|
+
return {
|
|
90
|
+
id: `${declaration.fileName}\0*`,
|
|
91
|
+
name: "*",
|
|
92
|
+
origin: declaration.fileName,
|
|
93
|
+
file: null,
|
|
94
|
+
statements: [],
|
|
95
|
+
thirdParty: ApiTargets.packageOf(declaration.fileName),
|
|
96
|
+
members: this.exportsOf(declaration),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const sourceFile = declaration.getSourceFile();
|
|
100
|
+
const name = ApiTargets.localNameOf(declaration);
|
|
101
|
+
if (name === null) return null;
|
|
102
|
+
const thirdParty = ApiTargets.packageOf(sourceFile.fileName);
|
|
103
|
+
const base = { id: `${sourceFile.fileName}\0${name}`, name, origin: sourceFile.fileName, members: null };
|
|
104
|
+
if (thirdParty) return { ...base, file: null, statements: [], thirdParty };
|
|
105
|
+
const file = sourceFile.isDeclarationFile ? sourceFile : this.#declarationFileOf(sourceFile);
|
|
106
|
+
if (!file) return { ...base, file: null, statements: [], thirdParty: null };
|
|
107
|
+
const statements = this.#indexOf(file).get(name);
|
|
108
|
+
if (statements) return { ...base, file, statements, thirdParty: null };
|
|
109
|
+
const nested = ApiTargets.#statementOf(declaration);
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
112
|
+
id: `${base.id}\0${declaration.pos}`,
|
|
113
|
+
file,
|
|
114
|
+
statements: nested && sourceFile.isDeclarationFile ? [nested] : [],
|
|
115
|
+
thirdParty: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#referencesIn(root: ts.Node, file: ts.SourceFile, origin: string): ApiTarget[] {
|
|
120
|
+
const found: ApiTarget[] = [];
|
|
121
|
+
const visit = (node: ts.Node) => {
|
|
122
|
+
if (ts.isTypeReferenceNode(node))
|
|
123
|
+
found.push(...this.#resolve(ApiTargets.#segmentsOf(node.typeName), node, file, origin));
|
|
124
|
+
else if (ts.isExpressionWithTypeArguments(node))
|
|
125
|
+
found.push(...this.#resolve(ApiTargets.#segmentsOf(node.expression), node, file, origin));
|
|
126
|
+
else if (ts.isTypeQueryNode(node))
|
|
127
|
+
found.push(...this.#resolve(ApiTargets.#segmentsOf(node.exprName), node, file, origin));
|
|
128
|
+
else if (ts.isComputedPropertyName(node))
|
|
129
|
+
found.push(...this.#resolve(ApiTargets.#segmentsOf(node.expression), node, file, origin));
|
|
130
|
+
else if (ts.isImportTypeNode(node)) found.push(...this.#resolveImportType(node, origin));
|
|
131
|
+
ts.forEachChild(node, visit);
|
|
132
|
+
};
|
|
133
|
+
visit(root);
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#resolve(segments: string[] | null, at: ts.Node, file: ts.SourceFile, origin: string): ApiTarget[] {
|
|
138
|
+
const [head, next] = segments ?? [];
|
|
139
|
+
if (!head || ApiTargets.#isTypeParameter(at, head)) return [];
|
|
140
|
+
const local = this.#indexOf(file).get(head);
|
|
141
|
+
if (local) return [this.#local(file, origin, head, local)];
|
|
142
|
+
const binding = this.#importsOf(file).get(head);
|
|
143
|
+
if (!binding) return [];
|
|
144
|
+
const module = this.#moduleOf(binding.specifier, origin);
|
|
145
|
+
if (!module) return [];
|
|
146
|
+
if (binding.imported !== "*") return this.#exported(module, binding.imported);
|
|
147
|
+
return next ? this.#exported(module, next) : this.#namespaceOf(module);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#resolveImportType(node: ts.ImportTypeNode, origin: string): ApiTarget[] {
|
|
151
|
+
const argument = node.argument;
|
|
152
|
+
if (!ts.isLiteralTypeNode(argument) || !ts.isStringLiteral(argument.literal)) return [];
|
|
153
|
+
const module = this.#moduleOf(argument.literal.text, origin);
|
|
154
|
+
if (!module) return [];
|
|
155
|
+
const [name] = node.qualifier ? (ApiTargets.#segmentsOf(node.qualifier) ?? []) : [];
|
|
156
|
+
return name ? this.#exported(module, name) : this.#namespaceOf(module);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#local(file: ts.SourceFile, origin: string, name: string, statements: ts.Statement[]): ApiTarget {
|
|
160
|
+
return { id: `${origin}\0${name}`, name, origin, file, statements, thirdParty: null, members: null };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#exported(module: ts.SourceFile, name: string) {
|
|
164
|
+
const symbol = this.#moduleExportsOf(module).get(name);
|
|
165
|
+
return symbol ? this.ofSymbol(symbol) : [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#namespaceOf(module: ts.SourceFile): ApiTarget[] {
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
id: `${module.fileName}\0*`,
|
|
172
|
+
name: "*",
|
|
173
|
+
origin: module.fileName,
|
|
174
|
+
file: null,
|
|
175
|
+
statements: [],
|
|
176
|
+
thirdParty: null,
|
|
177
|
+
members: [...this.#moduleExportsOf(module).values()],
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#moduleExportsOf(module: ts.SourceFile) {
|
|
183
|
+
const cached = this.#moduleExports.get(module);
|
|
184
|
+
if (cached) return cached;
|
|
185
|
+
const exports = new Map(this.exportsOf(module).map((symbol) => [symbol.getName(), symbol]));
|
|
186
|
+
this.#moduleExports.set(module, exports);
|
|
187
|
+
return exports;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#moduleOf(specifier: string, origin: string) {
|
|
191
|
+
const key = `${origin}\0${specifier}`;
|
|
192
|
+
if (this.#modules.has(key)) return this.#modules.get(key) ?? null;
|
|
193
|
+
const resolved = ts.resolveModuleName(specifier, origin, this.#program.getCompilerOptions(), ts.sys).resolvedModule;
|
|
194
|
+
const fileName = resolved?.resolvedFileName;
|
|
195
|
+
const module = fileName && !ApiTargets.packageOf(fileName) ? (this.#program.getSourceFile(fileName) ?? null) : null;
|
|
196
|
+
this.#modules.set(key, module);
|
|
197
|
+
return module;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#declarationFileOf(sourceFile: ts.SourceFile) {
|
|
201
|
+
const cached = this.#parsed.get(sourceFile.fileName);
|
|
202
|
+
if (cached) return cached;
|
|
203
|
+
const text = this.#emitted.get(sourceFile.fileName);
|
|
204
|
+
if (text === undefined) return null;
|
|
205
|
+
const fileName = sourceFile.fileName.replace(/\.tsx?$/, ".d.ts");
|
|
206
|
+
const parsed = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
|
|
207
|
+
this.#parsed.set(sourceFile.fileName, parsed);
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#indexOf(file: ts.SourceFile) {
|
|
212
|
+
const cached = this.#indexes.get(file);
|
|
213
|
+
if (cached) return cached;
|
|
214
|
+
const index = new Map<string, ts.Statement[]>();
|
|
215
|
+
const add = (name: string, statement: ts.Statement) => index.set(name, [...(index.get(name) ?? []), statement]);
|
|
216
|
+
for (const statement of file.statements) {
|
|
217
|
+
if (ts.isVariableStatement(statement)) {
|
|
218
|
+
for (const declaration of statement.declarationList.declarations)
|
|
219
|
+
if (ts.isIdentifier(declaration.name))
|
|
220
|
+
add(declaration.name.text, ApiTargets.#narrowed(statement, declaration));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const name = ApiDeclarationPrinter.isNamed(statement) ? ApiTargets.localNameOf(statement) : null;
|
|
224
|
+
if (name !== null) add(name, statement);
|
|
225
|
+
}
|
|
226
|
+
this.#indexes.set(file, index);
|
|
227
|
+
return index;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#importsOf(file: ts.SourceFile) {
|
|
231
|
+
const cached = this.#imports.get(file);
|
|
232
|
+
if (cached) return cached;
|
|
233
|
+
const bindings = new Map<string, ImportBinding>();
|
|
234
|
+
for (const statement of file.statements) {
|
|
235
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
236
|
+
const specifier = statement.moduleSpecifier.text;
|
|
237
|
+
const clause = statement.importClause;
|
|
238
|
+
if (clause?.name) bindings.set(clause.name.text, { specifier, imported: "default" });
|
|
239
|
+
const named = clause?.namedBindings;
|
|
240
|
+
if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { specifier, imported: "*" });
|
|
241
|
+
if (named && ts.isNamedImports(named))
|
|
242
|
+
for (const element of named.elements)
|
|
243
|
+
bindings.set(element.name.text, { specifier, imported: (element.propertyName ?? element.name).text });
|
|
244
|
+
}
|
|
245
|
+
this.#imports.set(file, bindings);
|
|
246
|
+
return bindings;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
static localNameOf(declaration: ts.Declaration): string | null {
|
|
250
|
+
const name = ts.getNameOfDeclaration(declaration);
|
|
251
|
+
if (name && ts.isIdentifier(name)) return name.text;
|
|
252
|
+
if (name) return null;
|
|
253
|
+
const modifiers = ts.canHaveModifiers(declaration) ? ts.getModifiers(declaration) : undefined;
|
|
254
|
+
return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ? "default" : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static packageOf(fileName: string) {
|
|
258
|
+
const normalized = fileName.replaceAll("\\", "/");
|
|
259
|
+
const idx = normalized.lastIndexOf("/node_modules/");
|
|
260
|
+
if (idx < 0) return null;
|
|
261
|
+
const [scope = "", name = ""] = normalized.slice(idx + "/node_modules/".length).split("/");
|
|
262
|
+
return scope.startsWith("@") ? `${scope}/${name}` : scope;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
static #statementOf(declaration: ts.Declaration): ts.Statement | null {
|
|
266
|
+
if (ts.isVariableDeclaration(declaration)) {
|
|
267
|
+
const statement = declaration.parent.parent;
|
|
268
|
+
return ts.isVariableStatement(statement) ? ApiTargets.#narrowed(statement, declaration) : null;
|
|
269
|
+
}
|
|
270
|
+
return ApiDeclarationPrinter.isNamed(declaration) ? declaration : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
static #narrowed(statement: ts.VariableStatement, declaration: ts.VariableDeclaration) {
|
|
274
|
+
if (statement.declarationList.declarations.length === 1) return statement;
|
|
275
|
+
const narrowed = ts.factory.updateVariableStatement(
|
|
276
|
+
statement,
|
|
277
|
+
statement.modifiers,
|
|
278
|
+
ts.factory.updateVariableDeclarationList(statement.declarationList, [declaration]),
|
|
279
|
+
);
|
|
280
|
+
ts.setOriginalNode(narrowed, statement);
|
|
281
|
+
return narrowed;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
static #segmentsOf(node: ts.Node): string[] | null {
|
|
285
|
+
if (ts.isIdentifier(node)) return [node.text];
|
|
286
|
+
if (ts.isQualifiedName(node)) {
|
|
287
|
+
const left = ApiTargets.#segmentsOf(node.left);
|
|
288
|
+
return left && [...left, node.right.text];
|
|
289
|
+
}
|
|
290
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) {
|
|
291
|
+
const left = ApiTargets.#segmentsOf(node.expression);
|
|
292
|
+
return left && [...left, node.name.text];
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
static #isTypeParameter(at: ts.Node, name: string) {
|
|
298
|
+
for (let node = at.parent; node && !ts.isSourceFile(node); node = node.parent) {
|
|
299
|
+
const typeParameters = (node as { typeParameters?: ts.NodeArray<ts.TypeParameterDeclaration> }).typeParameters;
|
|
300
|
+
if (typeParameters?.some((parameter) => parameter.name.text === name)) return true;
|
|
301
|
+
if (ts.isMappedTypeNode(node) && node.typeParameter.name.text === name) return true;
|
|
302
|
+
if (ts.isConditionalTypeNode(node) && ApiTargets.#infers(node.extendsType, name)) return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
static #infers(node: ts.Node, name: string): boolean {
|
|
308
|
+
if (ts.isInferTypeNode(node) && node.typeParameter.name.text === name) return true;
|
|
309
|
+
return ts.forEachChild(node, (child) => ApiTargets.#infers(child, name) || undefined) ?? false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type Bump = "major" | "minor" | "patch";
|
|
2
|
+
|
|
3
|
+
export class ChangesetBump {
|
|
4
|
+
static readonly #rank: Record<Bump, number> = { patch: 0, minor: 1, major: 2 };
|
|
5
|
+
static readonly #entry = /^\s*(["']?)([^"'#:\s][^"'#:]*?)\1\s*:\s*(["']?)(major|minor|patch)\3\s*(?:#.*)?$/;
|
|
6
|
+
|
|
7
|
+
static of(text: string, packageName: string): Bump | null {
|
|
8
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text.replace(/^/, ""))?.[1] ?? "";
|
|
9
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
10
|
+
const match = ChangesetBump.#entry.exec(line);
|
|
11
|
+
if (match?.[2] === packageName) return match[4] as Bump;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static atLeast(bump: Bump | null, floor: Bump) {
|
|
17
|
+
return bump !== null && ChangesetBump.#rank[bump] >= ChangesetBump.#rank[floor];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -51,4 +51,25 @@ describe("ApplicationBuildReporter.printSummary", () => {
|
|
|
51
51
|
lines.indexOf("Route artifacts: dist/apps/shop/.akan/artifact"),
|
|
52
52
|
);
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
test("prints only the route JS budget warnings for a quiet build", () => {
|
|
56
|
+
const routeJsWarnings = [{ file: "apps/shop/akan.budget.json", message: "unreadable", fix: "correct it" }];
|
|
57
|
+
ApplicationBuildReporter.printRouteJsWarnings({ routeJsWarnings });
|
|
58
|
+
ApplicationBuildReporter.printRouteJsWarnings({});
|
|
59
|
+
|
|
60
|
+
expect(rawLog.mock.calls.map(([line]) => line)).toEqual([
|
|
61
|
+
"Warning: apps/shop/akan.budget.json: unreadable (correct it)",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("prints each route JS budget warning of a build that only reports it", () => {
|
|
66
|
+
const routeJsWarnings = [
|
|
67
|
+
{ file: "apps/shop/akan.budget.json", message: "/:lang/memo loads 133,120 bytes brotli", fix: "shrink it" },
|
|
68
|
+
];
|
|
69
|
+
ApplicationBuildReporter.printSummary({ ...resultFor("main"), routeJsWarnings });
|
|
70
|
+
|
|
71
|
+
expect(rawLog.mock.calls.map(([line]) => line)).toContain(
|
|
72
|
+
"Warning: apps/shop/akan.budget.json: /:lang/memo loads 133,120 bytes brotli (shrink it)",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
54
75
|
});
|
|
@@ -40,12 +40,20 @@ export class ApplicationBuildReporter {
|
|
|
40
40
|
for (const line of RouteJsBudget.formatTable(result.routeJs)) Logger.rawLog(line);
|
|
41
41
|
Logger.rawLog("");
|
|
42
42
|
}
|
|
43
|
+
if (result.routeJsWarnings?.length) {
|
|
44
|
+
ApplicationBuildReporter.printRouteJsWarnings(result);
|
|
45
|
+
Logger.rawLog("");
|
|
46
|
+
}
|
|
43
47
|
Logger.rawLog(`Route artifacts: ${result.artifactDir}`);
|
|
44
48
|
Logger.rawLog(`Server output: ${result.outputDir}`);
|
|
45
49
|
Logger.rawLog(`Done in ${ApplicationBuildReporter.formatDuration(result.durationMs)}`);
|
|
46
50
|
if (result.environment === "local") Logger.rawLog(ApplicationBuildReporter.localBuildNotice);
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
static printRouteJsWarnings({ routeJsWarnings = [] }: Pick<ApplicationBuildResult, "routeJsWarnings">) {
|
|
54
|
+
for (const { file, message, fix } of routeJsWarnings) Logger.rawLog(`Warning: ${file}: ${message} (${fix})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
static readonly localBuildNotice =
|
|
50
58
|
"Built for local: the server runs without endpoint rate limits, and boots on local defaults (Redis on localhost, the first database mode) where a deployment would stop to ask. For a host others can reach, run `akan build <app> --env main` with AKAN_PUBLIC_REPO_NAME and AKAN_PUBLIC_SERVE_DOMAIN set in the shell or the workspace .env.";
|
|
51
59
|
|
|
@@ -7,6 +7,7 @@ import { ProductionLockfile } from "./akanConfig/productionLockfile";
|
|
|
7
7
|
import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
|
|
8
8
|
import type { App } from "./commandDecorators";
|
|
9
9
|
import { AppExecutor, Executor } from "./executors";
|
|
10
|
+
import { AllRoutesBuilder, type BuildAllRoutesResult, VendorNarrowing } from "./frontendBuild";
|
|
10
11
|
import { bundleDefine } from "./frontendBuild/bundleDefine";
|
|
11
12
|
import { createTempApp } from "./testHelpers";
|
|
12
13
|
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
@@ -307,6 +308,72 @@ console.info(new SampleService().name);
|
|
|
307
308
|
}
|
|
308
309
|
});
|
|
309
310
|
|
|
311
|
+
const buildSsrUntilVendors = async (routeChunkWritten: boolean) => {
|
|
312
|
+
const { root, workspace } = await createTempApp("portal");
|
|
313
|
+
const keys = ["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const;
|
|
314
|
+
const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
315
|
+
await Promise.all(["main.ts", "server.ts"].map((file) => writeFile(path.join(root, "apps", "portal", file), "")));
|
|
316
|
+
const app = AppExecutor.from(workspace, "portal");
|
|
317
|
+
app.prepareCommand = async () => ({ env: {} });
|
|
318
|
+
app.getPageKeys = async () => ["_index.tsx"];
|
|
319
|
+
const order: string[] = [];
|
|
320
|
+
let narrowed: Record<string, readonly string[]> = {};
|
|
321
|
+
const entryExports = VendorNarrowing.prototype.entryExports;
|
|
322
|
+
const bundle = spyOn(Bun, "build").mockImplementation(
|
|
323
|
+
async () => ({ success: true, outputs: [], logs: [] }) as unknown as Bun.BuildOutput,
|
|
324
|
+
);
|
|
325
|
+
const routes = spyOn(AllRoutesBuilder.prototype, "build").mockImplementation(async () => {
|
|
326
|
+
order.push("routes");
|
|
327
|
+
if (routeChunkWritten)
|
|
328
|
+
await Bun.write(
|
|
329
|
+
path.join(app.dist.cwdPath, ".akan/artifact/client/page-a1.js"),
|
|
330
|
+
'import{st}from"akanjs/store";',
|
|
331
|
+
);
|
|
332
|
+
const clientManifest = { "page#default": { id: "/_akan/client/page-a1.js", chunks: [], name: "default" } };
|
|
333
|
+
return { manifest: { routeIds: ["/"], clientManifest } } as unknown as BuildAllRoutesResult;
|
|
334
|
+
});
|
|
335
|
+
const vendors = spyOn(VendorNarrowing.prototype, "entryExports").mockImplementation(function (
|
|
336
|
+
this: VendorNarrowing,
|
|
337
|
+
entries,
|
|
338
|
+
) {
|
|
339
|
+
order.push("vendors");
|
|
340
|
+
narrowed = entryExports.call(this, entries);
|
|
341
|
+
throw new Error("stopped at the vendor build");
|
|
342
|
+
});
|
|
343
|
+
try {
|
|
344
|
+
const failure = await new ApplicationBuildRunner(app, { fast: true }).build().then(
|
|
345
|
+
() => "built",
|
|
346
|
+
(error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
347
|
+
);
|
|
348
|
+
return { failure, order, narrowed };
|
|
349
|
+
} finally {
|
|
350
|
+
bundle.mockRestore();
|
|
351
|
+
routes.mockRestore();
|
|
352
|
+
vendors.mockRestore();
|
|
353
|
+
for (const key of keys) {
|
|
354
|
+
if (shellEnv[key] === undefined) delete process.env[key];
|
|
355
|
+
else process.env[key] = shellEnv[key];
|
|
356
|
+
}
|
|
357
|
+
await rm(root, { recursive: true, force: true });
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
test("bundles the routes before the vendor entries, which export only the names the route chunks import", async () => {
|
|
362
|
+
const { failure, order, narrowed } = await buildSsrUntilVendors(true);
|
|
363
|
+
|
|
364
|
+
expect(failure).toBe("stopped at the vendor build");
|
|
365
|
+
expect(order).toEqual(["routes", "vendors"]);
|
|
366
|
+
expect(Object.entries(narrowed).find(([entry]) => entry.endsWith("akanjs-store.ts"))?.[1]).toEqual(["st"]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("stops before the vendor entries when a route's client entry is not where the scan reads", async () => {
|
|
370
|
+
const { failure, order } = await buildSsrUntilVendors(false);
|
|
371
|
+
|
|
372
|
+
expect(failure).toContain("no file under");
|
|
373
|
+
expect(failure).toContain("/_akan/client/page-a1.js");
|
|
374
|
+
expect(order).toEqual(["routes"]);
|
|
375
|
+
});
|
|
376
|
+
|
|
310
377
|
test("akan typecheck names the script/ files nothing imports, and a build's typecheck leaves them out", async () => {
|
|
311
378
|
const { root, app } = await createTempApp("portal");
|
|
312
379
|
const run = spyOn(ProjectTypecheck.prototype, "run").mockImplementation(async () => "");
|
|
@@ -55,7 +55,10 @@ export interface ApplicationBuildRunnerOptions {
|
|
|
55
55
|
environment?: string;
|
|
56
56
|
/** Bundles `env/env.server.<environment>.<profile>.ts`, which may hold no secret, in place of the environment's file. */
|
|
57
57
|
profile?: string;
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* `enforce` fails on an overage, a route with no entry, or a missing or unreadable akan.budget.json; `update` and
|
|
60
|
+
* `raise` rewrite the file first; `report` only warns.
|
|
61
|
+
*/
|
|
59
62
|
budget?: RouteJsBudgetMode;
|
|
60
63
|
}
|
|
61
64
|
export interface BuildOptions {
|
|
@@ -437,6 +440,7 @@ void run().catch((error) => {
|
|
|
437
440
|
const vendorExports = await VendorImportScanner.exportsUsedIn(
|
|
438
441
|
path.join(this.#app.dist.cwdPath, ".akan/artifact/client"),
|
|
439
442
|
NARROWABLE_VENDOR_SPECIFIERS,
|
|
443
|
+
Object.values(allRoutes.manifest.clientManifest).map(({ id }) => id),
|
|
440
444
|
);
|
|
441
445
|
const base = await new SsrBaseArtifactBuilder(this.#app, "build", {
|
|
442
446
|
serverEnv: this.#serverEnvPlugin(baseDevEnv.env, branches),
|
|
@@ -456,6 +460,7 @@ void run().catch((error) => {
|
|
|
456
460
|
const budgetPath = path.join(this.#app.cwdPath, RouteJsBudget.fileName);
|
|
457
461
|
const { summary: status, warnings } = await RouteJsBudget.check(sizes, {
|
|
458
462
|
mode: this.#budget,
|
|
463
|
+
app: this.#app.name,
|
|
459
464
|
budgetPath,
|
|
460
465
|
source: path.relative(this.#app.workspace.workspaceRoot, budgetPath),
|
|
461
466
|
environment,
|
|
@@ -53,6 +53,7 @@ describe("resolveSsrPageEntries", () => {
|
|
|
53
53
|
// Its guard runs from that level too, ahead of this one, and an MCP view takes its providers from there.
|
|
54
54
|
expect(generatedSource).toContain("export const guard = userLayout.guard;");
|
|
55
55
|
expect(generatedSource).not.toContain("McpAppRoot");
|
|
56
|
+
expect(generatedSource).not.toContain("LevelBoundary");
|
|
56
57
|
expect(generatedSource).toContain(
|
|
57
58
|
"<UserLayout params={params} searchParams={searchParams}>{children}</UserLayout>",
|
|
58
59
|
);
|
|
@@ -87,6 +88,9 @@ describe("resolveSsrPageEntries", () => {
|
|
|
87
88
|
);
|
|
88
89
|
expect(generatedSource).toContain("export function McpAppRoot({ children, params, path }: McpAppRootProps) {");
|
|
89
90
|
expect(generatedSource).toContain("<System.App");
|
|
91
|
+
expect(generatedSource).toContain(
|
|
92
|
+
'export { RouteLevelBoundary as LevelBoundary } from "akanjs/webkit/RouteLevelBoundary.tsx";',
|
|
93
|
+
);
|
|
90
94
|
expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
|
|
91
95
|
expect(generatedSource).toContain(
|
|
92
96
|
'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
|
|
@@ -71,10 +71,21 @@ export function getRootBoundarySegments(key: string): string[] | null {
|
|
|
71
71
|
return prefix.split("/").filter(Boolean);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
//? The generated root layout serves SSR, CSR and MCP App views alike; an SSR page renders only System's SSR shell.
|
|
75
|
+
export const ROOT_LAYOUT_SYSTEM = {
|
|
76
|
+
specifier: "akanjs/ui/System/index.tsx",
|
|
77
|
+
ssrShell: "akanjs/ui/System/SSR.tsx",
|
|
78
|
+
importsNoSsrPageRenders: ["./CSR", "./App"],
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
74
81
|
function implicitRootLayoutKey(segments: string[]): string {
|
|
75
82
|
return `./${[...segments, "__root_layout"].join("/")}.tsx`;
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
export function isImplicitRootLayoutKey(key: string): boolean {
|
|
86
|
+
return key.endsWith("/__root_layout.tsx");
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
function implicitRootLayoutAbsPath(appCwdPath: string, segments: string[]): string {
|
|
79
90
|
const filename = segments.length ? `${segments.join("__")}__root_layout.tsx` : "__root_layout.tsx";
|
|
80
91
|
return path.join(path.resolve(appCwdPath), IMPLICIT_LAYOUT_DIR, filename);
|
|
@@ -218,7 +229,7 @@ ${layoutBinding} return `;
|
|
|
218
229
|
? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
|
|
219
230
|
import { assertGuardOnChain, loadFonts, RouteGuards, resolveRouteModule, RouteHead } from "akanjs/client";
|
|
220
231
|
import type { ReactNode } from "react";
|
|
221
|
-
import { System } from "
|
|
232
|
+
import { System } from "${ROOT_LAYOUT_SYSTEM.specifier}";
|
|
222
233
|
import type { McpAppRootProps } from "akanjs/webkit";
|
|
223
234
|
import { env } from "@apps/${opts.appName}/env/env.client";
|
|
224
235
|
import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
|
|
@@ -231,6 +242,7 @@ const defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${d
|
|
|
231
242
|
export const theme = userLayout.theme ?? inheritedLayout.theme;
|
|
232
243
|
export const viewport = userLayout.viewport ?? inheritedLayout.viewport;
|
|
233
244
|
export const manifest = userLayout.manifest ?? inheritedLayout.manifest;
|
|
245
|
+
export { RouteLevelBoundary as LevelBoundary } from "akanjs/webkit/RouteLevelBoundary.tsx";
|
|
234
246
|
|
|
235
247
|
${layoutExports}(
|
|
236
248
|
<System.Provider
|
|
@@ -14,8 +14,9 @@ export class StoreBootstrap {
|
|
|
14
14
|
if (!/^@(?:apps|libs)\/[^/]+\/client$/.test(specifier)) return null;
|
|
15
15
|
const runtime = exports.get("usePage");
|
|
16
16
|
const store = exports.get("st");
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
//? A barrel with only one of the two is not the generated shape, so what else it runs on import is unknown.
|
|
18
|
+
if (!runtime || runtime.namespace || !store || store.namespace) return [specifier];
|
|
19
|
+
return [...new Set([runtime.subpath, store.subpath])];
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
static #lines(specifiers: string[]): string[] {
|