@akanjs/devkit 3.0.0-alpha.76 → 3.0.0-alpha.78

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,109 @@
1
+ import { afterEach, 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 { Executor, LibExecutor, WorkspaceExecutor } from "./executors";
6
+ import { formatLibStatuses, LibSource } from "./libSource";
7
+
8
+ const tempRoots: string[] = [];
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
12
+ });
13
+
14
+ const write = async (filePath: string, content: string) => {
15
+ await mkdir(path.dirname(filePath), { recursive: true });
16
+ await writeFile(filePath, content);
17
+ };
18
+
19
+ // `LibExecutor.from` memoises by name, so each fixture needs a name no other test has used.
20
+ const makeLib = async (libName: string) => {
21
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-libsource-"));
22
+ tempRoots.push(root);
23
+ await write(path.join(root, "package.json"), '{ "name": "hub", "version": "0.0.1", "description": "hub" }\n');
24
+ await write(path.join(root, ".gitignore"), "node_modules\n");
25
+ await write(path.join(root, `libs/${libName}/package.json`), `{ "name": "@${libName}", "version": "0.0.1" }\n`);
26
+ await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
27
+ await write(path.join(root, `libs/${libName}/env/env.server.testing.ts`), "export const env = { key: 1 };\n");
28
+
29
+ const git = new Executor("fixture", root);
30
+ await git.spawn("git", ["init", "--quiet"]);
31
+
32
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `hub-${libName}` });
33
+ const lib = LibExecutor.from(workspace, libName);
34
+ return { root, lib, source: new LibSource(lib) };
35
+ };
36
+
37
+ describe("LibSource", () => {
38
+ test("reports an unstamped library", async () => {
39
+ const { source } = await makeLib("plain-lib");
40
+ const status = await source.status();
41
+
42
+ expect(status.drift).toBe("unstamped");
43
+ expect(status.stamp).toBeNull();
44
+ expect(status.hash).toMatch(/^[0-9a-f]{32}$/);
45
+ });
46
+
47
+ test("stamps the origin into package.json and reads back clean", async () => {
48
+ const { lib, source } = await makeLib("stamped-lib");
49
+ const stamp = await source.write({ origin: "akanjs", sha: "3.0.0" });
50
+
51
+ expect(stamp.origin).toBe("akanjs");
52
+ expect(await source.read()).toEqual(stamp);
53
+ expect((await source.status()).drift).toBe("clean");
54
+
55
+ const manifest = await lib.getPackageJson();
56
+ expect(manifest.name).toBe("@stamped-lib");
57
+ expect((manifest.akan as { source: { sha: string } }).source.sha).toBe("3.0.0");
58
+ });
59
+
60
+ test("detects an edit to library source as drift", async () => {
61
+ const { root, source } = await makeLib("drift-lib");
62
+ await source.write({ origin: "akanjs", sha: "3.0.0" });
63
+ await write(path.join(root, "libs/drift-lib/common/helper.ts"), "export const helper = 2;\n");
64
+
65
+ expect((await source.status()).drift).toBe("drifted");
66
+ });
67
+
68
+ test("survives every other akan write to the manifest", async () => {
69
+ const { lib, source } = await makeLib("merge-lib");
70
+ const stamp = await source.write({ origin: "akanjs", sha: "3.0.0" });
71
+ const manifest = await lib.getPackageJson();
72
+ await lib.setPackageJson({ ...manifest, dependencies: { lodash: "4.0.0" } });
73
+
74
+ expect(await source.read()).toEqual(stamp);
75
+ expect((await source.status()).drift).toBe("drifted");
76
+ });
77
+
78
+ test("leaves env values out of the hash — they belong to the installing workspace", async () => {
79
+ const { root, source } = await makeLib("env-lib");
80
+ await source.write({ origin: "akanjs", sha: "3.0.0" });
81
+ await write(path.join(root, "libs/env-lib/env/env.server.testing.ts"), "export const env = { key: 999 };\n");
82
+
83
+ expect((await source.status()).drift).toBe("clean");
84
+ });
85
+ });
86
+
87
+ describe("formatLibStatuses", () => {
88
+ test("marks drifted libraries and counts them", () => {
89
+ const text = formatLibStatuses([
90
+ {
91
+ lib: "util",
92
+ drift: "clean",
93
+ hash: "a".repeat(32),
94
+ stamp: { origin: "akanjs", sha: "3.0.0", hash: "a".repeat(32), syncedAt: "now" },
95
+ },
96
+ {
97
+ lib: "shared",
98
+ drift: "drifted",
99
+ hash: "b".repeat(32),
100
+ stamp: { origin: "akanjs", sha: "3.0.0", hash: "a".repeat(32), syncedAt: "now" },
101
+ },
102
+ { lib: "local", drift: "unstamped", hash: "c".repeat(32), stamp: null },
103
+ ]);
104
+
105
+ expect(text).toContain("DRIFTED libs/shared akanjs@3.0.0");
106
+ expect(text).toContain("unstamped libs/local no akan.source in package.json");
107
+ expect(text).toContain("drifted: 1 / 3");
108
+ });
109
+ });
package/libSource.ts ADDED
@@ -0,0 +1,126 @@
1
+ import type { LibExecutor } from "./executors";
2
+ import type { PackageJson } from "./types";
3
+
4
+ export interface LibSourceStamp {
5
+ /** Where the copy came from: a git remote URL, or `akanjs` for the published package. */
6
+ origin: string;
7
+ /** The origin's commit sha, or the package version when the origin is a registry. */
8
+ sha: string;
9
+ /** Content hash of the library as installed, so a later edit is detectable without the origin. */
10
+ hash: string;
11
+ syncedAt: string;
12
+ }
13
+
14
+ export type LibDrift = "clean" | "drifted" | "unstamped";
15
+
16
+ export interface LibStatus {
17
+ lib: string;
18
+ drift: LibDrift;
19
+ stamp: LibSourceStamp | null;
20
+ hash: string;
21
+ }
22
+
23
+ /**
24
+ * A library's origin, recorded in its own `package.json` under an `akan.source` key.
25
+ *
26
+ * The key rides `package.json` rather than a file of its own because every akan write to a library's
27
+ * manifest is a spread of the existing object (`LibExecutor.syncPackageJson`), so an unknown top-level
28
+ * key survives — while a new root file would have to be added to `libRootAllowedFiles` before
29
+ * `akan sync` stopped rejecting it.
30
+ */
31
+ export class LibSource {
32
+ static readonly manifestKey = "akan";
33
+ /**
34
+ * Left out of the hash. `env/` holds per-deployment values that belong to the workspace the library
35
+ * was installed into, not to the origin, so an env edit is not drift.
36
+ */
37
+ static readonly unhashedDirs = ["env"];
38
+
39
+ #lib: LibExecutor;
40
+ constructor(lib: LibExecutor) {
41
+ this.#lib = lib;
42
+ }
43
+
44
+ get #prefix() {
45
+ return `libs/${this.#lib.name}/`;
46
+ }
47
+
48
+ #isHashed(file: string) {
49
+ const relative = file.slice(this.#prefix.length);
50
+ return !LibSource.unhashedDirs.includes(relative.split("/")[0] ?? "");
51
+ }
52
+
53
+ /** `package.json` cannot hash the stamp it carries, so the key comes off before hashing. */
54
+ async #hashableContent(file: string) {
55
+ const content = await this.#lib.workspace.readFile(file);
56
+ if (file !== `${this.#prefix}package.json`) return content;
57
+ const manifest = JSON.parse(content) as PackageJson;
58
+ delete manifest[LibSource.manifestKey];
59
+ return JSON.stringify(manifest);
60
+ }
61
+
62
+ /**
63
+ * Hash over the library's own files. Untracked files count: a freshly copied library is not committed
64
+ * yet, and its hash has to be the same one a later `status` recomputes.
65
+ */
66
+ async computeHash() {
67
+ const files = (await this.#lib.workspace.listGitFiles([`libs/${this.#lib.name}`], { untracked: true })).filter(
68
+ (file) => this.#isHashed(file),
69
+ );
70
+ const hasher = new Bun.CryptoHasher("sha256");
71
+ for (const file of files) {
72
+ hasher.update(file);
73
+ hasher.update("\0");
74
+ hasher.update(await this.#hashableContent(file));
75
+ hasher.update("\0");
76
+ }
77
+ return hasher.digest("hex").slice(0, 32);
78
+ }
79
+
80
+ async read(): Promise<LibSourceStamp | null> {
81
+ const manifest = await this.#lib.getPackageJson();
82
+ const akan = manifest[LibSource.manifestKey] as { source?: LibSourceStamp } | undefined;
83
+ return akan?.source ?? null;
84
+ }
85
+
86
+ async write({ origin, sha }: Pick<LibSourceStamp, "origin" | "sha">) {
87
+ const [manifest, hash] = await Promise.all([this.#lib.getPackageJson(), this.computeHash()]);
88
+ const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
89
+ const stamp: LibSourceStamp = { origin, sha, hash, syncedAt: new Date().toISOString() };
90
+ await this.#lib.setPackageJson({ ...manifest, [LibSource.manifestKey]: { ...akan, source: stamp } });
91
+ return stamp;
92
+ }
93
+
94
+ /**
95
+ * Writes the stamp only when it would change. `syncedAt` moves on every write, so an unconditional
96
+ * write leaves the manifest dirty and defeats an idempotent caller — the hash is computed with the
97
+ * stamp removed, so comparing it first is sound.
98
+ */
99
+ async syncStamp({ origin, sha }: Pick<LibSourceStamp, "origin" | "sha">) {
100
+ const [current, hash] = await Promise.all([this.read(), this.computeHash()]);
101
+ if (current?.origin === origin && current.sha === sha && current.hash === hash)
102
+ return { stamp: current, changed: false };
103
+ return { stamp: await this.write({ origin, sha }), changed: true };
104
+ }
105
+
106
+ async status(): Promise<LibStatus> {
107
+ const [stamp, hash] = await Promise.all([this.read(), this.computeHash()]);
108
+ const drift = !stamp ? "unstamped" : stamp.hash === hash ? "clean" : "drifted";
109
+ return { lib: this.#lib.name, drift, stamp, hash };
110
+ }
111
+ }
112
+
113
+ export function formatLibStatuses(statuses: LibStatus[]) {
114
+ const marks = { clean: "clean ", drifted: "DRIFTED ", unstamped: "unstamped" } as const;
115
+ const sections = [
116
+ "Akan Library Source Status",
117
+ "",
118
+ ...statuses.map((status) => {
119
+ const origin = status.stamp ? `${status.stamp.origin}@${status.stamp.sha}` : "no akan.source in package.json";
120
+ return ` ${marks[status.drift]} libs/${status.lib} ${origin}`;
121
+ }),
122
+ "",
123
+ `drifted: ${statuses.filter((status) => status.drift === "drifted").length} / ${statuses.length}`,
124
+ ];
125
+ return sections.join("\n");
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.76",
3
+ "version": "3.0.0-alpha.78",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -45,7 +45,7 @@
45
45
  "@langchain/openai": "^1.4.6",
46
46
  "@tailwindcss/node": "^4.3.0",
47
47
  "@trapezedev/project": "^7.1.4",
48
- "akanjs": "3.0.0-alpha.76",
48
+ "akanjs": "3.0.0-alpha.78",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",
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,151 @@
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 = "hub";
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: "hub",
43
+ version: "0.0.1",
44
+ description: "hub",
45
+ workspaces: ["pkgs/*"],
46
+ dependencies: { lodash: "4.0.0", "unused-dep": "1.0.0" },
47
+ devDependencies: { typescript: "6.0.0" },
48
+ };
49
+ await write(path.join(root, "package.json"), `${JSON.stringify(rootPackageJson, null, 2)}\n`);
50
+ await write(path.join(root, ".gitignore"), `${gitignore}\n`);
51
+ await write(path.join(root, "biome.json"), "{}\n");
52
+
53
+ await write(path.join(root, `apps/${appName}/akan.config.ts`), "export default {};\n");
54
+ await write(path.join(root, `apps/${appName}/tsconfig.json`), "{}\n");
55
+ await write(
56
+ path.join(root, `apps/${appName}/lib/task/task.constant.ts`),
57
+ [
58
+ `import { helper } from "@libs/${libName}/common";`,
59
+ 'import lodash from "lodash";',
60
+ "",
61
+ "export { helper, lodash };",
62
+ "",
63
+ ].join("\n"),
64
+ );
65
+ await write(path.join(root, `apps/${appName}/lib/cnst.ts`), "export {};\n");
66
+ await write(path.join(root, `apps/${appName}/env/env.server.local.ts`), "export const env = {};\n");
67
+
68
+ await write(path.join(root, `libs/${libName}/akan.config.ts`), "export default {};\n");
69
+ await write(path.join(root, `libs/${libName}/tsconfig.json`), "{}\n");
70
+ await mkdir(path.join(root, `libs/${libName}/lib`), { recursive: true });
71
+ await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
72
+ await write(path.join(root, `libs/${libName}/common/index.ts`), 'export * from "./helper";\n');
73
+
74
+ await write(path.join(root, "pkgs/in-tree/package.json"), '{ "name": "in-tree" }\n');
75
+
76
+ const git = new Executor("fixture", root);
77
+ await git.spawn("git", ["init", "--quiet"]);
78
+ await git.spawn("git", ["add", "-A"]);
79
+ await git.spawn("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "fixture"]);
80
+
81
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `hub-${appName}` });
82
+ return { root, plan: async () => await new SlicePlanner(AppExecutor.from(workspace, appName)).plan() };
83
+ };
84
+
85
+ describe("SlicePlanner", () => {
86
+ test("resolves the lib closure and lists only git-tracked slice files", async () => {
87
+ const { plan } = await makeWorkspace("slice-app", "slice-lib");
88
+ const result = await plan();
89
+
90
+ expect(result.app).toBe("slice-app");
91
+ expect(result.libs).toEqual(["slice-lib"]);
92
+ expect(result.appFiles).toContain("apps/slice-app/lib/task/task.constant.ts");
93
+ expect(result.libFiles["slice-lib"]).toContain("libs/slice-lib/common/helper.ts");
94
+ });
95
+
96
+ test("leaves out generated barrels, env values and every other workspace member", async () => {
97
+ const { plan } = await makeWorkspace("ignore-app", "ignore-lib");
98
+ const result = await plan();
99
+
100
+ expect(result.appFiles).not.toContain("apps/ignore-app/lib/cnst.ts");
101
+ expect(result.appFiles).not.toContain("apps/ignore-app/env/env.server.local.ts");
102
+ expect(result.libFiles["ignore-lib"]).not.toContain("libs/ignore-lib/common/index.ts");
103
+ expect(result.rootFiles).toEqual(expect.arrayContaining(["package.json", ".gitignore", "biome.json"]));
104
+ expect(result.rootFiles.filter((file) => file.startsWith("pkgs/"))).toEqual([]);
105
+ expect(result.rootFiles.filter((file) => /^(apps|libs)\//.test(file))).toEqual([]);
106
+ });
107
+
108
+ test("drops `workspaces` from the slice manifest and reports it", async () => {
109
+ const { plan } = await makeWorkspace("manifest-app", "manifest-lib");
110
+ const result = await plan();
111
+
112
+ expect(result.packageJson.workspaces).toBeUndefined();
113
+ expect(result.packageJson.dependencies).toEqual({ lodash: "4.0.0", "unused-dep": "1.0.0" });
114
+ expect(result.warnings.join("\n")).toContain("workspaces");
115
+ });
116
+
117
+ test("reports unused root dependencies without pruning the toolchain", async () => {
118
+ const { plan } = await makeWorkspace("deps-app", "deps-lib");
119
+ const result = await plan();
120
+
121
+ expect(result.unusedDependencies).toContain("unused-dep");
122
+ expect(result.unusedDependencies).not.toContain("typescript");
123
+ });
124
+
125
+ test("warns about untracked files under the slice paths", async () => {
126
+ const { root, plan } = await makeWorkspace("untracked-app", "untracked-lib");
127
+ await write(path.join(root, "apps/untracked-app/lib/task/task.service.ts"), "export {};\n");
128
+ const result = await plan();
129
+
130
+ expect(result.warnings.join("\n")).toContain("apps/untracked-app/lib/task/task.service.ts");
131
+ });
132
+ });
133
+
134
+ describe("formatSlicePlan", () => {
135
+ test("collapses the workspace shell to its top-level entries", () => {
136
+ const text = formatSlicePlan({
137
+ app: "demo",
138
+ libs: ["util"],
139
+ appFiles: ["apps/demo/main.ts"],
140
+ libFiles: { util: ["libs/util/index.ts"] },
141
+ rootFiles: ["package.json", "infra/app/values/main.yaml", "infra/app/templates/app.yaml"],
142
+ packageJson: { name: "demo", version: "0.0.1", description: "demo" },
143
+ unusedDependencies: [],
144
+ warnings: [],
145
+ });
146
+
147
+ expect(text).toContain("app: demo (apps/demo: 1 files)");
148
+ expect(text).toContain("3 files across infra, package.json");
149
+ expect(text).toContain("(none)");
150
+ });
151
+ });
@@ -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
+ }