@akanjs/devkit 3.0.0-alpha.1 → 3.0.0-alpha.100
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 +34 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +47 -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 +70 -42
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +37 -2
- package/applicationBuildRunner.ts +53 -24
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +340 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.test.ts +8 -0
- package/capacitorApp.ts +122 -18
- 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 +137 -5
- package/executors.ts +116 -26
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- 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/cssCandidateCache.test.ts +38 -0
- package/frontendBuild/cssCandidateCache.ts +15 -3
- 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/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +26 -7
- 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 +129 -0
- package/frontendBuild/watchRootResolver.ts +66 -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 +33 -0
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -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-return-in-store-action.grit +35 -0
- 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 +6 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +130 -0
- package/qualityScanner.ts +91 -62
- package/recipeScanner.ts +4 -1
- package/repoIdentity.ts +42 -0
- package/routeSourceValidator.test.ts +41 -0
- package/routeSourceValidator.ts +2 -44
- package/scanInfo.ts +32 -67
- 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 +78 -0
- package/workspaceLayout.ts +106 -0
- 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/scanInfo.ts
CHANGED
|
@@ -9,8 +9,9 @@ import type {
|
|
|
9
9
|
PkgScanResult,
|
|
10
10
|
ScanResult,
|
|
11
11
|
} from "./akanConfig";
|
|
12
|
-
|
|
12
|
+
import { AkanAppConfig } from "./akanConfig";
|
|
13
13
|
import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
|
|
14
|
+
import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles } from "./workspaceLayout";
|
|
14
15
|
|
|
15
16
|
const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
|
|
16
17
|
type ScalarFileType = (typeof scalarFileTypes)[number];
|
|
@@ -43,49 +44,7 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
|
|
|
43
44
|
|
|
44
45
|
type ModuleKind = "database" | "service" | "scalar";
|
|
45
46
|
|
|
46
|
-
const appRootAllowedFiles = new Set([
|
|
47
|
-
// 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
|
|
48
|
-
"AGENTS.md",
|
|
49
|
-
"CLAUDE.md",
|
|
50
|
-
"akan.app.json",
|
|
51
|
-
"akan.config.ts",
|
|
52
|
-
"capacitor.config.ts",
|
|
53
|
-
"client.ts",
|
|
54
|
-
"main.ts",
|
|
55
|
-
"package.json",
|
|
56
|
-
"server.ts",
|
|
57
|
-
"tsconfig.json",
|
|
58
|
-
"tsconfig.tsbuildinfo",
|
|
59
|
-
]);
|
|
60
47
|
const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
|
|
61
|
-
const appRootAllowedDirs = new Set([
|
|
62
|
-
".akan",
|
|
63
|
-
"android",
|
|
64
|
-
"env",
|
|
65
|
-
"ios",
|
|
66
|
-
"lib",
|
|
67
|
-
"mobile",
|
|
68
|
-
"page",
|
|
69
|
-
"private",
|
|
70
|
-
"public",
|
|
71
|
-
"script",
|
|
72
|
-
"ui",
|
|
73
|
-
"srvkit",
|
|
74
|
-
"webkit",
|
|
75
|
-
"common",
|
|
76
|
-
"secrets",
|
|
77
|
-
]);
|
|
78
|
-
const libRootAllowedFiles = new Set([
|
|
79
|
-
"cnst.ts",
|
|
80
|
-
"db.ts",
|
|
81
|
-
"dict.ts",
|
|
82
|
-
"option.ts",
|
|
83
|
-
"sig.ts",
|
|
84
|
-
"srv.ts",
|
|
85
|
-
"st.ts",
|
|
86
|
-
"useClient.ts",
|
|
87
|
-
"useServer.ts",
|
|
88
|
-
]);
|
|
89
48
|
const internalLibDirs = new Set(["__lib", "__scalar"]);
|
|
90
49
|
const moduleNonUiFileTypes = {
|
|
91
50
|
database: new Set(["constant", "dictionary", "document", "service", "signal", "store"]),
|
|
@@ -98,7 +57,6 @@ const moduleUiFileTypes = {
|
|
|
98
57
|
scalar: new Set(["Template", "Unit"]),
|
|
99
58
|
} satisfies Record<ModuleKind, Set<string>>;
|
|
100
59
|
const testFilePattern = /\.(test|spec)\.(ts|tsx)$/;
|
|
101
|
-
const rootSignalTestFilePattern = /^[A-Za-z][A-Za-z0-9_-]*\.signal\.(test|spec)\.(ts|tsx)$/;
|
|
102
60
|
|
|
103
61
|
// The dependency scanner needs `typescript` (~65MB resident). Only the scan/sync commands run it, so
|
|
104
62
|
// load it on demand rather than through the module graph of every process that imports scan info.
|
|
@@ -106,8 +64,6 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
|
|
|
106
64
|
(await import("./dependencyScanner")).TypeScriptDependencyScanner.from(exec);
|
|
107
65
|
|
|
108
66
|
const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
|
|
109
|
-
const isAllowedLibRootFile = (filename: string) =>
|
|
110
|
-
libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
|
|
111
67
|
const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
|
|
112
68
|
path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
|
|
113
69
|
async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
|
|
@@ -126,24 +82,29 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
|
|
|
126
82
|
violations.push(`${getScanPath(exec, relativePath)}: ${reason}`);
|
|
127
83
|
};
|
|
128
84
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
});
|
|
141
|
-
|
|
85
|
+
const allowedRootFiles: ReadonlySet<string> = rootAllowedFiles[exec.type];
|
|
86
|
+
const allowedRootDirs: ReadonlySet<string> = rootAllowedDirs[exec.type];
|
|
87
|
+
const { files, dirs } = await exec.getFilesAndDirs(".");
|
|
88
|
+
files
|
|
89
|
+
.filter((filename) => !allowedRootFiles.has(filename))
|
|
90
|
+
.forEach((filename) => {
|
|
91
|
+
addViolation(filename, `unsupported ${exec.type} root file`);
|
|
92
|
+
});
|
|
93
|
+
dirs
|
|
94
|
+
.filter((dirname) => !allowedRootDirs.has(dirname))
|
|
95
|
+
.forEach((dirname) => {
|
|
96
|
+
addViolation(dirname, `unsupported ${exec.type} root folder`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
//* An app validates its page tree in `getPageKeys`, which a lib has no equivalent of — so a lib's own
|
|
100
|
+
//* routes went unchecked until whichever app opted into them with `syncPageLibs` blew up on the sync.
|
|
101
|
+
if (exec.type === "lib")
|
|
102
|
+
for (const { relativePath, reason } of await exec.getPageConventionViolations()) addViolation(relativePath, reason);
|
|
142
103
|
|
|
143
104
|
libRoot.files
|
|
144
|
-
.filter((filename) => !
|
|
105
|
+
.filter((filename) => !isAllowedLibFacetRootFile(filename))
|
|
145
106
|
.forEach((filename) => {
|
|
146
|
-
addViolation(path.join("lib", filename), "unsupported lib root file");
|
|
107
|
+
addViolation(path.join("lib", filename), "unsupported lib facet root file");
|
|
147
108
|
});
|
|
148
109
|
|
|
149
110
|
libRoot.dirs
|
|
@@ -325,12 +286,11 @@ class ScanInfo {
|
|
|
325
286
|
}),
|
|
326
287
|
]);
|
|
327
288
|
const routes = exec.type === "lib" ? [] : await (exec as AppExecutor).getPageKeys();
|
|
328
|
-
const
|
|
289
|
+
const common = {
|
|
329
290
|
name: exec.name,
|
|
330
291
|
type: exec.type,
|
|
331
292
|
repoName: exec.workspace.repoName,
|
|
332
293
|
serveDomain: WorkspaceExecutor.getBaseDevEnv(path.join(exec.workspace.workspaceRoot, ".env")).serveDomain,
|
|
333
|
-
akanConfig,
|
|
334
294
|
files,
|
|
335
295
|
libDeps,
|
|
336
296
|
pkgDeps,
|
|
@@ -338,6 +298,11 @@ class ScanInfo {
|
|
|
338
298
|
devDependencies: npmDevDeps.filter((dep) => !isAkanFrameworkDependency(dep)),
|
|
339
299
|
routes,
|
|
340
300
|
};
|
|
301
|
+
//? `exec.type` and the resolved config class are correlated, which the union alone cannot express.
|
|
302
|
+
const scanResult: AppScanResult | LibScanResult =
|
|
303
|
+
akanConfig instanceof AkanAppConfig
|
|
304
|
+
? ({ ...common, akanConfig } satisfies AppScanResult)
|
|
305
|
+
: ({ ...common, akanConfig } satisfies LibScanResult);
|
|
341
306
|
return scanResult;
|
|
342
307
|
}
|
|
343
308
|
|
|
@@ -502,7 +467,7 @@ export class LibInfo extends ScanInfo {
|
|
|
502
467
|
LibInfo.libInfos.set(libName, await LibInfo.fromExecutor(libExecutor));
|
|
503
468
|
}),
|
|
504
469
|
);
|
|
505
|
-
const libInfo = new LibInfo(exec, scanResult);
|
|
470
|
+
const libInfo = new LibInfo(exec, scanResult as LibScanResult);
|
|
506
471
|
LibInfo.libInfos.set(exec.name, libInfo);
|
|
507
472
|
LibInfo.#sortedLibIndices = null;
|
|
508
473
|
return libInfo;
|
|
@@ -553,7 +518,7 @@ export class LibInfo extends ScanInfo {
|
|
|
553
518
|
export class PkgInfo {
|
|
554
519
|
readonly exec: PkgExecutor;
|
|
555
520
|
readonly name: string;
|
|
556
|
-
|
|
521
|
+
#scanResult: PkgScanResult;
|
|
557
522
|
|
|
558
523
|
static async scanExecutor(exec: PkgExecutor) {
|
|
559
524
|
const [tsconfig, rootPackageJson] = await Promise.all([exec.getTsConfig(), exec.workspace.getPackageJson()]);
|
|
@@ -595,10 +560,10 @@ export class PkgInfo {
|
|
|
595
560
|
constructor(exec: PkgExecutor, scanResult: PkgScanResult) {
|
|
596
561
|
this.exec = exec;
|
|
597
562
|
this.name = exec.name;
|
|
598
|
-
this
|
|
563
|
+
this.#scanResult = scanResult;
|
|
599
564
|
}
|
|
600
565
|
getScanResult() {
|
|
601
|
-
return this
|
|
566
|
+
return this.#scanResult;
|
|
602
567
|
}
|
|
603
568
|
}
|
|
604
569
|
|
package/semver.test.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { compareSemver } from "./semver";
|
|
3
|
+
|
|
4
|
+
describe("compareSemver", () => {
|
|
5
|
+
test("orders plain versions", () => {
|
|
6
|
+
expect(compareSemver("1.4.0", "1.3.13")).toBe(1);
|
|
7
|
+
expect(compareSemver("1.3.13", "1.4.0")).toBe(-1);
|
|
8
|
+
expect(compareSemver("19.2.7", "19.2.7")).toBe(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("compares the version behind a range operator, not the range", () => {
|
|
12
|
+
expect(compareSemver("^3.1049.0", "^3.721.0")).toBe(1);
|
|
13
|
+
expect(compareSemver("~1.2.0", "^1.10.0")).toBe(-1);
|
|
14
|
+
expect(compareSemver(">=1.3.13", "1.4.0")).toBe(-1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("sorts a prerelease below its release", () => {
|
|
18
|
+
expect(compareSemver("1.0.0-alpha", "1.0.0")).toBe(-1);
|
|
19
|
+
expect(compareSemver("0.0.0-experimental-603e6108-20241029", "0.0.0")).toBe(-1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("falls back to numeric comparison for specs that are not versions", () => {
|
|
23
|
+
expect(compareSemver("workspace:*", "0.0.0")).toBe(0);
|
|
24
|
+
expect(compareSemver("workspace:*", "1.0.0")).toBe(-1);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/semver.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `Bun.semver.order` compares range strings loosely — `^3.1049.0` vs `^3.721.0` answers 0 — so the
|
|
2
|
+
// operator prefix has to come off before it sees the version.
|
|
3
|
+
const stripRangeOperator = (version: string) => version.replace(/^[\s^~>=<v]+/, "").trim();
|
|
4
|
+
|
|
5
|
+
const parseVersion = (version: string): number[] => {
|
|
6
|
+
return version
|
|
7
|
+
.replace(/^[^\d]*/, "")
|
|
8
|
+
.split(/[.-]/)
|
|
9
|
+
.map((part) => Number.parseInt(part, 10))
|
|
10
|
+
.map((part) => (Number.isFinite(part) ? part : 0));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const compareNumeric = (a: string, b: string): number => {
|
|
14
|
+
const left = parseVersion(a);
|
|
15
|
+
const right = parseVersion(b);
|
|
16
|
+
const length = Math.max(left.length, right.length);
|
|
17
|
+
for (let i = 0; i < length; i++) {
|
|
18
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
19
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const compareSemver = (a: string, b: string): number => {
|
|
25
|
+
try {
|
|
26
|
+
return Bun.semver.order(stripRangeOperator(a), stripRangeOperator(b));
|
|
27
|
+
} catch {
|
|
28
|
+
// Not every dependency spec is a version — `workspace:*`, a git URL, a tarball path.
|
|
29
|
+
return compareNumeric(a, b);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AppExecutor, Executor, WorkspaceExecutor } from "./executors";
|
|
6
|
+
import { formatSlicePlan, SlicePlanner } from "./slicePlanner";
|
|
7
|
+
import type { PackageJson } from "./types";
|
|
8
|
+
|
|
9
|
+
const tempRoots: string[] = [];
|
|
10
|
+
const originalEnv = { ...process.env };
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
process.env = { ...originalEnv };
|
|
14
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "workspace";
|
|
15
|
+
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
16
|
+
process.env.AKAN_PUBLIC_ENV = "local";
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
process.env = { ...originalEnv };
|
|
21
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const write = async (filePath: string, content: string) => {
|
|
25
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
26
|
+
await writeFile(filePath, content);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const gitignore = [
|
|
30
|
+
"node_modules",
|
|
31
|
+
"apps/*/lib/cnst.ts",
|
|
32
|
+
"libs/*/common/index.ts",
|
|
33
|
+
"**/env.server.local.ts",
|
|
34
|
+
"**/akan.app.json",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
// `AppExecutor.from` and `AppInfo.fromExecutor` both memoise by name, so each fixture needs a fresh one.
|
|
38
|
+
const makeWorkspace = async (appName: string, libName: string) => {
|
|
39
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-slice-"));
|
|
40
|
+
tempRoots.push(root);
|
|
41
|
+
const rootPackageJson: PackageJson = {
|
|
42
|
+
name: "workspace",
|
|
43
|
+
version: "0.0.1",
|
|
44
|
+
description: "workspace",
|
|
45
|
+
workspaces: ["pkgs/*"],
|
|
46
|
+
dependencies: { lodash: "4.0.0", "unused-dep": "1.0.0", ioredis: "5.0.0" },
|
|
47
|
+
devDependencies: { typescript: "6.0.0", "@types/lodash": "4.1.0", "@types/qrcode": "1.0.0" },
|
|
48
|
+
patchedDependencies: {
|
|
49
|
+
"lodash@4.0.0": "patches/lodash@4.0.0.patch",
|
|
50
|
+
"unused-dep@1.0.0": "patches/unused-dep@1.0.0.patch",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
await write(path.join(root, "package.json"), `${JSON.stringify(rootPackageJson, null, 2)}\n`);
|
|
54
|
+
await write(path.join(root, ".gitignore"), `${gitignore}\n`);
|
|
55
|
+
await write(path.join(root, "biome.json"), "{}\n");
|
|
56
|
+
//? The framework's peers are declared by akanjs, not by any import, so the planner reads them here.
|
|
57
|
+
await write(
|
|
58
|
+
path.join(root, "node_modules/akanjs/package.json"),
|
|
59
|
+
'{ "name": "akanjs", "version": "3.0.0", "peerDependencies": { "react": "19.0.0", "ioredis": "5.0.0" } }\n',
|
|
60
|
+
);
|
|
61
|
+
await write(path.join(root, "patches/lodash@4.0.0.patch"), "");
|
|
62
|
+
await write(path.join(root, "patches/unused-dep@1.0.0.patch"), "");
|
|
63
|
+
|
|
64
|
+
await write(path.join(root, `apps/${appName}/akan.config.ts`), "export default {};\n");
|
|
65
|
+
await write(path.join(root, `apps/${appName}/tsconfig.json`), "{}\n");
|
|
66
|
+
await write(
|
|
67
|
+
path.join(root, `apps/${appName}/lib/task/task.constant.ts`),
|
|
68
|
+
[
|
|
69
|
+
`import { helper } from "@libs/${libName}/common";`,
|
|
70
|
+
'import lodash from "lodash";',
|
|
71
|
+
"",
|
|
72
|
+
"export { helper, lodash };",
|
|
73
|
+
"",
|
|
74
|
+
].join("\n"),
|
|
75
|
+
);
|
|
76
|
+
await write(path.join(root, `apps/${appName}/lib/cnst.ts`), "export {};\n");
|
|
77
|
+
await write(path.join(root, `apps/${appName}/env/env.server.local.ts`), "export const env = {};\n");
|
|
78
|
+
|
|
79
|
+
await write(path.join(root, `libs/${libName}/akan.config.ts`), "export default {};\n");
|
|
80
|
+
await write(path.join(root, `libs/${libName}/tsconfig.json`), "{}\n");
|
|
81
|
+
await mkdir(path.join(root, `libs/${libName}/lib`), { recursive: true });
|
|
82
|
+
await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
|
|
83
|
+
await write(path.join(root, `libs/${libName}/common/index.ts`), 'export * from "./helper";\n');
|
|
84
|
+
|
|
85
|
+
await write(path.join(root, "pkgs/in-tree/package.json"), '{ "name": "in-tree" }\n');
|
|
86
|
+
|
|
87
|
+
const git = new Executor("fixture", root);
|
|
88
|
+
await git.spawn("git", ["init", "--quiet"]);
|
|
89
|
+
await git.spawn("git", ["add", "-A"]);
|
|
90
|
+
await git.spawn("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "fixture"]);
|
|
91
|
+
|
|
92
|
+
const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `workspace-${appName}` });
|
|
93
|
+
return { root, plan: async () => await new SlicePlanner(AppExecutor.from(workspace, appName)).plan() };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
describe("SlicePlanner", () => {
|
|
97
|
+
test("resolves the lib closure and lists only git-tracked slice files", async () => {
|
|
98
|
+
const { plan } = await makeWorkspace("slice-app", "slice-lib");
|
|
99
|
+
const result = await plan();
|
|
100
|
+
|
|
101
|
+
expect(result.app).toBe("slice-app");
|
|
102
|
+
expect(result.libs).toEqual(["slice-lib"]);
|
|
103
|
+
expect(result.appFiles).toContain("apps/slice-app/lib/task/task.constant.ts");
|
|
104
|
+
expect(result.libFiles["slice-lib"]).toContain("libs/slice-lib/common/helper.ts");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("leaves out generated barrels, env values and every other workspace member", async () => {
|
|
108
|
+
const { plan } = await makeWorkspace("ignore-app", "ignore-lib");
|
|
109
|
+
const result = await plan();
|
|
110
|
+
|
|
111
|
+
expect(result.appFiles).not.toContain("apps/ignore-app/lib/cnst.ts");
|
|
112
|
+
expect(result.appFiles).not.toContain("apps/ignore-app/env/env.server.local.ts");
|
|
113
|
+
expect(result.libFiles["ignore-lib"]).not.toContain("libs/ignore-lib/common/index.ts");
|
|
114
|
+
expect(result.rootFiles).toEqual(expect.arrayContaining(["package.json", ".gitignore", "biome.json"]));
|
|
115
|
+
expect(result.rootFiles.filter((file) => file.startsWith("pkgs/"))).toEqual([]);
|
|
116
|
+
expect(result.rootFiles.filter((file) => /^(apps|libs)\//.test(file))).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("drops `workspaces` from the slice manifest and reports it", async () => {
|
|
120
|
+
const { plan } = await makeWorkspace("manifest-app", "manifest-lib");
|
|
121
|
+
const result = await plan();
|
|
122
|
+
|
|
123
|
+
expect(result.packageJson.workspaces).toBeUndefined();
|
|
124
|
+
expect(result.warnings.join("\n")).toContain("workspaces");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("prunes what the slice does not import, keeping the toolchain and the type packages", async () => {
|
|
128
|
+
const { plan } = await makeWorkspace("deps-app", "deps-lib");
|
|
129
|
+
const result = await plan();
|
|
130
|
+
|
|
131
|
+
expect(result.requiredDependencies).toContain("lodash");
|
|
132
|
+
//? `ioredis` is an akanjs peer: nothing imports it, and pruning it would break the server at runtime.
|
|
133
|
+
expect(result.packageJson.dependencies).toEqual({ lodash: "4.0.0", ioredis: "5.0.0" });
|
|
134
|
+
//? Nothing imports `@types/lodash` by name, so only its companion keeps it.
|
|
135
|
+
expect(result.packageJson.devDependencies).toEqual({ typescript: "6.0.0", "@types/lodash": "4.1.0" });
|
|
136
|
+
expect(result.packageJson.patchedDependencies).toEqual({ "lodash@4.0.0": "patches/lodash@4.0.0.patch" });
|
|
137
|
+
expect(result.unusedDependencies).toEqual(["@types/qrcode", "unused-dep"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("carries the dependencies whole when there is no akanjs manifest to read the peers from", async () => {
|
|
141
|
+
const { root, plan } = await makeWorkspace("nopeer-app", "nopeer-lib");
|
|
142
|
+
await rm(path.join(root, "node_modules"), { recursive: true, force: true });
|
|
143
|
+
const result = await plan();
|
|
144
|
+
|
|
145
|
+
expect(result.unusedDependencies).toEqual([]);
|
|
146
|
+
expect(result.packageJson.dependencies).toEqual({ lodash: "4.0.0", "unused-dep": "1.0.0", ioredis: "5.0.0" });
|
|
147
|
+
expect(result.warnings.join("\n")).toContain("carried whole");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("names a type package the way DefinitelyTyped does", () => {
|
|
151
|
+
expect(SlicePlanner.typesPackageOf("lodash")).toBe("@types/lodash");
|
|
152
|
+
expect(SlicePlanner.typesPackageOf("@capacitor/core")).toBe("@types/capacitor__core");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("warns about untracked files under the slice paths", async () => {
|
|
156
|
+
const { root, plan } = await makeWorkspace("untracked-app", "untracked-lib");
|
|
157
|
+
await write(path.join(root, "apps/untracked-app/lib/task/task.service.ts"), "export {};\n");
|
|
158
|
+
const result = await plan();
|
|
159
|
+
|
|
160
|
+
expect(result.warnings.join("\n")).toContain("apps/untracked-app/lib/task/task.service.ts");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("formatSlicePlan", () => {
|
|
165
|
+
test("collapses the workspace shell to its top-level entries", () => {
|
|
166
|
+
const text = formatSlicePlan({
|
|
167
|
+
app: "demo",
|
|
168
|
+
libs: ["util"],
|
|
169
|
+
appFiles: ["apps/demo/main.ts"],
|
|
170
|
+
libFiles: { util: ["libs/util/index.ts"] },
|
|
171
|
+
rootFiles: ["package.json", "infra/app/values/main.yaml", "infra/app/templates/app.yaml"],
|
|
172
|
+
packageJson: { name: "demo", version: "0.0.1", description: "demo" },
|
|
173
|
+
requiredDependencies: [],
|
|
174
|
+
unusedDependencies: [],
|
|
175
|
+
warnings: [],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(text).toContain("app: demo (apps/demo: 1 files)");
|
|
179
|
+
expect(text).toContain("3 files across infra, package.json");
|
|
180
|
+
expect(text).toContain("(none)");
|
|
181
|
+
});
|
|
182
|
+
});
|
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
|
+
}
|