@akanjs/devkit 3.2.0-rc.5 → 3.2.0-rc.6
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 +17 -6
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +483 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +64 -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.ts +5 -1
- package/artifact/implicitRootLayout.ts +12 -1
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/clientBuildTypes.ts +5 -1
- package/frontendBuild/clientEntryDiscovery.test.ts +28 -0
- package/frontendBuild/clientEntryDiscovery.ts +25 -6
- package/package.json +3 -3
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
package/CHANGELOG.md
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
- The dev builder draws the graph on request through a new `build-graph` builder RPC, and keeps it until the next save.
|
|
132
132
|
- It sits behind the same gate as the other `/_akan` devtools routes, and `akan tunnel` / `akan start --share` refuse it.
|
|
133
133
|
|
|
134
|
+
- 465ccb9: `@akanjs/devkit/apiReport/apiReport` renders a package's public API, one report per entry point, from its declaration emit. The akanjs repository now commits that report for `akanjs` and checks it in CI: a removed export or a changed declaration needs a `minor` changeset that says how to upgrade.
|
|
134
135
|
- 0504209: `akan build <app> --env <env>` builds for an environment other than the workspace `.env`'s, the way the desktop and mobile builds already do. Before, the shell's `AKAN_PUBLIC_ENV` lost to the `.env`, so a new workspace could only build `local` without editing it. The local-build notice, the image guard and the README now say what a `local` build changes in a built server: no endpoint rate limits, and local defaults for Redis and the database mode. They no longer say the dev routes are served, which a built server never does.
|
|
135
136
|
- bf872ce: A variable the shell sets now outranks the workspace `.env` when `akan` builds or runs an app, as dotenv and Bun's own `.env` loading do: `AKAN_PUBLIC_ENV=main akan build <app>` builds `main` where the `.env` says `local`. The order is `--env` (for `AKAN_PUBLIC_ENV`), then the process environment (the shell, and the `.env.local` and `.env.<NODE_ENV>` Bun loads into it), then the `.env`, then the defaults; an empty export counts as unset. The bundles, the bundled server env, `akan.env.json` and the Dockerfile still take one set of values. Where an exported value outranks the `.env`, `akan build` now warns with both values instead of the old warning that listed the values it ignored, and the server-env message, the `--env` help, the local-build notice, the README and the CLI reference state the new order.
|
|
136
137
|
|
|
@@ -229,19 +230,26 @@
|
|
|
229
230
|
- b101fcf: `akan remove-application`, `remove-library`, `remove-package`, `remove-module` and `remove-scalar` no longer delete a target they picked themselves. Left out, or named but not in the workspace, the target is refused in a terminal or not, with the choices and the line to rerun, such as `` Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-application <app>` (choices: pokedex). `` Before, `akan remove-application` in a one-app workspace removed `apps/<app>` without asking, and without a terminal `akan remove-scalar <name>` removed the scalar from the only app or lib. A terminal is no longer offered a select for these commands. Other commands still take the only app, or the only app or lib without a terminal, as before. A command declares this with `destructive: true` on its target.
|
|
230
231
|
- 44149b1: `akan remove-module` now removes a service module named exactly, as in `akan remove-module pokedex:_billing` for `apps/pokedex/lib/_billing`. Before, no command could remove a service module: `remove-module` left out every `lib/_<name>` folder and refused one by name with `Invalid module name: _billing`. A service module left out or misnamed gets the same refusal as any target of a remove command, and its choices now list the service modules. `create-view`, `create-unit`, `create-template` and `create-crud-page` still take only a model module. A command opts in to service modules by taking `ModuleOrService` instead of `Module`.
|
|
231
232
|
- 4fcf44f: A new `rename-key` workflow renames a slice key or a Filter query key everywhere it is spelled. That covers the key itself, every name derived from it (`initMemoInPublic`, `memoListInPublic`, `queryByName`) in the app and every lib, and its module dictionary entry. It is the same rename the editor extension's F2 runs, so agents get it through `plan_workflow` / `apply_workflow` and the CLI through `akan workflow plan rename-key --app <app> --module <module> --slice <key> --to <newKey>` (or `--query <key>`). The plan lists each file it will change. A use that could also be another model's is left alone, and `akan verify` then reports it.
|
|
233
|
+
- 627dde4: The route JS budget stops failing deploy builds: a plain `akan build` reports an overage, `akan build --check-budget` fails on it, and CI runs that for `apps/minimal` and `apps/akan`, which now commit their `akan.budget.json`. The rc npm publish, which calls CI, skips that job, so an app's budget never holds a release.
|
|
234
|
+
|
|
235
|
+
- **Report by default:** a plain `akan build`, the one a deploy runs, prints a route over its entry or with none, or an unreadable `akan.budget.json`, as a warning under the route table and passes, as `akan verify` does. `--quiet` drops the rest of the summary but still prints the warning.
|
|
236
|
+
- **`--check-budget`** fails the build on an overage, a route with no entry (a new page records its entry with `--update-budget` in the same change), an unreadable file, or an app with no file, naming every overage and missing entry in one failure.
|
|
237
|
+
- **`--raise-budget`** re-records every route at its measured size and names the entries it raised, for the change that grows a route on purpose. An overage now names it with the app's own name, along with the file and section to edit by hand. `--check-budget`, `--update-budget` and `--raise-budget` exclude each other.
|
|
238
|
+
- **`System` counts as the SSR shell:** the generated root layout imports `System` whole because it serves SSR pages, the CSR shell and MCP App views. The measure now reads that import as `System/SSR.tsx` and leaves out `System`'s CSR and MCP App providers wherever an app imports `System`, since no server page renders them; every client entry is still built. On `apps/minimal` the median route drops from 342KB to 307.5KB brotli and the largest, `/:lang/explore`, from 499.1KB to 473.3KB. On `apps/akan` most routes drop about 1KB, because their other client entries load the providers' dependencies anyway.
|
|
239
|
+
|
|
232
240
|
- 9a9e1fa: A server page from `akan build` now preloads its client modules from `<head>`, so a cold load stops waiting on chains of imports.
|
|
233
241
|
|
|
234
242
|
- **What is preloaded:** the rsc client and the chunks it imports, the chunks every client reference row (`I` row) in the shell's flight names, and the importmap vendors those chunks import, each with its own chunks. A client URL that only the page's own text carries is never preloaded. Before, only the rsc client and the locale dictionary were preloaded. The browser found the rest one import level at a time, after the document finished.
|
|
235
|
-
- **Measured on `apps/minimal`:** `/en/explore` preloads
|
|
243
|
+
- **Measured on `apps/minimal`:** `/en/explore` preloads 64 modules and its flight names 37 client chunks, all of them preloaded. `/en/lab/buttons` preloads 165 for 137. Preloading every client component each route's page and layouts import would have meant 274 modules for `/en/explore`, so the preloads follow the flight instead.
|
|
236
244
|
- **A boundary that resolves after the shell** still loads its chunks when its flight rows arrive. `akan start` preloads nothing new, because a dev page loads its client code from the SSR dev registry.
|
|
237
245
|
- `routes-manifest.json` carries the preload table as `modulePreloads`: the runtime chunks, each vendor's chunks, and the vendors each browser chunk imports.
|
|
238
246
|
|
|
239
|
-
- 9a9e1fa: `akan build` measures the JavaScript each server route loads, and
|
|
247
|
+
- 9a9e1fa: `akan build` measures the JavaScript each server route loads, and reports a route that outgrows the budget the app commits; `akan build --check-budget` fails on it.
|
|
240
248
|
|
|
241
|
-
- **The measure:** the runtime, every client module statically reachable from the route's page and layouts, and their vendors, counted in the brotli bytes the server sends. A chunk loaded by a dynamic `import()` is left out, and so is the locale dictionary. A new `Measuring route JS` step names the largest route, and the summary lists the ten largest with their brotli and raw sizes. On `apps/minimal` the largest is `/:lang/explore` at
|
|
242
|
-
- **The budget:** `akan build <app> --update-budget` writes `apps/<app>/akan.budget.json` with the environment it built for and one `routeJsBrotliKB` entry per route. After that,
|
|
243
|
-
- **Where it fails:** `akan build
|
|
244
|
-
- **Opt-in:** an app without the file gets the report and nothing fails. A route without an entry is reported,
|
|
249
|
+
- **The measure:** the runtime, every client module statically reachable from the route's page and layouts, and their vendors, counted in the brotli bytes the server sends. A chunk loaded by a dynamic `import()` is left out, and so is the locale dictionary. A new `Measuring route JS` step names the largest route, and the summary lists the ten largest with their brotli and raw sizes. On `apps/minimal` the largest is `/:lang/explore` at 473.3KB brotli (1.2MB raw).
|
|
250
|
+
- **The budget:** `akan build <app> --update-budget` writes `apps/<app>/akan.budget.json` with the environment it built for and one `routeJsBrotliKB` entry per route. After that, a route that passes its entry by more than the larger of 1% and 4KB, as the bundle size gate allows, is an overage, named with its bytes and its limit. The slack absorbs the `AKAN_PUBLIC_*` values a build inlines, so a budget recorded for one environment holds for another, and an overage says when the file was recorded for another one. The update adds a new route, lowers an entry its route now fits under and drops a route that is gone. It never raises an entry: `--raise-budget` or a hand edit does, in the change that grows the route.
|
|
251
|
+
- **Where it fails:** only `akan build --check-budget`, on an overage, a route with no entry, an unreadable file or a missing one. A plain `akan build` warns of each but a missing file under its route table, and `--update-budget` rewrites an unreadable file. `akan verify` warns of each route over its entry or with none and of an unreadable file, one line each, but never fails on them. A mobile or desktop build skips the step.
|
|
252
|
+
- **Opt-in:** an app without the file gets the report and nothing fails, unless `--check-budget` asks for it. A route without an entry is reported, and fails only `--check-budget`. `akan sync` accepts `akan.budget.json` at an app root.
|
|
245
253
|
|
|
246
254
|
- 9055634: Partial RSC commit can now patch a real page, and it patches cleanly. It is still off by default behind `AKAN_PUBLIC_RSC_PARTIAL_COMMIT=1`. With the flag on:
|
|
247
255
|
|
|
@@ -385,6 +393,7 @@
|
|
|
385
393
|
- d2b2291: A failed workflow no longer reports success to its caller. `akan workflow plan` exits 1 when the plan has an error, `akan workflow apply` when the apply or its `--dry-run` fails, and `akan workflow validate` when its status is `failed` (which includes `passed-with-baseline-blockers`); all three exited 0 before. A dry run of a plan with an error, such as `add-field --type Number`, reports `failed` and lists no changed or generated files, where it used to list the plan's edits under "Automatically Modified". Over MCP, `plan_workflow` names `apply_workflow` in `next` and `approval.canApplyWith` only for a plan without an error, and `apply_workflow` names `run_validation` only when the apply did not fail; a refused plan also drops its "apply this plan" and "validate the apply report" recommendations. The workflows that write into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`) now refuse a service module, or any folder without its `<module>.constant.ts`, at plan time and again at apply. The `workflow-module-not-database` error lists the target's database modules and points at `create-module`, and for a field at `create-scalar`. Before, `add-field` on a service module passed its plan and dry run and failed only at apply, and `create-ui` wrote a misnamed `_<service>.View.tsx` into it. The `workflow-module-missing` error now lists only database modules too, says a name that matches a service module (with or without its `_`) is one instead of asking "Did you mean \_<service>?", and points at another app or library only when it holds a database module of that name.
|
|
386
394
|
- adcdf3b: `akan workflow plan` and the `plan_workflow` MCP tool refuse a module the target app or library lacks, for every workflow that writes into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`). The `workflow-module-missing` error names the module, lists the modules the target has, and points at the module whose name differs only in case or by a leading `_`, at a scalar of that name, or at the app or library that holds it; only when none exists does it point at `create-module`. Before, the plan passed and its dry run reported `passed`. `add-mutation` used to plan a missing module with a warning so that a `create-module` plan could precede it; it now refuses too, so plan it after applying `create-module`. Every workflow now refuses an unknown `app` at plan time, listing the workspace's apps and libraries. `apply` and `--dry-run` check the target again before anything else, so a plan written earlier for a module that is gone fails instead of passing. `plan_workflow` now inspects the MCP server's workspace rather than the process directory.
|
|
387
395
|
- Updated dependencies [bc792ab]
|
|
396
|
+
- Updated dependencies [31f7039]
|
|
388
397
|
- Updated dependencies [61fbd7d]
|
|
389
398
|
- Updated dependencies [b3f85fa]
|
|
390
399
|
- Updated dependencies [a707354]
|
|
@@ -426,9 +435,11 @@
|
|
|
426
435
|
- Updated dependencies [4561152]
|
|
427
436
|
- Updated dependencies [73e833b]
|
|
428
437
|
- Updated dependencies [b24fef3]
|
|
438
|
+
- Updated dependencies [465ccb9]
|
|
429
439
|
- Updated dependencies [513e48d]
|
|
430
440
|
- Updated dependencies [5cd1fc3]
|
|
431
441
|
- Updated dependencies [f47cc82]
|
|
442
|
+
- Updated dependencies [7f0de74]
|
|
432
443
|
- Updated dependencies [1f93507]
|
|
433
444
|
- Updated dependencies [5d3cb3e]
|
|
434
445
|
- Updated dependencies [9a9e1fa]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export type ApiPrintMode = "export" | "declare";
|
|
4
|
+
|
|
5
|
+
export class ApiDeclarationPrinter {
|
|
6
|
+
static readonly #reportedTags = ["deprecated", "internal"];
|
|
7
|
+
static readonly #printer = ts.createPrinter({ removeComments: true, newLine: ts.NewLineKind.LineFeed });
|
|
8
|
+
static readonly #headPattern =
|
|
9
|
+
"^((?:abstract |const )?(?:class|function|interface|type|enum|namespace|module|const|let|var) )";
|
|
10
|
+
|
|
11
|
+
static isNamed(
|
|
12
|
+
node: ts.Node,
|
|
13
|
+
): node is
|
|
14
|
+
| ts.ClassDeclaration
|
|
15
|
+
| ts.FunctionDeclaration
|
|
16
|
+
| ts.InterfaceDeclaration
|
|
17
|
+
| ts.TypeAliasDeclaration
|
|
18
|
+
| ts.EnumDeclaration
|
|
19
|
+
| ts.ModuleDeclaration {
|
|
20
|
+
return (
|
|
21
|
+
ts.isClassDeclaration(node) ||
|
|
22
|
+
ts.isFunctionDeclaration(node) ||
|
|
23
|
+
ts.isInterfaceDeclaration(node) ||
|
|
24
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
25
|
+
ts.isEnumDeclaration(node) ||
|
|
26
|
+
(ts.isModuleDeclaration(node) && ts.isIdentifier(node.name))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static prepared(statement: ts.Statement): ts.Statement {
|
|
31
|
+
const kept = (ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : []).filter(
|
|
32
|
+
(modifier) =>
|
|
33
|
+
modifier.kind !== ts.SyntaxKind.ExportKeyword &&
|
|
34
|
+
modifier.kind !== ts.SyntaxKind.DefaultKeyword &&
|
|
35
|
+
modifier.kind !== ts.SyntaxKind.DeclareKeyword,
|
|
36
|
+
);
|
|
37
|
+
const stripped = ts.canHaveModifiers(statement)
|
|
38
|
+
? (ts.factory.replaceModifiers(statement as ts.HasModifiers, kept) as ts.Statement)
|
|
39
|
+
: statement;
|
|
40
|
+
return ts.isClassDeclaration(stripped) ? ApiDeclarationPrinter.#withoutPrivateMembers(stripped) : stripped;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static print(
|
|
44
|
+
statement: ts.Statement,
|
|
45
|
+
sourceFile: ts.SourceFile,
|
|
46
|
+
{ localName, name, mode }: { localName: string; name: string; mode: ApiPrintMode },
|
|
47
|
+
) {
|
|
48
|
+
const tags = ApiDeclarationPrinter.#tagsOf(ts.getOriginalNode(statement));
|
|
49
|
+
const node = ApiDeclarationPrinter.#withSortedUnions(ApiDeclarationPrinter.prepared(statement), sourceFile);
|
|
50
|
+
const printed = ApiDeclarationPrinter.#printer.printNode(ts.EmitHint.Unspecified, node, sourceFile);
|
|
51
|
+
const tagLine = tags.length ? `/** ${tags.map((tag) => `@${tag}`).join(" ")} */\n` : "";
|
|
52
|
+
if (mode === "export" && name === "default") return `${tagLine}export default ${printed}`;
|
|
53
|
+
const renamed =
|
|
54
|
+
localName === name
|
|
55
|
+
? printed
|
|
56
|
+
: printed.replace(
|
|
57
|
+
new RegExp(`${ApiDeclarationPrinter.#headPattern}${ApiDeclarationPrinter.#escape(localName)}(?![\\w$])`),
|
|
58
|
+
`$1${name}`,
|
|
59
|
+
);
|
|
60
|
+
const typeOnly = ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement);
|
|
61
|
+
const keyword = mode === "export" ? (typeOnly ? "export " : "export declare ") : typeOnly ? "" : "declare ";
|
|
62
|
+
return `${tagLine}${keyword}${renamed}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static printType(node: ts.TypeNode, sourceFile: ts.SourceFile) {
|
|
66
|
+
return ApiDeclarationPrinter.#printer.printNode(ts.EmitHint.Unspecified, node, sourceFile);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
//? A union's members print in the order the checker first met them, which moves when unrelated files change.
|
|
70
|
+
static #withSortedUnions(statement: ts.Statement, sourceFile: ts.SourceFile) {
|
|
71
|
+
const result = ts.transform(statement, [
|
|
72
|
+
(context) => (root) => {
|
|
73
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
74
|
+
const visited = ts.visitEachChild(node, visit, context);
|
|
75
|
+
if (!ts.isUnionTypeNode(visited)) return visited;
|
|
76
|
+
const sorted = visited.types
|
|
77
|
+
.map((type) => ({ type, key: ApiDeclarationPrinter.printType(type, sourceFile) }))
|
|
78
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
79
|
+
.map(({ type }) => type);
|
|
80
|
+
return context.factory.updateUnionTypeNode(visited, context.factory.createNodeArray(sorted));
|
|
81
|
+
};
|
|
82
|
+
return ts.visitNode(root, visit) as ts.Statement;
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
const [transformed = statement] = result.transformed;
|
|
86
|
+
result.dispose();
|
|
87
|
+
return transformed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static #withoutPrivateMembers(declaration: ts.ClassDeclaration) {
|
|
91
|
+
const members = declaration.members.filter(
|
|
92
|
+
(member) =>
|
|
93
|
+
ts.isConstructorDeclaration(member) ||
|
|
94
|
+
!(
|
|
95
|
+
(member.name && ts.isPrivateIdentifier(member.name)) ||
|
|
96
|
+
(ts.canHaveModifiers(member) &&
|
|
97
|
+
ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword))
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
if (members.length === declaration.members.length) return declaration;
|
|
101
|
+
return ts.factory.updateClassDeclaration(
|
|
102
|
+
declaration,
|
|
103
|
+
declaration.modifiers,
|
|
104
|
+
declaration.name,
|
|
105
|
+
declaration.typeParameters,
|
|
106
|
+
declaration.heritageClauses,
|
|
107
|
+
members,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static #tagsOf(node: ts.Node) {
|
|
112
|
+
const target = ts.isVariableStatement(node) ? (node.declarationList.declarations[0] ?? node) : node;
|
|
113
|
+
const names = ts.getJSDocTags(target).map((tag) => tag.tagName.text);
|
|
114
|
+
return ApiDeclarationPrinter.#reportedTags.filter((tag) => names.includes(tag));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static #escape(value: string) {
|
|
118
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
}
|
|
120
|
+
}
|