@akanjs/devkit 3.0.0-alpha.80 → 3.0.0-alpha.82

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/libSource.test.ts CHANGED
@@ -20,7 +20,10 @@ const write = async (filePath: string, content: string) => {
20
20
  const makeLib = async (libName: string) => {
21
21
  const root = await mkdtemp(path.join(os.tmpdir(), "akan-libsource-"));
22
22
  tempRoots.push(root);
23
- await write(path.join(root, "package.json"), '{ "name": "hub", "version": "0.0.1", "description": "hub" }\n');
23
+ await write(
24
+ path.join(root, "package.json"),
25
+ '{ "name": "workspace", "version": "0.0.1", "description": "workspace" }\n',
26
+ );
24
27
  await write(path.join(root, ".gitignore"), "node_modules\n");
25
28
  await write(path.join(root, `libs/${libName}/package.json`), `{ "name": "@${libName}", "version": "0.0.1" }\n`);
26
29
  await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
@@ -29,7 +32,7 @@ const makeLib = async (libName: string) => {
29
32
  const git = new Executor("fixture", root);
30
33
  await git.spawn("git", ["init", "--quiet"]);
31
34
 
32
- const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `hub-${libName}` });
35
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `workspace-${libName}` });
33
36
  const lib = LibExecutor.from(workspace, libName);
34
37
  return { root, lib, source: new LibSource(lib) };
35
38
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.80",
3
+ "version": "3.0.0-alpha.82",
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.80",
48
+ "akanjs": "3.0.0-alpha.82",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",
@@ -11,7 +11,7 @@ const originalEnv = { ...process.env };
11
11
 
12
12
  beforeEach(() => {
13
13
  process.env = { ...originalEnv };
14
- process.env.AKAN_PUBLIC_REPO_NAME = "hub";
14
+ process.env.AKAN_PUBLIC_REPO_NAME = "workspace";
15
15
  process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
16
16
  process.env.AKAN_PUBLIC_ENV = "local";
17
17
  });
@@ -39,16 +39,27 @@ const makeWorkspace = async (appName: string, libName: string) => {
39
39
  const root = await mkdtemp(path.join(os.tmpdir(), "akan-slice-"));
40
40
  tempRoots.push(root);
41
41
  const rootPackageJson: PackageJson = {
42
- name: "hub",
42
+ name: "workspace",
43
43
  version: "0.0.1",
44
- description: "hub",
44
+ description: "workspace",
45
45
  workspaces: ["pkgs/*"],
46
- dependencies: { lodash: "4.0.0", "unused-dep": "1.0.0" },
47
- devDependencies: { typescript: "6.0.0" },
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
+ },
48
52
  };
49
53
  await write(path.join(root, "package.json"), `${JSON.stringify(rootPackageJson, null, 2)}\n`);
50
54
  await write(path.join(root, ".gitignore"), `${gitignore}\n`);
51
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"), "");
52
63
 
53
64
  await write(path.join(root, `apps/${appName}/akan.config.ts`), "export default {};\n");
54
65
  await write(path.join(root, `apps/${appName}/tsconfig.json`), "{}\n");
@@ -78,7 +89,7 @@ const makeWorkspace = async (appName: string, libName: string) => {
78
89
  await git.spawn("git", ["add", "-A"]);
79
90
  await git.spawn("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "fixture"]);
80
91
 
81
- const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `hub-${appName}` });
92
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `workspace-${appName}` });
82
93
  return { root, plan: async () => await new SlicePlanner(AppExecutor.from(workspace, appName)).plan() };
83
94
  };
84
95
 
@@ -110,16 +121,35 @@ describe("SlicePlanner", () => {
110
121
  const result = await plan();
111
122
 
112
123
  expect(result.packageJson.workspaces).toBeUndefined();
113
- expect(result.packageJson.dependencies).toEqual({ lodash: "4.0.0", "unused-dep": "1.0.0" });
114
124
  expect(result.warnings.join("\n")).toContain("workspaces");
115
125
  });
116
126
 
