@akanjs/devkit 3.0.0-alpha.75 → 3.0.0-alpha.77

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.
@@ -0,0 +1,157 @@
1
+ import type { AppExecutor } 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. */
13
+ packageJson: PackageJson;
14
+ /** Root dependencies nothing in the slice imports — prune candidates for a human, never pruned here. */
15
+ unusedDependencies: string[];
16
+ warnings: string[];
17
+ }
18
+
19
+ /**
20
+ * The exact file set a single app needs to live in a workspace of its own: the app, its transitive lib
21
+ * closure, the workspace shell around them, and a root manifest for the result.
22
+ *
23
+ * Consumed by `akan plan-slice`, and by anything that moves an app or a lib between workspaces.
24
+ */
25
+ export class SlicePlanner {
26
+ /** Root entries owned by an app, a lib or an in-tree package rather than by the workspace shell. */
27
+ static readonly memberDirs = ["apps", "libs", "pkgs"];
28
+ /** Never reported unused: the toolchain a workspace needs whether or not app code imports it. */
29
+ static readonly toolchainDependencies = [
30
+ "@akanjs/cli",
31
+ "@akanjs/devkit",
32
+ "@biomejs/biome",
33
+ "@types/bun",
34
+ "akanjs",
35
+ "typescript",
36
+ ];
37
+
38
+ #app: AppExecutor;
39
+ constructor(app: AppExecutor) {
40
+ this.#app = app;
41
+ }
42
+
43
+ async #trackedFiles(paths: string[]) {
44
+ return await this.#app.workspace.listGitFiles(paths);
45
+ }
46
+
47
+ async #untrackedFiles(paths: string[]) {
48
+ const [tracked, all] = await Promise.all([
49
+ this.#app.workspace.listGitFiles(paths),
50
+ this.#app.workspace.listGitFiles(paths, { untracked: true }),
51
+ ]);
52
+ const trackedSet = new Set(tracked);
53
+ return all.filter((file) => !trackedSet.has(file));
54
+ }
55
+
56
+ #isMemberFile(file: string) {
57
+ return SlicePlanner.memberDirs.includes(file.split("/")[0] ?? "");
58
+ }
59
+
60
+ static #requiredDependencies(appInfo: AppInfo) {
61
+ const scanResults = [
62
+ appInfo.getScanResult(),
63
+ ...[...appInfo.getLibInfos().values()].map((lib) => lib.getScanResult()),
64
+ ];
65
+ return new Set([
66
+ ...SlicePlanner.toolchainDependencies,
67
+ ...scanResults.flatMap((scanResult) => [
68
+ ...scanResult.dependencies,
69
+ ...scanResult.devDependencies,
70
+ ...scanResult.pkgDeps,
71
+ ]),
72
+ ]);
73
+ }
74
+
75
+ async #patchWarnings(packageJson: PackageJson) {
76
+ const patchedDependencies = (packageJson.patchedDependencies ?? {}) as Record<string, string>;
77
+ const missing = (
78
+ await Promise.all(
79
+ Object.entries(patchedDependencies).map(async ([spec, patchPath]) =>
80
+ (await this.#app.workspace.exists(patchPath)) ? null : `${spec} -> ${patchPath}`,
81
+ ),
82
+ )
83
+ ).filter((entry): entry is string => !!entry);
84
+ return missing.length ? [`patchedDependencies references a missing patch file: ${missing.join(", ")}`] : [];
85
+ }
86
+
87
+ async plan(): Promise<SlicePlan> {
88
+ const appInfo = await AppInfo.fromExecutor(this.#app);
89
+ const libs = appInfo.getLibs();
90
+ const slicePaths = [`apps/${this.#app.name}`, ...libs.map((lib) => `libs/${lib}`)];
91
+
92
+ const [appFiles, allTracked, untracked, rootPackageJson] = await Promise.all([
93
+ this.#trackedFiles([`apps/${this.#app.name}`]),
94
+ this.#trackedFiles(["."]),
95
+ this.#untrackedFiles(slicePaths),
96
+ this.#app.workspace.getPackageJson(),
97
+ ]);
98
+
99
+ const libFiles = Object.fromEntries(
100
+ await Promise.all(libs.map(async (lib) => [lib, await this.#trackedFiles([`libs/${lib}`])] as const)),
101
+ );
102
+ const rootFiles = allTracked.filter((file) => !this.#isMemberFile(file));
103
+
104
+ const required = SlicePlanner.#requiredDependencies(appInfo);
105
+ const rootDependencies = { ...rootPackageJson.dependencies, ...rootPackageJson.devDependencies };
106
+ const unusedDependencies = Object.keys(rootDependencies)
107
+ .filter((dep) => !required.has(dep))
108
+ .sort();
109
+
110
+ //* `workspaces` names in-tree packages, and a slice never carries `pkgs/` — it consumes akanjs from
111
+ //* the registry. Dependencies are copied whole rather than pruned: the root of a hub is a superset of
112
+ //* every slice, so carrying it always installs, while a wrong prune produces a repo that does not.
113
+ const { workspaces: _workspaces, ...slicePackageJson } = rootPackageJson;
114
+
115
+ const warnings = [
116
+ ...(untracked.length
117
+ ? [`${untracked.length} untracked file(s) under the slice paths: ${untracked.join(", ")}`]
118
+ : []),
119
+ ...(rootPackageJson.workspaces ? ["root `workspaces` dropped — a slice consumes akanjs from the registry"] : []),
120
+ ...(await this.#patchWarnings(rootPackageJson)),
121
+ ];
122
+
123
+ return {
124
+ app: this.#app.name,
125
+ libs,
126
+ appFiles,
127
+ libFiles,
128
+ rootFiles,
129
+ packageJson: slicePackageJson as PackageJson,
130
+ unusedDependencies,
131
+ warnings,
132
+ };
133
+ }
134
+ }
135
+
136
+ /** Top-level entry each path sits under, so a long tree (`infra/**`) reads as one line. */
137
+ const rootEntriesOf = (rootFiles: string[]) => [...new Set(rootFiles.map((file) => file.split("/")[0] ?? file))].sort();
138
+
139
+ export function formatSlicePlan(plan: SlicePlan) {
140
+ const libCounts = Object.entries(plan.libFiles).map(([lib, files]) => ` - libs/${lib}: ${files.length} files`);
141
+ const sections = [
142
+ "Akan Slice Plan",
143
+ `app: ${plan.app} (apps/${plan.app}: ${plan.appFiles.length} files)`,
144
+ `libs: ${plan.libs.length ? plan.libs.join(", ") : "(none)"}`,
145
+ ...libCounts,
146
+ `workspace shell: ${plan.rootFiles.length} files across ${rootEntriesOf(plan.rootFiles).join(", ")}`,
147
+ "",
148
+ `Unused root dependencies (${plan.unusedDependencies.length}) — prune candidates, not pruned:`,
149
+ "",
150
+ ...(plan.unusedDependencies.length ? plan.unusedDependencies.map((dep) => ` - ${dep}`) : [" (none)"]),
151
+ "",
152
+ `Warnings (${plan.warnings.length}):`,
153
+ "",
154
+ ...(plan.warnings.length ? plan.warnings.map((warning) => ` - ${warning}`) : [" (none)"]),
155
+ ];
156
+ return sections.join("\n");
157
+ }
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type ts from "typescript";
4
+
5
+ type TypeScript = typeof ts;
6
+
7
+ export class AsyncDefaultExportDetector {
8
+ static #typescriptLoad: Promise<TypeScript> | undefined;
9
+
10
+ // `typescript` costs ~70 MB resident and this detector is reached from the cli entry through the root
11
+ // layout generator, so the compiler loads on first use rather than at import (`entryModuleGraph.test.ts`).
12
+ static #loadTypescript(): Promise<TypeScript> {
13
+ AsyncDefaultExportDetector.#typescriptLoad ??= import("typescript").then(
14
+ (mod) => (mod.default ?? mod) as TypeScript,
15
+ );
16
+ return AsyncDefaultExportDetector.#typescriptLoad;
17
+ }
18
+
19
+ static async detect(moduleAbsPath: string): Promise<boolean> {
20
+ try {
21
+ const typescript = await AsyncDefaultExportDetector.#loadTypescript();
22
+ const source = fs.readFileSync(path.resolve(moduleAbsPath), "utf8");
23
+ const sourceFile = typescript.createSourceFile(
24
+ moduleAbsPath,
25
+ source,
26
+ typescript.ScriptTarget.Latest,
27
+ true,
28
+ AsyncDefaultExportDetector.#scriptKind(typescript, moduleAbsPath),
29
+ );
30
+ return new AsyncDefaultExportDetector(typescript).detectInSourceFile(sourceFile);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ #ts: TypeScript;
37
+
38
+ constructor(typescript: TypeScript) {
39
+ this.#ts = typescript;
40
+ }
41
+
42
+ detectInSourceFile(sourceFile: ts.SourceFile): boolean {
43
+ const ts = this.#ts;
44
+ const asyncBindings = new Map<string, boolean>();
45
+ let defaultIdentifier: string | null = null;
46
+
47
+ for (const statement of sourceFile.statements) {
48
+ if (ts.isFunctionDeclaration(statement)) {
49
+ if (this.#hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
50
+ return this.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword);
51
+ }
52
+ if (statement.name) {
53
+ asyncBindings.set(statement.name.text, this.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword));
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (ts.isVariableStatement(statement)) {
59
+ for (const declaration of statement.declarationList.declarations) {
60
+ if (!ts.isIdentifier(declaration.name)) continue;
61
+ asyncBindings.set(declaration.name.text, this.#isAsyncFunctionExpression(declaration.initializer));
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (ts.isExportAssignment(statement)) {
67
+ if (this.#isAsyncFunctionExpression(statement.expression)) return true;
68
+ if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
69
+ continue;
70
+ }
71
+
72
+ if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
73
+ const exportClause = statement.exportClause;
74
+ for (const specifier of exportClause.elements) {
75
+ if (specifier.name.text !== "default") continue;
76
+ defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
77
+ }
78
+ }
79
+ }
80
+
81
+ return defaultIdentifier ? asyncBindings.get(defaultIdentifier) === true : false;
82
+ }
83
+
84
+ #hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
85
+ const ts = this.#ts;
86
+ return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false);
87
+ }
88
+
89
+ #isAsyncFunctionExpression(node?: ts.Expression): boolean {
90
+ const ts = this.#ts;
91
+ return Boolean(
92
+ node &&
93
+ (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
94
+ this.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
95
+ );
96
+ }
97
+
98
+ static #scriptKind(typescript: TypeScript, moduleAbsPath: string): ts.ScriptKind {
99
+ return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx")
100
+ ? typescript.ScriptKind.TSX
101
+ : typescript.ScriptKind.TS;
102
+ }
103
+ }