@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.
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +25 -5
- package/executors.ts +20 -0
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +90 -9
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/libSource.test.ts +109 -0
- package/libSource.ts +114 -0
- package/package.json +2 -2
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +151 -0
- package/slicePlanner.ts +157 -0
- package/transforms/asyncDefaultExportDetector.ts +103 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import path from "node:path";
|
|
3
|
-
import ts from "typescript";
|
|
4
2
|
import type { PageEntry } from "../artifact/implicitRootLayout";
|
|
3
|
+
import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
|
|
5
4
|
|
|
6
5
|
export class PagesEntrySourceGenerator {
|
|
7
6
|
#pageEntries: PageEntry[];
|
|
@@ -22,101 +21,25 @@ export class PagesEntrySourceGenerator {
|
|
|
22
21
|
return `export const pages = {\n${lines.join("\n")}\n};\n`;
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
static generateStatic(pageEntries: PageEntry[]): string {
|
|
26
|
-
return new PagesEntrySourceGenerator(pageEntries).generateStatic();
|
|
24
|
+
static async generateStatic(pageEntries: PageEntry[]): Promise<string> {
|
|
25
|
+
return await new PagesEntrySourceGenerator(pageEntries).generateStatic();
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
generateStatic(): string {
|
|
28
|
+
async generateStatic(): Promise<string> {
|
|
30
29
|
const imports = this.#pageEntries.map(({ moduleAbsPath }, index) => {
|
|
31
30
|
const specifier = PagesEntrySourceGenerator.#toImportSpecifier(moduleAbsPath);
|
|
32
31
|
return `import * as page${index} from ${JSON.stringify(specifier)};`;
|
|
33
32
|
});
|
|
34
|
-
const entries =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
const entries = await Promise.all(
|
|
34
|
+
this.#pageEntries.map(async ({ key, moduleAbsPath }, index) => {
|
|
35
|
+
const isAsyncDefault = await AsyncDefaultExportDetector.detect(moduleAbsPath);
|
|
36
|
+
return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
38
39
|
return `${imports.join("\n")}\nexport const pages = {\n${entries.join("\n")}\n};\n`;
|
|
39
40
|
}
|
|
41
|
+
|
|
40
42
|
static #toImportSpecifier(moduleAbsPath: string): string {
|
|
41
43
|
return path.resolve(moduleAbsPath).split(path.sep).join("/");
|
|
42
44
|
}
|
|
43
|
-
|
|
44
|
-
static #hasAsyncDefaultExport(moduleAbsPath: string): boolean {
|
|
45
|
-
try {
|
|
46
|
-
const source = fs.readFileSync(path.resolve(moduleAbsPath), "utf8");
|
|
47
|
-
const sourceFile = ts.createSourceFile(
|
|
48
|
-
moduleAbsPath,
|
|
49
|
-
source,
|
|
50
|
-
ts.ScriptTarget.Latest,
|
|
51
|
-
true,
|
|
52
|
-
PagesEntrySourceGenerator.#scriptKind(moduleAbsPath),
|
|
53
|
-
);
|
|
54
|
-
return PagesEntrySourceGenerator.#sourceFileHasAsyncDefaultExport(sourceFile);
|
|
55
|
-
} catch {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
static #sourceFileHasAsyncDefaultExport(sourceFile: ts.SourceFile): boolean {
|
|
61
|
-
const asyncBindings = new Map<string, boolean>();
|
|
62
|
-
let defaultIdentifier: string | null = null;
|
|
63
|
-
|
|
64
|
-
for (const statement of sourceFile.statements) {
|
|
65
|
-
if (ts.isFunctionDeclaration(statement)) {
|
|
66
|
-
if (PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
|
|
67
|
-
return PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword);
|
|
68
|
-
}
|
|
69
|
-
if (statement.name) {
|
|
70
|
-
asyncBindings.set(
|
|
71
|
-
statement.name.text,
|
|
72
|
-
PagesEntrySourceGenerator.#hasModifier(statement, ts.SyntaxKind.AsyncKeyword),
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (ts.isVariableStatement(statement)) {
|
|
79
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
80
|
-
if (!ts.isIdentifier(declaration.name)) continue;
|
|
81
|
-
asyncBindings.set(
|
|
82
|
-
declaration.name.text,
|
|
83
|
-
PagesEntrySourceGenerator.#isAsyncFunctionExpression(declaration.initializer),
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (ts.isExportAssignment(statement)) {
|
|
90
|
-
if (PagesEntrySourceGenerator.#isAsyncFunctionExpression(statement.expression)) return true;
|
|
91
|
-
if (ts.isIdentifier(statement.expression)) defaultIdentifier = statement.expression.text;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
96
|
-
const exportClause = statement.exportClause;
|
|
97
|
-
for (const specifier of exportClause.elements) {
|
|
98
|
-
if (specifier.name.text !== "default") continue;
|
|
99
|
-
defaultIdentifier = specifier.propertyName?.text ?? specifier.name.text;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return defaultIdentifier ? asyncBindings.get(defaultIdentifier) === true : false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
static #hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean {
|
|
108
|
-
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
static #isAsyncFunctionExpression(node?: ts.Expression): boolean {
|
|
112
|
-
return Boolean(
|
|
113
|
-
node &&
|
|
114
|
-
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
115
|
-
PagesEntrySourceGenerator.#hasModifier(node, ts.SyntaxKind.AsyncKeyword),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
static #scriptKind(moduleAbsPath: string): ts.ScriptKind {
|
|
120
|
-
return moduleAbsPath.endsWith(".tsx") || moduleAbsPath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
121
|
-
}
|
|
122
45
|
}
|
|
@@ -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,114 @@
|
|
|
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
|
+
async status(): Promise<LibStatus> {
|
|
95
|
+
const [stamp, hash] = await Promise.all([this.read(), this.computeHash()]);
|
|
96
|
+
const drift = !stamp ? "unstamped" : stamp.hash === hash ? "clean" : "drifted";
|
|
97
|
+
return { lib: this.#lib.name, drift, stamp, hash };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function formatLibStatuses(statuses: LibStatus[]) {
|
|
102
|
+
const marks = { clean: "clean ", drifted: "DRIFTED ", unstamped: "unstamped" } as const;
|
|
103
|
+
const sections = [
|
|
104
|
+
"Akan Library Source Status",
|
|
105
|
+
"",
|
|
106
|
+
...statuses.map((status) => {
|
|
107
|
+
const origin = status.stamp ? `${status.stamp.origin}@${status.stamp.sha}` : "no akan.source in package.json";
|
|
108
|
+
return ` ${marks[status.drift]} libs/${status.lib} ${origin}`;
|
|
109
|
+
}),
|
|
110
|
+
"",
|
|
111
|
+
`drifted: ${statuses.filter((status) => status.drift === "drifted").length} / ${statuses.length}`,
|
|
112
|
+
];
|
|
113
|
+
return sections.join("\n");
|
|
114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.77",
|
|
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.
|
|
48
|
+
"akanjs": "3.0.0-alpha.77",
|
|
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
|
+
});
|