117
- test("reports unused root dependencies without pruning the toolchain", async () => {
127
+ test("prunes what the slice does not import, keeping the toolchain and the type packages", async () => {
118
128
  const { plan } = await makeWorkspace("deps-app", "deps-lib");
119
129
  const result = await plan();
120
130
 
121
- expect(result.unusedDependencies).toContain("unused-dep");
122
- expect(result.unusedDependencies).not.toContain("typescript");
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");
123
153
  });
124
154
 
125
155
  test("warns about untracked files under the slice paths", async () => {
@@ -140,6 +170,7 @@ describe("formatSlicePlan", () => {
140
170
  libFiles: { util: ["libs/util/index.ts"] },
141
171
  rootFiles: ["package.json", "infra/app/values/main.yaml", "infra/app/templates/app.yaml"],
142
172
  packageJson: { name: "demo", version: "0.0.1", description: "demo" },
173
+ requiredDependencies: [],
143
174
  unusedDependencies: [],
144
175
  warnings: [],
145
176
  });
package/slicePlanner.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AppExecutor } from "./executors";
1
+ import type { AppExecutor, WorkspaceExecutor } from "./executors";
2
2
  import { AppInfo } from "./scanInfo";
3
3
  import type { PackageJson } from "./types";
4
4
 
@@ -9,9 +9,11 @@ export interface SlicePlan {
9
9
  appFiles: string[];
10
10
  libFiles: Record<string, string[]>;
11
11
  rootFiles: string[];
12
- /** Root manifest for a workspace holding only this slice. */
12
+ /** Root manifest for a workspace holding only this slice, with the dependencies it does not use pruned. */
13
13
  packageJson: PackageJson;
14
- /** Root dependencies nothing in the slice imports prune candidates for a human, never pruned here. */
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`. */
15
17
  unusedDependencies: string[];
16
18
  warnings: string[];
17
19
  }
@@ -25,15 +27,94 @@ export interface SlicePlan {
25
27
  export class SlicePlanner {
26
28
  /** Root entries owned by an app, a lib or an in-tree package rather than by the workspace shell. */
27
29
  static readonly memberDirs = ["apps", "libs", "pkgs"];
28
- /** Never reported unused: the toolchain a workspace needs whether or not app code imports it. */
30
+ /** Never pruned: the toolchain a workspace runs on, which no source file imports. */
29
31
  static readonly toolchainDependencies = [
30
32
  "@akanjs/cli",
31
33
  "@akanjs/devkit",
32
34
  "@biomejs/biome",
33
35
  "@types/bun",
34
36
  "akanjs",
37
+ "tailwindcss",
35
38
  "typescript",
36
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
+ }
37
118
 
38
119
  #app: AppExecutor;
39
120
  constructor(app: AppExecutor) {
@@ -63,7 +144,6 @@ export class SlicePlanner {
63
144
  ...[...appInfo.getLibInfos().values()].map((lib) => lib.getScanResult()),
64
145
  ];
65
146
  return new Set([
66
- ...SlicePlanner.toolchainDependencies,
67
147
  ...scanResults.flatMap((scanResult) => [
68
148
  ...scanResult.dependencies,
69
149
  ...scanResult.devDependencies,
@@ -102,21 +182,18 @@ export class SlicePlanner {
102
182
  const rootFiles = allTracked.filter((file) => !this.#isMemberFile(file));
103
183
 
104
184
  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;
185
+ const {
186
+ packageJson,
187
+ pruned,
188
+ warnings: manifestWarnings,
189
+ } = await SlicePlanner.pruneDependencies(this.#app.workspace, rootPackageJson, required);
114
190
 
115
191
  const warnings = [
116
192
  ...(untracked.length
117
193
  ? [`${untracked.length} untracked file(s) under the slice paths: ${untracked.join(", ")}`]
118
194
  : []),
119
195
  ...(rootPackageJson.workspaces ? ["root `workspaces` dropped — a slice consumes akanjs from the registry"] : []),
196
+ ...manifestWarnings,
120
197
  ...(await this.#patchWarnings(rootPackageJson)),
121
198
  ];
122
199
 
@@ -126,8 +203,9 @@ export class SlicePlanner {
126
203
  appFiles,
127
204
  libFiles,
128
205
  rootFiles,
129
- packageJson: slicePackageJson as PackageJson,
130
- unusedDependencies,
206
+ packageJson,
207
+ requiredDependencies: [...required].sort(),
208
+ unusedDependencies: pruned,
131
209
  warnings,
132
210
  };
133
211
  }
@@ -145,7 +223,7 @@ export function formatSlicePlan(plan: SlicePlan) {
145
223
  ...libCounts,
146
224
  `workspace shell: ${plan.rootFiles.length} files across ${rootEntriesOf(plan.rootFiles).join(", ")}`,
147
225
  "",
148
- `Unused root dependencies (${plan.unusedDependencies.length}) — prune candidates, not pruned:`,
226
+ `Root dependencies this slice does not use (${plan.unusedDependencies.length}) — pruned:`,
149
227
  "",
150
228
  ...(plan.unusedDependencies.length ? plan.unusedDependencies.map((dep) => ` - ${dep}`) : [" (none)"]),
151
229
  "",