@akanjs/devkit 3.1.2-rc.3 → 3.2.0-rc.0

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/abstractDoc.ts +1 -1
  3. package/agentAssets.ts +2 -2
  4. package/akanConfig/akanConfig.ts +2 -2
  5. package/akanMcpContract.ts +2 -2
  6. package/apiReference.test.ts +37 -0
  7. package/apiReference.ts +240 -0
  8. package/applicationBuildReporter.test.ts +54 -0
  9. package/applicationBuildReporter.ts +12 -0
  10. package/applicationBuildRunner.test.ts +34 -14
  11. package/applicationBuildRunner.ts +77 -14
  12. package/artifact/implicitRootLayout.test.ts +42 -0
  13. package/artifact/implicitRootLayout.ts +5 -6
  14. package/artifact/storeBootstrap.ts +24 -0
  15. package/cloud/cloudApi.test.ts +70 -7
  16. package/cloud/cloudApi.ts +10 -4
  17. package/cloud/constants.ts +3 -0
  18. package/cloud/globalConfig.test.ts +36 -0
  19. package/cloud/globalConfig.ts +10 -1
  20. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  21. package/codeAgent/skills/akan-page/SKILL.md +30 -2
  22. package/codeAgent/skills/akan-service/SKILL.md +77 -1
  23. package/commandDecorators/command.ts +55 -9
  24. package/commandDecorators/commandDecorators.test.ts +131 -2
  25. package/declarationReader.ts +211 -0
  26. package/executors.test.ts +65 -8
  27. package/executors.ts +5 -3
  28. package/frameworkGraph.test.ts +121 -0
  29. package/frameworkGraph.ts +165 -0
  30. package/frontendBuild/allRoutesBuilder.test.ts +84 -0
  31. package/frontendBuild/allRoutesBuilder.ts +32 -12
  32. package/frontendBuild/buildRouteClient.test.ts +192 -2
  33. package/frontendBuild/bundleDefine.ts +4 -0
  34. package/frontendBuild/clientBuildTypes.ts +7 -1
  35. package/frontendBuild/clientEntriesBundler.test.ts +82 -0
  36. package/frontendBuild/clientEntriesBundler.ts +55 -4
  37. package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
  38. package/frontendBuild/csrArtifactBuilder.ts +2 -1
  39. package/frontendBuild/fontOptimizer.test.ts +54 -0
  40. package/frontendBuild/fontOptimizer.ts +5 -4
  41. package/frontendBuild/index.ts +3 -0
  42. package/frontendBuild/routeClientBuilder.ts +6 -2
  43. package/frontendBuild/routeModulePreloads.test.ts +59 -0
  44. package/frontendBuild/routeModulePreloads.ts +52 -0
  45. package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
  46. package/frontendBuild/ssrDevBundler.test.ts +26 -0
  47. package/frontendBuild/ssrDevBundler.ts +2 -1
  48. package/frontendBuild/vendorImportScanner.test.ts +71 -0
  49. package/frontendBuild/vendorImportScanner.ts +80 -0
  50. package/frontendBuild/vendorNarrowing.test.ts +103 -0
  51. package/frontendBuild/vendorNarrowing.ts +64 -0
  52. package/frontendBuild/vendorSpecifiers.ts +13 -0
  53. package/gitRevision.test.ts +82 -0
  54. package/gitRevision.ts +128 -0
  55. package/incrementalBuilder/builderGraphPayloads.ts +60 -0
  56. package/incrementalBuilder/incrementalBuilder.proc.test.ts +105 -6
  57. package/incrementalBuilder/incrementalBuilder.proc.ts +6 -10
  58. package/integration/devStability.integration.test.ts +1 -1
  59. package/integration/devStabilityHarness.test.ts +1 -1
  60. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +4 -2
  61. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +2 -0
  62. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -2
  63. package/lint/no-daisyui-legacy-class.grit +9 -7
  64. package/lint/no-deep-internal-import.grit +1 -1
  65. package/lint/no-js-private-class-method.grit +9 -28
  66. package/maintainabilityBaseline.ts +7 -2
  67. package/maintainabilityContract.test.ts +183 -0
  68. package/maintainabilityContract.ts +145 -30
  69. package/maintainabilityExceptionScanner.test.ts +13 -2
  70. package/maintainabilityExceptionScanner.ts +16 -6
  71. package/maintainabilityLimits.ts +12 -0
  72. package/maintainabilityReport.test.ts +35 -0
  73. package/maintainabilityReport.ts +29 -8
  74. package/mobile/nativePublicEnv.test.ts +5 -1
  75. package/package.json +3 -3
  76. package/routeJsBudget.test.ts +177 -0
  77. package/routeJsBudget.ts +222 -0
  78. package/structureScanner.test.ts +167 -0
  79. package/structureScanner.ts +164 -0
  80. package/transforms/barrelAnalyzer.ts +10 -3
  81. package/transforms/barrelImportsPlugin.ts +13 -7
  82. package/transforms/serverEnvPlugin.test.ts +1 -1
  83. package/transforms/serverEnvPlugin.ts +1 -1
  84. package/transforms/transforms.test.ts +64 -12
  85. package/transforms/tsconfigPackageResolver.test.ts +12 -0
  86. package/typeChecker.test.ts +100 -0
  87. package/typeChecker.ts +14 -2
  88. package/workflow/plan.ts +79 -17
  89. package/workflow/types.ts +1 -0
  90. package/workspaceGraph.test.ts +121 -0
  91. package/workspaceGraph.ts +149 -16
  92. package/workspaceLayout.ts +1 -0
  93. /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText as write } from "../testHelpers";
