@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.90
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 +15 -0
- package/DEV_RUNTIME_KNOBS.md +27 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +45 -21
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +86 -47
- package/biomeBase.ts +6 -5
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +129 -4
- package/executors.ts +105 -24
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +67 -0
- package/frontendBuild/watchRootResolver.ts +13 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +2 -10
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +99 -25
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +116 -0
- package/qualityScanner.ts +89 -21
- package/recipeScanner.ts +4 -1
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +103 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
package/slicePlanner.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { AppExecutor, WorkspaceExecutor } from "./executors";
|
|
2
|
+
import { AppInfo } from "./scanInfo";
|
|
3
|
+
import type { PackageJson } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface SlicePlan {
|
|
6
|
+
app: string;
|
|
7
|
+
/** Transitive lib closure, in mount order. */
|
|
8
|
+
libs: string[];
|
|
9
|
+
appFiles: string[];
|
|
10
|
+
libFiles: Record<string, string[]>;
|
|
11
|
+
rootFiles: string[];
|
|
12
|
+
/** Root manifest for a workspace holding only this slice, with the dependencies it does not use pruned. */
|
|
13
|
+
packageJson: PackageJson;
|
|
14
|
+
/** What this slice imports, so a caller serving several apps can union them before pruning. */
|
|
15
|
+
requiredDependencies: string[];
|
|
16
|
+
/** Root dependencies nothing in the slice imports — left out of `packageJson`. */
|
|
17
|
+
unusedDependencies: string[];
|
|
18
|
+
warnings: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The exact file set a single app needs to live in a workspace of its own: the app, its transitive lib
|
|
23
|
+
* closure, the workspace shell around them, and a root manifest for the result.
|
|
24
|
+
*
|
|
25
|
+
* Consumed by `akan plan-slice`, and by anything that moves an app or a lib between workspaces.
|
|
26
|
+
*/
|
|
27
|
+
export class SlicePlanner {
|
|
28
|
+
/** Root entries owned by an app, a lib or an in-tree package rather than by the workspace shell. */
|
|
29
|
+
static readonly memberDirs = ["apps", "libs", "pkgs"];
|
|
30
|
+
/** Never pruned: the toolchain a workspace runs on, which no source file imports. */
|
|
31
|
+
static readonly toolchainDependencies = [
|
|
32
|
+
"@akanjs/cli",
|
|
33
|
+
"@akanjs/devkit",
|
|
34
|
+
"@biomejs/biome",
|
|
35
|
+
"@types/bun",
|
|
36
|
+
"akanjs",
|
|
37
|
+
"tailwindcss",
|
|
38
|
+
"typescript",
|
|
39
|
+
];
|
|
40
|
+
static readonly akanPackages = ["akanjs", "@akanjs/devkit", "@akanjs/cli"];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the framework imports but does not install, so a workspace root has to declare it: `react`,
|
|
44
|
+
* `scheduler` and the RSC runtime, but also `ioredis`, `postgres` and `bullmq` the moment an app uses
|
|
45
|
+
* a database or a queue. No source file in the workspace names any of them, so no scan can find them
|
|
46
|
+
* — they are the akan packages' own `peerDependencies`, read wherever those are installed. Anything
|
|
47
|
+
* they declare as a plain dependency is installed with them and needs no root entry.
|
|
48
|
+
*/
|
|
49
|
+
static async #frameworkPeers(workspace: WorkspaceExecutor) {
|
|
50
|
+
const peers = new Set<string>();
|
|
51
|
+
let found = false;
|
|
52
|
+
for (const akanPackage of SlicePlanner.akanPackages) {
|
|
53
|
+
for (const prefix of ["node_modules", "pkgs"]) {
|
|
54
|
+
const manifestPath = `${prefix}/${akanPackage}/package.json`;
|
|
55
|
+
if (!(await workspace.exists(manifestPath))) continue;
|
|
56
|
+
const manifest = (await workspace.readJson(manifestPath)) as PackageJson;
|
|
57
|
+
for (const peer of Object.keys(manifest.peerDependencies ?? {})) peers.add(peer);
|
|
58
|
+
found = true;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return found ? peers : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** DefinitelyTyped's name for a package: `lodash` -> `@types/lodash`, `@scope/x` -> `@types/scope__x`. */
|
|
66
|
+
static typesPackageOf(dependency: string) {
|
|
67
|
+
return dependency.startsWith("@") ? `@types/${dependency.slice(1).replace("/", "__")}` : `@types/${dependency}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The root manifest a workspace holding only these dependencies would carry, with the workspace's own
|
|
72
|
+
* version specs kept — that is what makes one branch resolve to one dependency tree everywhere.
|
|
73
|
+
*
|
|
74
|
+
* A `@types/x` package is kept alongside `x`: nothing imports it by name, so no scan can see it, and
|
|
75
|
+
* dropping it breaks the typecheck of every file that imports `x`. `workspaces` is dropped because it
|
|
76
|
+
* names in-tree packages, which a slice never carries — it consumes akanjs from the registry.
|
|
77
|
+
*/
|
|
78
|
+
static async pruneDependencies(
|
|
79
|
+
workspace: WorkspaceExecutor,
|
|
80
|
+
rootPackageJson: PackageJson,
|
|
81
|
+
required: Iterable<string>,
|
|
82
|
+
) {
|
|
83
|
+
const peers = await SlicePlanner.#frameworkPeers(workspace);
|
|
84
|
+
const { workspaces: _workspaces, ...rest } = rootPackageJson;
|
|
85
|
+
//* Without akanjs's peer list every peer looks unused, and pruning it produces an install that
|
|
86
|
+
//* succeeds and a server that fails at its first redis or postgres call. Carry the dependencies
|
|
87
|
+
//* whole instead and say why.
|
|
88
|
+
if (!peers)
|
|
89
|
+
return {
|
|
90
|
+
packageJson: rest as PackageJson,
|
|
91
|
+
pruned: [],
|
|
92
|
+
warnings: [`dependencies carried whole — none of ${SlicePlanner.akanPackages.join(", ")} is installed`],
|
|
93
|
+
};
|
|
94
|
+
const keep = new Set([...SlicePlanner.toolchainDependencies, ...peers, ...required]);
|
|
95
|
+
for (const dependency of [...keep]) keep.add(SlicePlanner.typesPackageOf(dependency));
|
|
96
|
+
const pick = (entries: Record<string, string> | undefined) =>
|
|
97
|
+
Object.fromEntries(Object.entries(entries ?? {}).filter(([name]) => keep.has(name)));
|
|
98
|
+
const patched = (rootPackageJson.patchedDependencies ?? {}) as Record<string, string>;
|
|
99
|
+
const packageJson = {
|
|
100
|
+
...rest,
|
|
101
|
+
dependencies: pick(rootPackageJson.dependencies),
|
|
102
|
+
devDependencies: pick(rootPackageJson.devDependencies),
|
|
103
|
+
//? A key here is `name@version`, and bun fails the install when a patch names a package the
|
|
104
|
+
//? manifest no longer asks for.
|
|
105
|
+
...(Object.keys(patched).length
|
|
106
|
+
? {
|
|
107
|
+
patchedDependencies: Object.fromEntries(
|
|
108
|
+
Object.entries(patched).filter(([spec]) => keep.has(spec.slice(0, spec.lastIndexOf("@")))),
|
|
109
|
+
),
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
} as PackageJson;
|
|
113
|
+
const pruned = Object.keys({ ...rootPackageJson.dependencies, ...rootPackageJson.devDependencies })
|
|
114
|
+
.filter((name) => !keep.has(name))
|
|
115
|
+
.sort();
|
|
116
|
+
return { packageJson, pruned, warnings: [] as string[] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#app: AppExecutor;
|
|
120
|
+
constructor(app: AppExecutor) {
|
|
121
|
+
this.#app = app;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async #trackedFiles(paths: string[]) {
|
|
125
|
+
return await this.#app.workspace.listGitFiles(paths);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #untrackedFiles(paths: string[]) {
|
|
129
|
+
const [tracked, all] = await Promise.all([
|
|
130
|
+
this.#app.workspace.listGitFiles(paths),
|
|
131
|
+
this.#app.workspace.listGitFiles(paths, { untracked: true }),
|
|
132
|
+
]);
|
|
133
|
+
const trackedSet = new Set(tracked);
|
|
134
|
+
return all.filter((file) => !trackedSet.has(file));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#isMemberFile(file: string) {
|
|
138
|
+
return SlicePlanner.memberDirs.includes(file.split("/")[0] ?? "");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static #requiredDependencies(appInfo: AppInfo) {
|
|
142
|
+
const scanResults = [
|
|
143
|
+
appInfo.getScanResult(),
|
|
144
|
+
...[...appInfo.getLibInfos().values()].map((lib) => lib.getScanResult()),
|
|
145
|
+
];
|
|
146
|
+
return new Set([
|
|
147
|
+
...scanResults.flatMap((scanResult) => [
|
|
148
|
+
...scanResult.dependencies,
|
|
149
|
+
...scanResult.devDependencies,
|
|
150
|
+
...scanResult.pkgDeps,
|
|
151
|
+
]),
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async #patchWarnings(packageJson: PackageJson) {
|
|
156
|
+
const patchedDependencies = (packageJson.patchedDependencies ?? {}) as Record<string, string>;
|
|
157
|
+
const missing = (
|
|
158
|
+
await Promise.all(
|
|
159
|
+
Object.entries(patchedDependencies).map(async ([spec, patchPath]) =>
|
|
160
|
+
(await this.#app.workspace.exists(patchPath)) ? null : `${spec} -> ${patchPath}`,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
).filter((entry): entry is string => !!entry);
|
|
164
|
+
return missing.length ? [`patchedDependencies references a missing patch file: ${missing.join(", ")}`] : [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async plan(): Promise<SlicePlan> {
|
|
168
|
+
const appInfo = await AppInfo.fromExecutor(this.#app);
|
|
169
|
+
const libs = appInfo.getLibs();
|
|
170
|
+
const slicePaths = [`apps/${this.#app.name}`, ...libs.map((lib) => `libs/${lib}`)];
|
|
171
|
+
|
|
172
|
+
const [appFiles, allTracked, untracked, rootPackageJson] = await Promise.all([
|
|
173
|
+
this.#trackedFiles([`apps/${this.#app.name}`]),
|
|
174
|
+
this.#trackedFiles(["."]),
|
|
175
|
+
this.#untrackedFiles(slicePaths),
|
|
176
|
+
this.#app.workspace.getPackageJson(),
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const libFiles = Object.fromEntries(
|
|
180
|
+
await Promise.all(libs.map(async (lib) => [lib, await this.#trackedFiles([`libs/${lib}`])] as const)),
|
|
181
|
+
);
|
|
182
|
+
const rootFiles = allTracked.filter((file) => !this.#isMemberFile(file));
|
|
183
|
+
|
|
184
|
+
const required = SlicePlanner.#requiredDependencies(appInfo);
|
|
185
|
+
const {
|
|
186
|
+
packageJson,
|
|
187
|
+
pruned,
|
|
188
|
+
warnings: manifestWarnings,
|
|
189
|
+
} = await SlicePlanner.pruneDependencies(this.#app.workspace, rootPackageJson, required);
|
|
190
|
+
|
|
191
|
+
const warnings = [
|
|
192
|
+
...(untracked.length
|
|
193
|
+
? [`${untracked.length} untracked file(s) under the slice paths: ${untracked.join(", ")}`]
|
|
194
|
+
: []),
|
|
195
|
+
...(rootPackageJson.workspaces ? ["root `workspaces` dropped — a slice consumes akanjs from the registry"] : []),
|
|
196
|
+
...manifestWarnings,
|
|
197
|
+
...(await this.#patchWarnings(rootPackageJson)),
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
app: this.#app.name,
|
|
202
|
+
libs,
|
|
203
|
+
appFiles,
|
|
204
|
+
libFiles,
|
|
205
|
+
rootFiles,
|
|
206
|
+
packageJson,
|
|
207
|
+
requiredDependencies: [...required].sort(),
|
|
208
|
+
unusedDependencies: pruned,
|
|
209
|
+
warnings,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Top-level entry each path sits under, so a long tree (`infra/**`) reads as one line. */
|
|
215
|
+
const rootEntriesOf = (rootFiles: string[]) => [...new Set(rootFiles.map((file) => file.split("/")[0] ?? file))].sort();
|
|
216
|
+
|
|
217
|
+
export function formatSlicePlan(plan: SlicePlan) {
|
|
218
|
+
const libCounts = Object.entries(plan.libFiles).map(([lib, files]) => ` - libs/${lib}: ${files.length} files`);
|
|
219
|
+
const sections = [
|
|
220
|
+
"Akan Slice Plan",
|
|
221
|
+
`app: ${plan.app} (apps/${plan.app}: ${plan.appFiles.length} files)`,
|
|
222
|
+
`libs: ${plan.libs.length ? plan.libs.join(", ") : "(none)"}`,
|
|
223
|
+
...libCounts,
|
|
224
|
+
`workspace shell: ${plan.rootFiles.length} files across ${rootEntriesOf(plan.rootFiles).join(", ")}`,
|
|
225
|
+
"",
|
|
226
|
+
`Root dependencies this slice does not use (${plan.unusedDependencies.length}) — pruned:`,
|
|
227
|
+
"",
|
|
228
|
+
...(plan.unusedDependencies.length ? plan.unusedDependencies.map((dep) => ` - ${dep}`) : [" (none)"]),
|
|
229
|
+
"",
|
|
230
|
+
`Warnings (${plan.warnings.length}):`,
|
|
231
|
+
"",
|
|
232
|
+
...(plan.warnings.length ? plan.warnings.map((warning) => ` - ${warning}`) : [" (none)"]),
|
|
233
|
+
];
|
|
234
|
+
return sections.join("\n");
|
|
235
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
import { formatSsrBalance, SSR_SERVER_SHARE_TARGET, SsrScanner } from "./ssrScanner";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `SsrScanner` is a documented gate — `akan quality ssr` prints the server share and AGENTS.md names 50% as
|
|
8
|
+
* the floor — so both halves matter: a rule that stops firing lets the share rot, and a rule that
|
|
9
|
+
* over-reports is what makes developers stop reading the output. Every rule here gets a positive and the
|
|
10
|
+
* negative that must stay quiet.
|
|
11
|
+
*/
|
|
12
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
13
|
+
file,
|
|
14
|
+
absolutePath: `/repo/${file}`,
|
|
15
|
+
content,
|
|
16
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const rulesOf = (...files: SourceFileInfo[]) => new SsrScanner().scan(files).warnings.map((warning) => warning.rule);
|
|
20
|
+
|
|
21
|
+
const scanOne = (file: string, content: string) => rulesOf(fileOf(file, content));
|
|
22
|
+
|
|
23
|
+
const UI = "apps/demo/ui/Thing.tsx";
|
|
24
|
+
|
|
25
|
+
describe("akan.ssr.unnecessary-use-client", () => {
|
|
26
|
+
test("flags a directive with no client-only capability behind it", () => {
|
|
27
|
+
expect(scanOne(UI, `"use client";\nexport const Thing = () => <div />;\n`)).toContain(
|
|
28
|
+
"akan.ssr.unnecessary-use-client",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a hook, a handler, a store read and a browser global each justify the directive", () => {
|
|
33
|
+
const justified = [
|
|
34
|
+
`export const Thing = () => { const [open] = useState(false); return <div>{open}</div>; };`,
|
|
35
|
+
`export const Thing = () => <button onClick={save} />;`,
|
|
36
|
+
`export const Thing = () => <div>{st.use.task()}</div>;`,
|
|
37
|
+
`export const Thing = () => <div>{window.innerWidth}</div>;`,
|
|
38
|
+
];
|
|
39
|
+
for (const body of justified)
|
|
40
|
+
expect(scanOne(UI, `"use client";\n${body}\n`)).not.toContain("akan.ssr.unnecessary-use-client");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a third-party import is a reason the AST cannot rule out", () => {
|
|
44
|
+
expect(scanOne(UI, `"use client";\nimport Chart from "recharts";\nexport const Thing = () => <div />;\n`)).toEqual(
|
|
45
|
+
[],
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// `usePage`/`getSelf` read request-scoped server context, so they must not read as client evidence.
|
|
50
|
+
test("a server-safe call is not client evidence", () => {
|
|
51
|
+
expect(
|
|
52
|
+
scanOne(
|
|
53
|
+
UI,
|
|
54
|
+
`"use client";\nexport const Thing = () => { const { l } = usePage(); return <div>{l("a")}</div>; };\n`,
|
|
55
|
+
),
|
|
56
|
+
).toContain("akan.ssr.unnecessary-use-client");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("importing st or fetch justifies the directive on its own", () => {
|
|
60
|
+
expect(
|
|
61
|
+
scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Thing = () => <div />;\n`),
|
|
62
|
+
).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Zone/Template/Util and the lazy() boundary carry the directive by role, not by accident.
|
|
66
|
+
test("every convention client file is exempt", () => {
|
|
67
|
+
const body = `"use client";\nexport const Card = () => <div />;\n`;
|
|
68
|
+
const exempt = [
|
|
69
|
+
"apps/demo/lib/task/Task.Zone.tsx",
|
|
70
|
+
"apps/demo/lib/task/Task.Template.tsx",
|
|
71
|
+
"apps/demo/lib/task/Task.Util.tsx",
|
|
72
|
+
"apps/demo/ui/Chart/index_.tsx",
|
|
73
|
+
];
|
|
74
|
+
for (const file of exempt) expect(scanOne(file, body)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a Unit or View in the same module is not exempt", () => {
|
|
78
|
+
const body = `"use client";\nexport const Card = () => <div />;\n`;
|
|
79
|
+
expect(scanOne("apps/demo/lib/task/Task.Unit.tsx", body)).toContain("akan.ssr.unnecessary-use-client");
|
|
80
|
+
expect(scanOne("apps/demo/lib/task/Task.View.tsx", body)).toContain("akan.ssr.unnecessary-use-client");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a Zone outside a module folder is not exempt", () => {
|
|
84
|
+
expect(scanOne("apps/demo/ui/Task.Zone.tsx", `"use client";\nexport const Zone = () => <div />;\n`)).toContain(
|
|
85
|
+
"akan.ssr.unnecessary-use-client",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the directive has to be the first statement to count as one", () => {
|
|
90
|
+
expect(
|
|
91
|
+
scanOne(UI, `import { cn } from "akanjs/client";\n"use client";\nexport const Thing = () => <div />;\n`),
|
|
92
|
+
).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("akan.ssr.client-static-component", () => {
|
|
97
|
+
const four = "<div><span /><span /><span /></div>";
|
|
98
|
+
|
|
99
|
+
test("flags a static component once its subtree is worth moving", () => {
|
|
100
|
+
expect(
|
|
101
|
+
scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Card = () => ${four};\n`),
|
|
102
|
+
).toContain("akan.ssr.client-static-component");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("stays quiet below the mass floor", () => {
|
|
106
|
+
expect(
|
|
107
|
+
scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const Card = () => <div><span /></div>;\n`),
|
|
108
|
+
).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a component with any client touch is not static", () => {
|
|
112
|
+
expect(
|
|
113
|
+
scanOne(UI, `"use client";\nexport const Card = () => <div onClick={save}><span /><span /><span /></div>;\n`),
|
|
114
|
+
).not.toContain("akan.ssr.client-static-component");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// A vendor component's own tags may be client-only, so its subtree is not server-renderable markup.
|
|
118
|
+
test("a subtree rendering a vendor tag is exempt", () => {
|
|
119
|
+
expect(
|
|
120
|
+
scanOne(
|
|
121
|
+
UI,
|
|
122
|
+
`"use client";\nimport Chart from "recharts";\nexport const Card = () => <div><Chart /><span /><span /></div>;\n`,
|
|
123
|
+
),
|
|
124
|
+
).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a lowercase binding is not a component", () => {
|
|
128
|
+
expect(scanOne(UI, `"use client";\nimport { st } from "../lib/st";\nexport const card = () => ${four};\n`)).toEqual(
|
|
129
|
+
[],
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("akan.ssr.client-static-markup", () => {
|
|
135
|
+
const ten = `<div><p /><p /><p /><p /><p /><p /><p /><p /><button onClick={save} /></div>`;
|
|
136
|
+
|
|
137
|
+
test("flags a large subtree wrapping one interactive touch", () => {
|
|
138
|
+
expect(scanOne(UI, `"use client";\nexport const Panel = () => ${ten};\n`)).toContain(
|
|
139
|
+
"akan.ssr.client-static-markup",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("stays quiet when the subtree is small enough that splitting buys nothing", () => {
|
|
144
|
+
expect(scanOne(UI, `"use client";\nexport const Panel = () => <div><button onClick={save} /></div>;\n`)).toEqual(
|
|
145
|
+
[],
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("stays quiet once the component is interactive throughout", () => {
|
|
150
|
+
const busy = `<div><button onClick={a} /><button onClick={b} /><button onClick={c} /><p /><p /><p /><p /><p /><p /></div>`;
|
|
151
|
+
expect(scanOne(UI, `"use client";\nexport const Panel = () => ${busy};\n`)).not.toContain(
|
|
152
|
+
"akan.ssr.client-static-markup",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("akan.ssr.client-mount-load", () => {
|
|
158
|
+
const mount = (body: string) =>
|
|
159
|
+
`"use client";\nexport const Thing = () => { useEffect(() => { ${body} }, []); return <div />; };\n`;
|
|
160
|
+
|
|
161
|
+
test("flags a fetch and a generated store load fired from a mount-only effect", () => {
|
|
162
|
+
expect(scanOne(UI, mount("void fetch.initTaskInOrg(orgId);"))).toContain("akan.ssr.client-mount-load");
|
|
163
|
+
expect(scanOne(UI, mount("void st.do.initTaskInOrg();"))).toContain("akan.ssr.client-mount-load");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("useLayoutEffect is the same finding", () => {
|
|
167
|
+
const body = `"use client";\nexport const Thing = () => { useLayoutEffect(() => { void fetch.viewTask(id); }, []); return <div />; };\n`;
|
|
168
|
+
expect(scanOne(UI, body)).toContain("akan.ssr.client-mount-load");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// A reactive effect responds to client state and has no server-side equivalent.
|
|
172
|
+
test("a non-empty dependency array is not a mount load", () => {
|
|
173
|
+
const body = `"use client";\nexport const Thing = () => { useEffect(() => { void fetch.viewTask(id); }, [id]); return <div />; };\n`;
|
|
174
|
+
expect(scanOne(UI, body)).not.toContain("akan.ssr.client-mount-load");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a mount effect that loads nothing is not a finding", () => {
|
|
178
|
+
expect(scanOne(UI, mount("subscribe();"))).not.toContain("akan.ssr.client-mount-load");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a store action that is not a load is not a finding", () => {
|
|
182
|
+
expect(scanOne(UI, mount("void st.do.openModal();"))).not.toContain("akan.ssr.client-mount-load");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("akan.ssr.template-client-state", () => {
|
|
187
|
+
const template = "apps/demo/lib/task/Task.Template.tsx";
|
|
188
|
+
|
|
189
|
+
test("flags useState in a Template", () => {
|
|
190
|
+
const body = `"use client";\nexport const General = () => { const [draft, setDraft] = useState(""); return <input value={draft} />; };\n`;
|
|
191
|
+
expect(scanOne(template, body)).toContain("akan.ssr.template-client-state");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("the same useState in a Zone is not a finding", () => {
|
|
195
|
+
const body = `"use client";\nexport const Card = () => { const [open, setOpen] = useState(false); return <div>{open}</div>; };\n`;
|
|
196
|
+
expect(scanOne("apps/demo/lib/task/Task.Zone.tsx", body)).not.toContain("akan.ssr.template-client-state");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a store-driven Template is clean", () => {
|
|
200
|
+
const body = `"use client";\nexport const General = () => <input value={st.use.taskForm().name} onChange={st.do.setNameOnTask} />;\n`;
|
|
201
|
+
expect(scanOne(template, body)).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("akan.ssr.module-missing-server-view", () => {
|
|
206
|
+
const clientMass = `<div><p /><p /><p /><p /><p /><p /><p /><p /><p /><p /><p /></div>`;
|
|
207
|
+
|
|
208
|
+
test("flags a module that renders only from client files", () => {
|
|
209
|
+
const zone = fileOf(
|
|
210
|
+
"apps/demo/lib/task/Task.Zone.tsx",
|
|
211
|
+
`"use client";\nexport const Card = () => ${clientMass};\n`,
|
|
212
|
+
);
|
|
213
|
+
expect(rulesOf(zone)).toContain("akan.ssr.module-missing-server-view");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("one Unit or View in the module answers it", () => {
|
|
217
|
+
const zone = fileOf(
|
|
218
|
+
"apps/demo/lib/task/Task.Zone.tsx",
|
|
219
|
+
`"use client";\nexport const Card = () => ${clientMass};\n`,
|
|
220
|
+
);
|
|
221
|
+
const unit = fileOf("apps/demo/lib/task/Task.Unit.tsx", `export const Card = () => <div />;\n`);
|
|
222
|
+
expect(rulesOf(zone, unit)).not.toContain("akan.ssr.module-missing-server-view");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a module too small to matter is not a finding", () => {
|
|
226
|
+
const zone = fileOf(
|
|
227
|
+
"apps/demo/lib/task/Task.Zone.tsx",
|
|
228
|
+
`"use client";\nexport const Card = () => <div><p /></div>;\n`,
|
|
229
|
+
);
|
|
230
|
+
expect(rulesOf(zone)).not.toContain("akan.ssr.module-missing-server-view");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// `lib/_<service>` folders are service modules, which own no model to render server-side.
|
|
234
|
+
test("a service module is not held to it", () => {
|
|
235
|
+
const zone = fileOf(
|
|
236
|
+
"apps/demo/lib/_upload/Upload.Zone.tsx",
|
|
237
|
+
`"use client";\nexport const Card = () => ${clientMass};\n`,
|
|
238
|
+
);
|
|
239
|
+
expect(rulesOf(zone)).not.toContain("akan.ssr.module-missing-server-view");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("scan scope", () => {
|
|
244
|
+
test("only apps/libs ui and lib .tsx files are measured", () => {
|
|
245
|
+
const outOfScope = [
|
|
246
|
+
"apps/demo/page/task/_index.tsx",
|
|
247
|
+
"apps/demo/webkit/useThing.tsx",
|
|
248
|
+
"apps/demo/ui/Thing.test.tsx",
|
|
249
|
+
"pkgs/akanjs/ui/Thing.tsx",
|
|
250
|
+
"apps/demo/ui/thing.ts",
|
|
251
|
+
];
|
|
252
|
+
for (const file of outOfScope)
|
|
253
|
+
expect(new SsrScanner().scan([fileOf(file, `"use client";\nexport const Thing = () => <div />;\n`)])).toEqual({
|
|
254
|
+
warnings: [],
|
|
255
|
+
balance: [],
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("balance", () => {
|
|
261
|
+
test("measures the share in JSX elements and not in files", () => {
|
|
262
|
+
const server = fileOf("apps/demo/ui/Server.tsx", `export const A = () => <div><p /><p /></div>;\n`);
|
|
263
|
+
const client = fileOf("apps/demo/ui/Client.tsx", `"use client";\nexport const B = () => <div onClick={a} />;\n`);
|
|
264
|
+
const [entry] = new SsrScanner().scan([server, client]).balance;
|
|
265
|
+
expect(entry).toEqual({ scope: "apps/demo", serverMass: 3, clientMass: 1, serverShare: 0.75 });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("adds a workspace row only once a second scope exists", () => {
|
|
269
|
+
const demo = fileOf("apps/demo/ui/A.tsx", `export const A = () => <div />;\n`);
|
|
270
|
+
const shared = fileOf("libs/shared/ui/B.tsx", `"use client";\nexport const B = () => <div onClick={a} />;\n`);
|
|
271
|
+
expect(new SsrScanner().scan([demo]).balance.map((entry) => entry.scope)).toEqual(["apps/demo"]);
|
|
272
|
+
expect(new SsrScanner().scan([demo, shared]).balance.map((entry) => entry.scope)).toEqual([
|
|
273
|
+
"apps/demo",
|
|
274
|
+
"libs/shared",
|
|
275
|
+
"workspace",
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// An empty scope reads as fully server-rendered rather than as a division by zero.
|
|
280
|
+
test("a scope with no JSX at all is 100% server", () => {
|
|
281
|
+
const empty = fileOf("apps/demo/ui/Empty.tsx", `export const value = 1;\n`);
|
|
282
|
+
expect(new SsrScanner().scan([empty]).balance[0].serverShare).toBe(1);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("formatSsrBalance", () => {
|
|
287
|
+
test("flags a scope under the target and leaves one above it unmarked", () => {
|
|
288
|
+
const lines = formatSsrBalance([
|
|
289
|
+
{ scope: "apps/low", serverMass: 1, clientMass: 9, serverShare: 0.1 },
|
|
290
|
+
{ scope: "apps/high", serverMass: 9, clientMass: 1, serverShare: 0.9 },
|
|
291
|
+
]);
|
|
292
|
+
expect(lines[0]).toContain("10% server");
|
|
293
|
+
expect(lines[0]).toContain(`below the ${Math.round(SSR_SERVER_SHARE_TARGET * 100)}% target`);
|
|
294
|
+
expect(lines[1]).toContain("90% server");
|
|
295
|
+
expect(lines[1]).not.toContain("below the");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("says so rather than printing an empty table", () => {
|
|
299
|
+
expect(formatSsrBalance([])).toEqual(["No component files found."]);
|
|
300
|
+
});
|
|
301
|
+
});
|