4
+ import { SsrDevBundler } from "./ssrDevBundler";
5
+
6
+ const makeTempRoot = tempDirs("akan-ssr-dev-bundler-");
7
+
8
+ describe("SsrDevBundler bootstrap", () => {
9
+ const bootstrapOf = async (root: string) => {
10
+ const appDir = path.join(root, "apps/demo");
11
+ const app = { name: "demo", cwdPath: appDir, workspace: { workspaceRoot: root } } as never;
12
+ const { files } = await new SsrDevBundler(app).writeEntries();
13
+ return await Bun.file(files[SsrDevBundler.bootstrapKey] ?? "").text();
14
+ };
15
+
16
+ test("runs the client runtime and the store, not the client barrel", async () => {
17
+ const root = await makeTempRoot();
18
+ await write(path.join(root, "apps/demo/lib/st.ts"), "export const st = {};\n");
19
+
20
+ expect(await bootstrapOf(root)).toBe('import "@apps/demo/lib/useClient";\nimport "@apps/demo/lib/st";\n');
21
+ });
22
+
23
+ test("runs nothing for an app without a store", async () => {
24
+ expect(await bootstrapOf(await makeTempRoot())).toBe("export {};\n");
25
+ });
26
+ });
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { SSR_DEV_DIRNAME, SSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
4
4
  import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import { computeRouteSeedIndex } from "../artifact/routeSeedIndex";
6
+ import { StoreBootstrap } from "../artifact/storeBootstrap";
6
7
  import type { App } from "../commandDecorators";
7
8
  import { bundleDefine } from "./bundleDefine";
8
9
  import type { ClientEntryDiscovery } from "./clientBuildTypes";
@@ -58,7 +59,7 @@ export class SsrDevBundler extends DevRegistryBundler {
58
59
  await mkdir(this.entryDir, { recursive: true });
59
60
  const file = path.join(this.entryDir, "bootstrap.ts");
60
61
  const hasStore = await Bun.file(path.join(this.app.cwdPath, "lib", "st.ts")).exists();
61
- const source = hasStore ? `import ${JSON.stringify(`@apps/${this.app.name}/client`)};\n` : "export {};\n";
62
+ const source = hasStore ? StoreBootstrap.source(this.app.name) : "export {};\n";
62
63
  const unchanged = (await Bun.file(file).exists()) && (await Bun.file(file).text()) === source;
63
64
  if (!unchanged) await Bun.write(file, source);
64
65
  const entryFile = CsrDevPaths.realpath(file);
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText as write } from "../testHelpers";
4
+ import { VendorImportScanner } from "./vendorImportScanner";
5
+
6
+ const makeTempRoot = tempDirs("akan-vendor-imports-");
7
+ const specifiers = ["akanjs/store", "akanjs/common", "akanjs/fetch", "tailwind-merge"];
8
+ const scan = (...sources: string[]) => {
9
+ const scanner = new VendorImportScanner(specifiers);
10
+ for (const source of sources) scanner.scan(source);
11
+ return scanner.usedExports();
12
+ };
13
+
14
+ describe("VendorImportScanner", () => {
15
+ test("reads the imported names, not the local aliases, of minified and plain imports", () => {
16
+ const used = scan(
17
+ 'import{capitalize as He,Logger as K}from"akanjs/common";import{st}from"akanjs/store";',
18
+ 'import { formSetterNames, Logger } from "akanjs/common";\nimport "akanjs/fetch";\n',
19
+ );
20
+
21
+ expect(used).toEqual({
22
+ "akanjs/store": ["st"],
23
+ "akanjs/common": ["Logger", "capitalize", "formSetterNames"],
24
+ "akanjs/fetch": [],
25
+ "tailwind-merge": [],
26
+ });
27
+ });
28
+
29
+ test("leaves out a specifier read whole: a namespace import, an export star or a dynamic import", () => {
30
+ expect(scan('import*as s from"akanjs/store";import{isEmail}from"akanjs/common";')).not.toHaveProperty(
31
+ "akanjs/store",
32
+ );
33
+ expect(scan('export*from"akanjs/store";')).not.toHaveProperty("akanjs/store");
34
+ expect(scan('export*as s from"akanjs/store";')).not.toHaveProperty("akanjs/store");
35
+ expect(scan('const load=()=>import("akanjs/store");')).not.toHaveProperty("akanjs/store");
36
+ expect(scan('export{twMerge as m}from"tailwind-merge";')["tailwind-merge"]).toEqual(["twMerge"]);
37
+ });
38
+
39
+ test("reads nothing from a require, which a browser chunk carries as Bun's shim and never resolves", () => {
40
+ const used = scan('function C9(){let{untrackedHeaders:n,untrackedRequest:d}=ii("akanjs/fetch");return d}');
41
+
42
+ expect(used["akanjs/fetch"]).toEqual([]);
43
+ });
44
+
45
+ test("ignores a specifier quoted in a string, as a docs page shows code", () => {
46
+ const used = scan(
47
+ 'const sample="import { st } from \\"akanjs/store\\"";const tpl=`export * from "akanjs/store"`;console.info("akanjs/store");',
48
+ );
49
+
50
+ expect(used["akanjs/store"]).toEqual([]);
51
+ });
52
+
53
+ test("collects across every .js file of a directory", async () => {
54
+ const dir = await makeTempRoot();
55
+ await write(path.join(dir, "entry-a.js"), 'import{st}from"akanjs/store";');
56
+ await write(path.join(dir, "chunks/lazy.js"), 'import{RootStore}from"akanjs/store";');
57
+ await write(path.join(dir, "chunks/lazy.js.map"), 'import{ignored}from"akanjs/store";');
58
+
59
+ const used = await VendorImportScanner.exportsUsedIn(dir, ["akanjs/store"]);
60
+
61
+ expect(used).toEqual({ "akanjs/store": ["RootStore", "st"] });
62
+ });
63
+
64
+ test("reads a client output directory a build never wrote as importing nothing", async () => {
65
+ const dir = await makeTempRoot();
66
+
67
+ expect(await VendorImportScanner.exportsUsedIn(path.join(dir, "client"), ["akanjs/store"])).toEqual({
68
+ "akanjs/store": [],
69
+ });
70
+ });
71
+ });
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+
5
+ export class VendorImportScanner {
6
+ readonly #specifiers: Set<string>;
7
+ readonly #names = new Map<string, Set<string>>();
8
+ readonly #whole = new Set<string>();
9
+
10
+ constructor(specifiers: readonly string[]) {
11
+ this.#specifiers = new Set(specifiers);
12
+ for (const specifier of specifiers) this.#names.set(specifier, new Set());
13
+ }
14
+
15
+ /** The names each specifier's importers read across a directory's `.js` files; a specifier read whole is left out. */
16
+ static async exportsUsedIn(dir: string, specifiers: readonly string[]): Promise<Record<string, string[]>> {
17
+ const scanner = new VendorImportScanner(specifiers);
18
+ if (!fs.existsSync(dir)) return scanner.usedExports();
19
+ for await (const relative of new Bun.Glob("**/*.js").scan({ cwd: dir }))
20
+ scanner.scan(await Bun.file(path.join(dir, relative)).text(), relative);
21
+ return scanner.usedExports();
22
+ }
23
+
24
+ scan(source: string, fileName = "chunk.js"): this {
25
+ if (![...this.#specifiers].some((specifier) => source.includes(specifier))) return this;
26
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS);
27
+ const visit = (node: ts.Node): void => {
28
+ if (ts.isImportDeclaration(node)) this.#readImport(node);
29
+ else if (ts.isExportDeclaration(node)) this.#readExport(node);
30
+ else if (ts.isCallExpression(node)) this.#readCall(node);
31
+ ts.forEachChild(node, visit);
32
+ };
33
+ visit(file);
34
+ return this;
35
+ }
36
+
37
+ usedExports(): Record<string, string[]> {
38
+ return Object.fromEntries(
39
+ [...this.#names]
40
+ .filter(([specifier]) => !this.#whole.has(specifier))
41
+ .map(([specifier, names]) => [specifier, [...names].sort()]),
42
+ );
43
+ }
44
+
45
+ #specifierOf(node: ts.Expression | undefined): string | null {
46
+ if (!node || !ts.isStringLiteralLike(node)) return null;
47
+ return this.#specifiers.has(node.text) ? node.text : null;
48
+ }
49
+
50
+ #readImport(node: ts.ImportDeclaration): void {
51
+ const specifier = this.#specifierOf(node.moduleSpecifier);
52
+ const clause = node.importClause;
53
+ if (!specifier || !clause) return;
54
+ if (clause.name) this.#add(specifier, "default");
55
+ const bindings = clause.namedBindings;
56
+ if (!bindings) return;
57
+ if (ts.isNamespaceImport(bindings)) this.#whole.add(specifier);
58
+ else for (const element of bindings.elements) this.#add(specifier, (element.propertyName ?? element.name).text);
59
+ }
60
+
61
+ #readExport(node: ts.ExportDeclaration): void {
62
+ const specifier = this.#specifierOf(node.moduleSpecifier);
63
+ if (!specifier) return;
64
+ const clause = node.exportClause;
65
+ if (!clause || ts.isNamespaceExport(clause)) this.#whole.add(specifier);
66
+ else for (const element of clause.elements) this.#add(specifier, (element.propertyName ?? element.name).text);
67
+ }
68
+
69
+ //? Only `import()` reaches the import map: a `require` left in a browser chunk is Bun's shim, which throws there.
70
+ #readCall(node: ts.CallExpression): void {
71
+ if (node.expression.kind !== ts.SyntaxKind.ImportKeyword) return;
72
+ const specifier = this.#specifierOf(node.arguments[0]);
73
+ if (specifier) this.#whole.add(specifier);
74
+ }
75
+
76
+ #add(specifier: string, name: string): void {
77
+ if (!/^[A-Za-z_$][\w$]*$/.test(name)) this.#whole.add(specifier);
78
+ else this.#names.get(specifier)?.add(name);
79
+ }
80
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { tempDirs, writeText as write } from "../testHelpers";
5
+ import { ClientEntriesBundler } from "./clientEntriesBundler";
6
+ import { VendorNarrowing } from "./vendorNarrowing";
7
+
8
+ const makeTempRoot = tempDirs("akan-vendor-narrowing-");
9
+ const akanjsRoot = path.resolve(import.meta.dir, "../../../akanjs");
10
+ const vendorFile = (name: string) => path.join(akanjsRoot, "server/vendor", `${name}.ts`);
11
+ const agentOnlyText = "path must be an internal path starting with /.";
12
+
13
+ const staticClosure = (absPath: string, seen = new Set<string>()): Set<string> => {
14
+ if (seen.has(absPath)) return seen;
15
+ seen.add(absPath);
16
+ const transpiler = new Bun.Transpiler({ loader: "js" });
17
+ for (const { path: imported, kind } of transpiler.scanImports(readFileSync(absPath, "utf8")))
18
+ if (kind !== "dynamic-import" && imported.startsWith("."))
19
+ staticClosure(path.resolve(path.dirname(absPath), imported), seen);
20
+ return seen;
21
+ };
22
+ const closureText = (absPath: string) =>
23
+ [...staticClosure(absPath)].map((file) => readFileSync(file, "utf8")).join("\n");
24
+
25
+ const buildVendors = async (storeNames: string[], plugins: Awaited<ReturnType<VendorNarrowing["plugins"]>>) => {
26
+ const root = await makeTempRoot();
27
+ const storeAlias = path.join(root, "store.ts");
28
+ const agentAlias = path.join(root, "agent.ts");
29
+ await write(storeAlias, ClientEntriesBundler.narrowedAliasSource(vendorFile("akanjs-store"), storeNames));
30
+ await write(agentAlias, `export * from ${JSON.stringify(vendorFile("akanjs-store-agent"))};\n`);
31
+ const result = await Bun.build({
32
+ entrypoints: [storeAlias, agentAlias],
33
+ outdir: path.join(root, "out"),
34
+ splitting: true,
35
+ target: "browser",
36
+ format: "esm",
37
+ plugins,
38
+ });
39
+ expect(result.logs.map((log) => log.message)).toEqual([]);
40
+ const outputOf = (name: string) => {
41
+ const output = result.outputs.find((o) => o.kind === "entry-point" && path.basename(o.path) === `${name}.js`);
42
+ if (!output) throw new Error(`no ${name}.js output`);
43
+ return output.path;
44
+ };
45
+ return { store: closureText(outputOf("store")), agent: closureText(outputOf("agent")) };
46
+ };
47
+
48
+ describe("VendorNarrowing", () => {
49
+ test("maps each narrowed specifier's vendor entry to its names and leaves the rest whole", () => {
50
+ const narrowing = new VendorNarrowing(akanjsRoot, { "akanjs/store": ["st"], "akanjs/base": [] });
51
+
52
+ expect(
53
+ narrowing.entryExports([
54
+ { specifier: "akanjs/store", absPath: "/v/akanjs-store.ts" },
55
+ { specifier: "akanjs/base", absPath: "/v/akanjs-base.ts" },
56
+ { specifier: "akanjs/common", absPath: "/v/akanjs-common.ts" },
57
+ ]),
58
+ ).toEqual({ "/v/akanjs-store.ts": ["st"], "/v/akanjs-base.ts": [] });
59
+ });
60
+
61
+ test("keeps the deprecated agent re-export when a route imports one of its names, or reads the store whole", async () => {
62
+ expect(await new VendorNarrowing(akanjsRoot, { "akanjs/store": ["AgentContext", "st"] }).plugins()).toEqual([]);
63
+ expect(await new VendorNarrowing(akanjsRoot, {}).plugins()).toEqual([]);
64
+ });
65
+
66
+ test("a store vendor no route reads an agent name from loads no agent runtime; the agent vendor does", async () => {
67
+ const narrowing = new VendorNarrowing(akanjsRoot, { "akanjs/store": ["StoreRegistry", "store"] });
68
+ const plugins = await narrowing.plugins();
69
+ expect(plugins).toHaveLength(1);
70
+
71
+ const { store, agent } = await buildVendors(["StoreRegistry", "store"], plugins);
72
+
73
+ expect(store).not.toContain(agentOnlyText);
74
+ expect(agent).toContain(agentOnlyText);
75
+ }, 60_000);
76
+
77
+ test("without the blanking, the deprecated re-export drags the agent runtime into the store vendor", async () => {
78
+ const { store } = await buildVendors(["StoreRegistry", "store"], []);
79
+
80
+ expect(store).toContain(agentOnlyText);
81
+ }, 60_000);
82
+
83
+ test("an akanjs older than the agent split keeps whole vendors and gets no agent entry", async () => {
84
+ const root = await makeTempRoot();
85
+ for (const name of ["akanjs-store", "akanjs-base"])
86
+ await write(path.join(root, "server/vendor", `${name}.ts`), `export * from "${name}";\n`);
87
+ const entries = VendorNarrowing.entriesIn(path.join(root, "server"));
88
+ const narrowing = new VendorNarrowing(root, { "akanjs/store": ["st"], "akanjs/base": [] });
89
+
90
+ expect(entries.map(({ specifier }) => specifier)).toEqual(["akanjs/store", "akanjs/base"]);
91
+ expect(narrowing.entryExports(entries)).toEqual({});
92
+ expect(await narrowing.plugins()).toEqual([]);
93
+ expect(VendorNarrowing.entriesIn(path.join(akanjsRoot, "server")).map(({ specifier }) => specifier)).toContain(
94
+ "akanjs/store/agent",
95
+ );
96
+ });
97
+
98
+ test("an alias narrowed to no names still loads its entry, so the entry's side effects run", () => {
99
+ expect(ClientEntriesBundler.narrowedAliasSource("/v/akanjs-fetch.ts", [])).toBe(
100
+ `export {} from ${JSON.stringify(path.resolve("/v/akanjs-fetch.ts"))};\n`,
101
+ );
102
+ });
103
+ });
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { BunPlugin } from "bun";
4
+ import { VENDOR_SPECIFIERS, type VendorSpecifier } from "./vendorSpecifiers";
5
+
6
+ //? Bun puts a module in the chunk of every entry that reaches it, read or not, so a deprecated re-export would keep
7
+ //? its target in a vendor every page loads; a narrowed build blanks it unless a route imports one of its names.
8
+ const deprecatedReexports: Partial<Record<VendorSpecifier, string>> = { "akanjs/store": "store/deprecatedAgent.ts" };
9
+
10
+ export interface VendorEntry {
11
+ specifier: VendorSpecifier;
12
+ absPath: string;
13
+ }
14
+
15
+ export class VendorNarrowing {
16
+ readonly #akanjsRoot: string;
17
+ readonly #vendorExports: Partial<Record<VendorSpecifier, readonly string[]>>;
18
+
19
+ //? A devkit newer than the installed akanjs finds no agent vendor or deprecated re-export there, and builds that
20
+ //? akanjs's vendors whole, as the devkit it shipped with did.
21
+ static entriesIn(akanServerPath: string): VendorEntry[] {
22
+ return VENDOR_SPECIFIERS.map((specifier) => ({
23
+ specifier,
24
+ absPath: path.resolve(akanServerPath, "vendor", `${specifier.replaceAll("/", "-").replaceAll(".", "-")}.ts`),
25
+ })).filter(({ absPath }) => fs.existsSync(absPath));
26
+ }
27
+
28
+ constructor(akanjsRoot: string, vendorExports: Partial<Record<VendorSpecifier, readonly string[]>>) {
29
+ this.#akanjsRoot = akanjsRoot;
30
+ const current = Object.values(deprecatedReexports).every((file) => fs.existsSync(path.join(akanjsRoot, file)));
31
+ this.#vendorExports = current ? vendorExports : {};
32
+ }
33
+
34
+ entryExports(vendorEntries: VendorEntry[]): Record<string, readonly string[]> {
35
+ return Object.fromEntries(
36
+ vendorEntries.flatMap(({ specifier, absPath }) => {
37
+ const names = this.#vendorExports[specifier];
38
+ return names ? [[absPath, names]] : [];
39
+ }),
40
+ );
41
+ }
42
+
43
+ async plugins(): Promise<BunPlugin[]> {
44
+ const blanked: string[] = [];
45
+ for (const [specifier, file] of Object.entries(deprecatedReexports) as [VendorSpecifier, string][]) {
46
+ const used = this.#vendorExports[specifier];
47
+ if (!used) continue;
48
+ const source = await Bun.file(path.join(this.#akanjsRoot, file)).text();
49
+ const names = new Bun.Transpiler({ loader: "ts" }).scan(source).exports;
50
+ if (!names.some((name) => used.includes(name))) blanked.push(`akanjs/${file}`);
51
+ }
52
+ if (blanked.length === 0) return [];
53
+ const escaped = blanked.map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll("/", "[\\\\/]"));
54
+ const filter = new RegExp(`[\\\\/](?:${escaped.join("|")})$`);
55
+ return [
56
+ {
57
+ name: "akan-blank-deprecated-reexports",
58
+ setup(build) {
59
+ build.onLoad({ filter }, () => ({ contents: "export {};\n", loader: "ts" }));
60
+ },
61
+ },
62
+ ];
63
+ }
64
+ }
@@ -8,6 +8,7 @@ export const VENDOR_SPECIFIERS = [
8
8
  "scheduler",
9
9
  "react-server-dom-webpack/client.browser",
10
10
  "akanjs/store",
11
+ "akanjs/store/agent",
11
12
  "akanjs/base",
12
13
  "akanjs/common",
13
14
  "akanjs/constant",
@@ -16,3 +17,15 @@ export const VENDOR_SPECIFIERS = [
16
17
  ] as const;
17
18
 
18
19
  export type VendorSpecifier = (typeof VENDOR_SPECIFIERS)[number];
20
+
21
+ //? The vendor entries a build narrows to the names its client code imports: each is an ESM `export *`, while React's
22
+ //? entries list a CommonJS module's names by hand.
23
+ export const NARROWABLE_VENDOR_SPECIFIERS = [
24
+ "akanjs/store",
25
+ "akanjs/store/agent",
26
+ "akanjs/base",
27
+ "akanjs/common",
28
+ "akanjs/constant",
29
+ "akanjs/fetch",
30
+ "tailwind-merge",
31
+ ] as const satisfies readonly VendorSpecifier[];
@@ -0,0 +1,82 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { GitRevision } from "./gitRevision";
6
+
7
+ const roots: string[] = [];
8
+ const byName = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
9
+ afterEach(async () => {
10
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
11
+ });
12
+
13
+ const repoOf = async () => {
14
+ const root = await mkdtemp(path.join(tmpdir(), "akan-git-"));
15
+ roots.push(root);
16
+ const git = (...args: string[]) => {
17
+ const result = Bun.spawnSync(
18
+ ["git", "-c", "user.name=akan", "-c", "user.email=akan@example.com", "-c", "commit.gpgsign=false", ...args],
19
+ { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
20
+ );
21
+ expect(result.exitCode).toBe(0);
22
+ return result.stdout.toString().trim();
23
+ };
24
+ const write = async (file: string, text: string) => {
25
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true });
26
+ await writeFile(path.join(root, file), text);
27
+ };
28
+ git("init", "-q", "-b", "main");
29
+ return { root, git, write };
30
+ };
31
+
32
+ describe("GitRevision", () => {
33
+ it("lists each changed file with its change, a rename as a removal and an addition", async () => {
34
+ const { root, git, write } = await repoOf();
35
+ await write(".gitignore", "apps/*/scripts\n");
36
+ await write("apps/demo/lib/memo/memo.constant.ts", "a\n");
37
+ await write("apps/demo/lib/memo/memo.service.ts", "b\n");
38
+ await write("apps/demo/lib/memo/memo.store.ts", "c\n");
39
+ git("add", "-A");
40
+ git("commit", "-q", "--no-verify", "-m", "base");
41
+ const base = git("rev-parse", "HEAD");
42
+ await write("apps/demo/lib/memo/memo.constant.ts", "a2\n");
43
+ await unlink(path.join(root, "apps/demo/lib/memo/memo.service.ts"));
44
+ git("mv", "apps/demo/lib/memo/memo.store.ts", "apps/demo/lib/memo/memo2.store.ts");
45
+ await write("apps/demo/lib/memo/memo.signal.ts", "d\n");
46
+ await write("apps/demo/scripts/seed.ts", "e\n");
47
+ const git2 = new GitRevision(root);
48
+ expect(await git2.changedFilesSince(base)).toEqual([
49
+ { file: "apps/demo/lib/memo/memo.constant.ts", change: "changed" },
50
+ { file: "apps/demo/lib/memo/memo.service.ts", change: "removed" },
51
+ { file: "apps/demo/lib/memo/memo.signal.ts", change: "added" },
52
+ { file: "apps/demo/lib/memo/memo.store.ts", change: "removed" },
53
+ { file: "apps/demo/lib/memo/memo2.store.ts", change: "added" },
54
+ ]);
55
+ expect((await git2.workingFiles()).sort(byName)).toEqual([
56
+ "apps/demo/lib/memo/memo.constant.ts",
57
+ "apps/demo/lib/memo/memo.service.ts",
58
+ "apps/demo/lib/memo/memo.signal.ts",
59
+ "apps/demo/lib/memo/memo2.store.ts",
60
+ ]);
61
+ expect([...(await git2.filesAt(base, () => true)).keys()].sort(byName)).toEqual([
62
+ "apps/demo/lib/memo/memo.constant.ts",
63
+ "apps/demo/lib/memo/memo.service.ts",
64
+ "apps/demo/lib/memo/memo.store.ts",
65
+ ]);
66
+ });
67
+
68
+ it("says so when HEAD shares no commit with the revision, instead of comparing with its tip", async () => {
69
+ const { git, write, root } = await repoOf();
70
+ await write("apps/demo/a.ts", "a\n");
71
+ git("add", "-A");
72
+ git("commit", "-q", "--no-verify", "-m", "main");
73
+ git("checkout", "-q", "--orphan", "other");
74
+ await write("apps/demo/b.ts", "b\n");
75
+ git("add", "-A");
76
+ git("commit", "-q", "--no-verify", "-m", "other");
77
+ const revision = new GitRevision(root);
78
+ expect((await revision.forkPointOf("main")).error).toContain("shares no commit with main");
79
+ expect((await revision.forkPointOf(null)).error).toContain("shares no commit with main");
80
+ expect((await revision.forkPointOf("-x")).error).toBe("-x is not a commit in this repository.");
81
+ });
82
+ });
package/gitRevision.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { AkanChangedFile } from "@akanjs/language";
2
+
3
+ export type ForkPoint = { sha: string; error?: undefined } | { sha?: undefined; error: string };
4
+
5
+ const systemPaths = ["apps", "libs"];
6
+ const changeOfStatus: { [status: string]: AkanChangedFile["change"] | undefined } = { A: "added", D: "removed" };
7
+
8
+ //? Commands run in the workspace root, which may sit below the repository root; `./<path>` and `--relative` keep every
9
+ //? path git reads or prints relative to the workspace.
10
+ export class GitRevision {
11
+ static readonly #defaultBranches = ["upstream/HEAD", "upstream/main", "origin/HEAD", "origin/main", "main", "master"];
12
+ //? Studio and MCP read git beside the user's own commands; without this, `git diff` may take index.lock to refresh it.
13
+ static readonly #env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
14
+ readonly #root: string;
15
+
16
+ constructor(root: string) {
17
+ this.#root = root;
18
+ }
19
+
20
+ async forkPointOf(since: string | null): Promise<ForkPoint> {
21
+ if ((await this.#git(["rev-parse", "--is-inside-work-tree"]))?.trim() !== "true")
22
+ return { error: "The Change Map compares with git history, and this workspace is not in a git repository." };
23
+ if (since !== null) {
24
+ //? A leading `-` would reach git as an option.
25
+ const sha = /^[^-\s][^\s]*$/.test(since)
26
+ ? (await this.#git(["rev-parse", "--verify", "--quiet", `${since}^{commit}`]))?.trim()
27
+ : undefined;
28
+ if (!sha) return { error: `${since} is not a commit in this repository.` };
29
+ return await this.#mergeBase(since, sha);
30
+ }
31
+ for (const branch of GitRevision.#defaultBranches)
32
+ if ((await this.#git(["rev-parse", "--verify", "--quiet", `${branch}^{commit}`])) !== null)
33
+ return await this.#mergeBase(branch, branch);
34
+ const head = (await this.#git(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]))?.trim();
35
+ return head ? { sha: head } : { error: "The repository has no commit to compare with yet." };
36
+ }
37
+
38
+ async #mergeBase(name: string, revision: string): Promise<ForkPoint> {
39
+ const sha = (await this.#git(["merge-base", "HEAD", revision]))?.trim();
40
+ if (sha) return { sha };
41
+ return {
42
+ error: `HEAD shares no commit with ${name} here. In a shallow clone, \`git fetch --unshallow\` brings the history in.`,
43
+ };
44
+ }
45
+
46
+ async filesAt(
47
+ sha: string,
48
+ isWanted: (file: string) => boolean,
49
+ paths: string[] = systemPaths,
50
+ ): Promise<Map<string, string>> {
51
+ const listed = await this.#git(["ls-tree", "-r", "-z", "--name-only", sha, "--", ...paths]);
52
+ if (listed === null) throw new Error(`git ls-tree failed for ${sha}`);
53
+ const files = listed.split("\0").filter((file) => file && isWanted(file));
54
+ const texts = new Map<string, string>();
55
+ if (!files.length) return texts;
56
+ const proc = Bun.spawn(["git", "cat-file", "--batch"], {
57
+ cwd: this.#root,
58
+ env: GitRevision.#env,
59
+ stdin: "pipe",
60
+ stdout: "pipe",
61
+ stderr: "ignore",
62
+ });
63
+ const output = new Response(proc.stdout).arrayBuffer();
64
+ proc.stdin.write(files.map((file) => `${sha}:./${file}\n`).join(""));
65
+ await proc.stdin.end();
66
+ const bytes = new Uint8Array(await output);
67
+ if ((await proc.exited) !== 0) throw new Error(`git cat-file failed for ${sha}`);
68
+ const decoder = new TextDecoder();
69
+ let offset = 0;
70
+ for (const file of files) {
71
+ const lineEnd = bytes.indexOf(10, offset);
72
+ if (lineEnd < 0) throw new Error(`git cat-file ended before ${file} at ${sha}`);
73
+ const header = decoder.decode(bytes.subarray(offset, lineEnd));
74
+ offset = lineEnd + 1;
75
+ if (header.endsWith(" missing")) continue;
76
+ const [, type, size] = header.split(" ");
77
+ const length = Number(size);
78
+ if (type === "blob") texts.set(file, decoder.decode(bytes.subarray(offset, offset + length)));
79
+ offset += length + 1;
80
+ }
81
+ return texts;
82
+ }
83
+
84
+ async changedFilesSince(sha: string): Promise<AkanChangedFile[]> {
85
+ const [tracked, untracked] = await Promise.all([
86
+ this.#git(["diff", "--name-status", "--no-renames", "--relative", "-z", sha, "--", ...systemPaths]),
87
+ this.#git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...systemPaths]),
88
+ ]);
89
+ if (tracked === null || untracked === null) throw new Error(`git could not list the changes since ${sha}`);
90
+ const changes = new Map<string, AkanChangedFile["change"]>();
91
+ const fields = tracked.split("\0");
92
+ for (let idx = 0; idx + 1 < fields.length; idx += 2)
93
+ changes.set(fields[idx + 1], changeOfStatus[fields[idx]] ?? "changed");
94
+ for (const file of untracked.split("\0")) if (file) changes.set(file, "added");
95
+ return [...changes].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([file, change]) => ({ file, change }));
96
+ }
97
+
98
+ //? Tracked and untracked files, `.gitignore` applied, so the working side of a Change Map sees what the base side
99
+ //? can: a glob would read ignored files the base never has and mark them added.
100
+ async workingFiles(): Promise<string[]> {
101
+ const listed = await this.#git([
102
+ "ls-files",
103
+ "-z",
104
+ "--cached",
105
+ "--others",
106
+ "--exclude-standard",
107
+ "--",
108
+ ...systemPaths,
109
+ ]);
110
+ if (listed === null) throw new Error("git ls-files failed");
111
+ return [...new Set(listed.split("\0").filter(Boolean))];
112
+ }
113
+
114
+ async #git(args: string[]) {
115
+ try {
116
+ const proc = Bun.spawn(["git", ...args], {
117
+ cwd: this.#root,
118
+ env: GitRevision.#env,
119
+ stdout: "pipe",
120
+ stderr: "ignore",
121
+ });
122
+ const [text, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
123
+ return exitCode === 0 ? text : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,60 @@
1
+ import type { App } from "@akanjs/devkit/commandDecorators";
2
+ import { FrameworkGraph } from "@akanjs/devkit/frameworkGraph";
3
+ import { GitRevision } from "@akanjs/devkit/gitRevision";
4
+ import { WorkspaceGraph } from "@akanjs/devkit/workspaceGraph";
5
+ import type { BuildChangesPayload, BuilderGraphReq, BuildFrameworkPayload, BuildGraphPayload } from "akanjs/server";
6
+
7
+ export class BuilderGraphPayloads {
8
+ readonly #app: App;
9
+ readonly #generation: () => number;
10
+ #graph: { generation: number; payload: BuildGraphPayload } | null = null;
11
+ #changes: { key: string; payload: Promise<BuildChangesPayload> } | null = null;
12
+
13
+ constructor(app: App, generation: () => number) {
14
+ this.#app = app;
15
+ this.#generation = generation;
16
+ }
17
+
18
+ touched(files: string[]) {
19
+ FrameworkGraph.touched(this.#app.workspace.workspaceRoot, files);
20
+ }
21
+
22
+ async of(msg: BuilderGraphReq): Promise<BuildGraphPayload | BuildChangesPayload | BuildFrameworkPayload> {
23
+ if (msg.framework) return await this.#framework();
24
+ return msg.changes ? await this.#changesSince(msg.changes.since) : await this.#workspaceGraph();
25
+ }
26
+
27
+ async #workspaceGraph(): Promise<BuildGraphPayload> {
28
+ const generation = this.#generation();
29
+ if (this.#graph?.generation !== generation) {
30
+ const { workspace } = this.#app;
31
+ const graph = WorkspaceGraph.visibleTo(await WorkspaceGraph.build(workspace), this.#app.name);
32
+ this.#graph = { generation, payload: { workspaceRoot: workspace.workspaceRoot, graph } };
33
+ }
34
+ return this.#graph.payload;
35
+ }
36
+
37
+ async #framework(): Promise<BuildFrameworkPayload> {
38
+ const { workspaceRoot } = this.#app.workspace;
39
+ return { workspaceRoot, framework: await FrameworkGraph.cached(workspaceRoot) };
40
+ }
41
+
42
+ async #changesSince(since: string | null): Promise<BuildChangesPayload> {
43
+ const { workspace, name } = this.#app;
44
+ const { workspaceRoot } = workspace;
45
+ const forkPoint = await new GitRevision(workspaceRoot).forkPointOf(since);
46
+ if (forkPoint.error !== undefined) return { workspaceRoot, changes: { error: forkPoint.error } };
47
+ const key = `${this.#generation()}:${forkPoint.sha}`;
48
+ if (this.#changes?.key !== key) {
49
+ const payload = WorkspaceGraph.changesAt(workspace, forkPoint.sha).then((changes) => ({
50
+ workspaceRoot,
51
+ changes: WorkspaceGraph.changesVisibleTo(changes, name),
52
+ }));
53
+ payload.catch(() => {
54
+ if (this.#changes?.payload === payload) this.#changes = null;
55
+ });
56
+ this.#changes = { key, payload };
57
+ }
58
+ return await this.#changes.payload;
59
+ }
60
+ }