@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.2

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 (34) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/akanContext.ts +1 -3
  3. package/akanMcpContract.ts +4 -3
  4. package/artifact/storeBootstrap.ts +3 -2
  5. package/codeAgent/skills/akan-scaffold/SKILL.md +2 -1
  6. package/commandDecorators/command.ts +35 -18
  7. package/commandDecorators/commandDecorators.test.ts +75 -3
  8. package/commandDecorators/targetMeta.ts +2 -0
  9. package/constantFieldNames.test.ts +34 -0
  10. package/constantFieldNames.ts +18 -0
  11. package/frontendBuild/buildRouteClient.test.ts +43 -0
  12. package/frontendBuild/clientEntryDiscovery.test.ts +22 -0
  13. package/frontendBuild/clientEntryDiscovery.ts +28 -3
  14. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  15. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  16. package/frontendBuild/csrDevBundler.ts +12 -2
  17. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  18. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  19. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  20. package/frontendBuild/csrDevPatcher.ts +47 -7
  21. package/frontendBuild/csrDevTypes.ts +16 -0
  22. package/frontendBuild/cssCompiler.test.ts +63 -1
  23. package/frontendBuild/devBarrelImports.ts +132 -0
  24. package/frontendBuild/devRegistryBundler.ts +19 -4
  25. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  26. package/frontendBuild/ssrDevBundler.ts +13 -2
  27. package/package.json +3 -3
  28. package/transforms/barrelAnalyzer.ts +46 -1
  29. package/transforms/barrelImportsPlugin.ts +61 -45
  30. package/transforms/transforms.test.ts +67 -1
  31. package/workflow/artifacts.ts +17 -0
  32. package/workflow/plan.ts +54 -14
  33. package/workspaceGraph.test.ts +116 -3
  34. package/workspaceGraph.ts +6 -3
@@ -1,11 +1,24 @@
1
1
  import type { PageEntry } from "../artifact/implicitRootLayout";
2
2
 
3
+ export interface CsrDevNarrowedImport {
4
+ names: string[];
5
+ hash: string;
6
+ }
7
+
8
+ export interface CsrDevBarrelStamp {
9
+ /** The mtime of each file and directory the barrel's analysis read, by id. */
10
+ read: Record<string, number>;
11
+ /** Each export name with the file it resolves to, hashed: what every importer's narrowing reads. */
12
+ digest: string;
13
+ }
14
+
3
15
  export interface CsrDevGraphModule {
4
16
  vendor: boolean;
5
17
  mtimeMs: number;
6
18
  hash: string;
7
19
  deps: string[];
8
20
  helpers?: string;
21
+ narrowed?: Record<string, CsrDevNarrowedImport>;
9
22
  }
10
23
 
11
24
  export interface CsrDevGraph {
@@ -22,6 +35,7 @@ export interface CsrDevGraph {
22
35
  failed?: { roots: string[]; files: Record<string, string> };
23
36
  /** The stamp of the signal and dictionary sources the last whole build inlined; see `RegistryMetadataFingerprint`. */
24
37
  metadata?: string;
38
+ barrels?: Record<string, CsrDevBarrelStamp>;
25
39
  }
26
40
 
27
41
  export interface CsrDevContext {
@@ -35,6 +49,7 @@ export interface CsrDevContext {
35
49
  refreshFile: string | null;
36
50
  /** Bare specifiers the page already loads (an SSR page's import map): required as `vendor:<specifier>`, not compiled. */
37
51
  externals?: readonly string[];
52
+ barrelImports?: readonly string[];
38
53
  }
39
54
 
40
55
  export interface CsrDevSharedHelpers {
@@ -52,6 +67,7 @@ export interface CsrDevCompiledModule {
52
67
  deps: string[];
53
68
  mtimeMs: number;
54
69
  hash: string;
70
+ narrowed?: Record<string, CsrDevNarrowedImport>;
55
71
  }
56
72
 
57
73
  /** Module factories by id and helper definitions by hash, kept in memory so app.js is written without a pass over the disk. */
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { declaredCustomProperties } from "./cssCompiler";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText as write } from "../testHelpers";
4
+ import { CssCompiler, declaredCustomProperties } from "./cssCompiler";
5
+
6
+ const makeTempRoot = tempDirs("akan-css-compiler-");
3
7
 
4
8
  describe("declaredCustomProperties", () => {
5
9
  test("names what the regex-based version named", () => {
@@ -51,3 +55,61 @@ describe("declaredCustomProperties", () => {
51
55
  }
52
56
  });
53
57
  });
58
+
59
+ describe("CssCompiler.discoverCssAndSources", () => {
60
+ test("scans the files a page reaches through a client barrel's narrowed imports, never the barrel's other modules", async () => {
61
+ const root = await makeTempRoot();
62
+ const paths = { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] };
63
+ const writeSys = async (dir: string, modules: string[]) => {
64
+ await write(
65
+ path.join(dir, "client.ts"),
66
+ [
67
+ 'export * as cnst from "./lib/cnst";',
68
+ 'export { msg, usePage } from "./lib/useClient";',
69
+ 'export { st, RootStore } from "./lib/st";',
70
+ ...modules.map((name) => `export { ${name} } from "./lib/${name.toLowerCase()}";`),
71
+ "",
72
+ ].join("\n"),
73
+ );
74
+ await write(path.join(dir, "lib/cnst.ts"), "export const Sample = 1;\n");
75
+ await write(path.join(dir, "lib/useClient.ts"), "export const msg = 1;\nexport const usePage = () => msg;\n");
76
+ for (const name of modules) {
77
+ await write(
78
+ path.join(dir, `lib/${name.toLowerCase()}/index.ts`),
79
+ `import * as Zone from "./${name}.Zone";\nexport const ${name} = { Zone };\n`,
80
+ );
81
+ await write(path.join(dir, `lib/${name.toLowerCase()}/${name}.Zone.tsx`), 'export const Card = () => "p-4";\n');
82
+ }
83
+ };
84
+ const appDir = path.join(root, "apps/demo");
85
+ await writeSys(appDir, ["Memo", "Other"]);
86
+ await writeSys(path.join(root, "libs/kit"), ["Widget"]);
87
+ await write(path.join(root, "libs/kit/lib/st.ts"), "export class RootStore {}\nexport const st = {};\n");
88
+ await write(
89
+ path.join(appDir, "lib/st.ts"),
90
+ 'import { RootStore as kit } from "@libs/kit/client";\nexport class RootStore {}\nexport const st = { kit };\n',
91
+ );
92
+ await write(
93
+ path.join(appDir, "page/_index.tsx"),
94
+ 'import { Memo, st } from "@apps/demo/client";\nexport default function Page() { return [Memo, st]; }\n',
95
+ );
96
+ const app = {
97
+ cwdPath: appDir,
98
+ workspace: { workspaceRoot: root },
99
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
100
+ getConfig: async () => ({ barrelImports: ["@apps/demo/client", "@libs/kit/client"] }),
101
+ };
102
+
103
+ const { sourcePaths } = await new CssCompiler(app as never).discoverCssAndSources({ pageKeys: ["_index.tsx"] });
104
+
105
+ expect(sourcePaths.map((file) => path.relative(root, file)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
106
+ "apps/demo/lib/memo/Memo.Zone.tsx",
107
+ "apps/demo/lib/memo/index.ts",
108
+ "apps/demo/lib/st.ts",
109
+ "apps/demo/lib/useClient.ts",
110
+ "apps/demo/page/_index.tsx",
111
+ "libs/kit/lib/st.ts",
112
+ "libs/kit/lib/useClient.ts",
113
+ ]);
114
+ });
115
+ });
@@ -0,0 +1,132 @@
1
+ import path from "node:path";
2
+ import type { App } from "../commandDecorators";
3
+ import { BarrelAnalyzer, type PackageEntry } from "../transforms/barrelAnalyzer";
4
+ import { createTsconfigPackageResolver, rewriteBarrelImports } from "../transforms/barrelImportsPlugin";
5
+ import { CsrDevPaths } from "./csrDevPaths";
6
+ import type { CsrDevBarrelStamp, CsrDevNarrowedImport } from "./csrDevTypes";
7
+
8
+ export interface DevBarrelRewrite {
9
+ contents: string | null;
10
+ narrowed: Record<string, CsrDevNarrowedImport>;
11
+ }
12
+
13
+ //* Targets are written as relative paths, which resolve against the disk with no resolution build: a save that adds a
14
+ //* barrel import stays a patch.
15
+ export class DevBarrelImports {
16
+ readonly #barrels: string[];
17
+ readonly #resolvePackage: (pkgName: string) => Promise<PackageEntry | null>;
18
+ readonly #analyzer: BarrelAnalyzer;
19
+ readonly #packages = new Map<string, Promise<PackageEntry | null>>();
20
+
21
+ constructor(barrels: readonly string[], resolvePackage: (pkgName: string) => Promise<PackageEntry | null>) {
22
+ this.#barrels = [...barrels];
23
+ this.#resolvePackage = resolvePackage;
24
+ this.#analyzer = new BarrelAnalyzer({ resolvePackage });
25
+ }
26
+
27
+ static async create(app: App, barrels: readonly string[]): Promise<DevBarrelImports | null> {
28
+ if (barrels.length === 0) return null;
29
+ return new DevBarrelImports(barrels, await createTsconfigPackageResolver(app));
30
+ }
31
+
32
+ async rewrite(source: string, importer: string): Promise<DevBarrelRewrite | null> {
33
+ const named = this.#barrels.filter((barrel) => source.includes(barrel));
34
+ if (named.length === 0) return null;
35
+ const packages = await this.#packagesOf(named);
36
+ const from = path.dirname(CsrDevPaths.realpath(importer));
37
+ const names = new Map<string, Set<string>>();
38
+ const contents = await rewriteBarrelImports(source, named, this.#analyzer, {
39
+ keepLines: true,
40
+ specifierOf: (subpath) => DevBarrelImports.#relative(subpath, from, packages),
41
+ onImport: (barrel, imported) => names.set(barrel, new Set([...(names.get(barrel) ?? []), ...imported])),
42
+ });
43
+ const narrowed = await Promise.all(
44
+ [...names].map(async ([barrel, set]) => {
45
+ const sorted = [...set].sort();
46
+ return [barrel, { names: sorted, hash: await this.fingerprint(barrel, sorted) }] as const;
47
+ }),
48
+ );
49
+ return { contents, narrowed: Object.fromEntries(narrowed) };
50
+ }
51
+
52
+ //? Hashes what an import of these names alone narrows to, with each target as the file it resolves to: the same
53
+ //? names in another importer narrow to the same files, and only a moved export or a moved file changes it.
54
+ async fingerprint(barrel: string, names: string[]): Promise<string> {
55
+ const packages = await this.#packagesOf([barrel]);
56
+ const aliased = names.map((name, index) => `${name} as _${index}`).join(", ");
57
+ const narrowed = await rewriteBarrelImports(
58
+ `import { ${aliased} } from ${JSON.stringify(barrel)};`,
59
+ [barrel],
60
+ this.#analyzer,
61
+ { specifierOf: (subpath) => DevBarrelImports.#fileOf(subpath, packages) ?? subpath },
62
+ );
63
+ return Bun.hash(narrowed ?? "").toString(36);
64
+ }
65
+
66
+ async stamps(barrels: Iterable<string>, idOf: (file: string) => string): Promise<Record<string, CsrDevBarrelStamp>> {
67
+ const stamped = await Promise.all(
68
+ [...barrels].map(async (barrel) => {
69
+ const files = await this.#analyzer.filesOf(barrel);
70
+ const read = [...new Set([...files, ...files.map((file) => path.dirname(file))])];
71
+ const stamp: CsrDevBarrelStamp = {
72
+ read: Object.fromEntries(read.map((file) => [idOf(file), CsrDevPaths.mtimeOf(file)])),
73
+ digest: await this.#digest(barrel),
74
+ };
75
+ return [barrel, stamp] as const;
76
+ }),
77
+ );
78
+ return Object.fromEntries(stamped);
79
+ }
80
+
81
+ //? Every input of an importer's fingerprint: an importer is hashed again only once this moves, so a save that
82
+ //? changes a component's body costs one analysis of its barrel, whatever the number of importers.
83
+ async #digest(barrel: string): Promise<string> {
84
+ const map = await this.#analyzer.analyze(barrel);
85
+ const packages = await this.#packagesOf([barrel]);
86
+ const files = new Map<string, string>();
87
+ const fileOf = (subpath: string) => {
88
+ let file = files.get(subpath);
89
+ if (file === undefined) {
90
+ file = DevBarrelImports.#fileOf(subpath, packages) ?? subpath;
91
+ files.set(subpath, file);
92
+ }
93
+ return file;
94
+ };
95
+ const exports = [...(map ?? [])]
96
+ .map(([name, target]) => [name, target.originalName, !!target.namespace, fileOf(target.subpath)] as const)
97
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
98
+ return Bun.hash(JSON.stringify([fileOf(barrel), map === null, exports])).toString(36);
99
+ }
100
+
101
+ async #packagesOf(barrels: string[]): Promise<PackageEntry[]> {
102
+ return (await Promise.all(barrels.map((barrel) => this.#packageOf(barrel))))
103
+ .filter((pkg): pkg is PackageEntry => pkg !== null)
104
+ .sort((a, b) => b.pkgName.length - a.pkgName.length);
105
+ }
106
+
107
+ #packageOf(barrel: string): Promise<PackageEntry | null> {
108
+ let pkg = this.#packages.get(barrel);
109
+ if (!pkg) {
110
+ pkg = this.#resolvePackage(barrel);
111
+ this.#packages.set(barrel, pkg);
112
+ }
113
+ return pkg;
114
+ }
115
+
116
+ static #fileOf(subpath: string, packages: PackageEntry[]): string | null {
117
+ const pkg = packages.find(({ pkgName }) => subpath === pkgName || subpath.startsWith(`${pkgName}/`));
118
+ if (!pkg) return null;
119
+ const file = CsrDevPaths.resolveOnDisk(path.join(pkg.pkgDir, subpath.slice(pkg.pkgName.length)));
120
+ return file ? CsrDevPaths.realpath(file) : null;
121
+ }
122
+
123
+ static #relative(subpath: string, from: string, packages: PackageEntry[]): string {
124
+ const file = DevBarrelImports.#fileOf(subpath, packages);
125
+ if (!file) return subpath;
126
+ const relative = path.relative(from, file);
127
+ //? A target on another Windows drive has no relative path: `path.relative` returns it absolute.
128
+ if (path.isAbsolute(relative)) return file.split(path.sep).join("/");
129
+ const posix = relative.split(path.sep).join("/");
130
+ return posix.startsWith("../") ? posix : `./${posix}`;
131
+ }
132
+ }
@@ -7,7 +7,8 @@ import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
7
7
  import { CsrDevPatcher } from "./csrDevPatcher";
8
8
  import { CsrDevPaths } from "./csrDevPaths";
9
9
  import { CsrDevResolver } from "./csrDevResolver";
10
- import type { CsrDevCompiledModule, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
10
+ import type { CsrDevBarrelStamp, CsrDevCompiledModule, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
11
+ import { DevBarrelImports } from "./devBarrelImports";
11
12
  import { RegistryMetadataFingerprint } from "./registryMetadataFingerprint";
12
13
  import { WatchRootResolver } from "./watchRootResolver";
13
14
 
@@ -131,7 +132,7 @@ export abstract class DevRegistryBundler {
131
132
  const resolver = new CsrDevResolver({ paths: this.paths, context, entryFiles: roots });
132
133
  await resolver.prepass();
133
134
  const compiler = this.compiler(context, resolver);
134
- const { modules: compiled } = await compiler.compile(
135
+ const { modules: compiled, barrels } = await compiler.compile(
135
136
  [...roots, ...(context.refreshFile ? [context.refreshFile] : [])],
136
137
  new Set(),
137
138
  );
@@ -147,7 +148,7 @@ export abstract class DevRegistryBundler {
147
148
  pending: [],
148
149
  metadata,
149
150
  };
150
- this.merge(graph, resolver, compiled);
151
+ this.merge(graph, resolver, compiled, barrels);
151
152
  await this.writer.markBuilding();
152
153
  await this.writer.writeModules(compiled);
153
154
  const vendorFile = await this.writer.writeVendor(graph);
@@ -170,7 +171,16 @@ export abstract class DevRegistryBundler {
170
171
  });
171
172
  }
172
173
 
173
- merge(graph: CsrDevGraph, resolver: CsrDevResolver, compiled: CsrDevCompiledModule[]): void {
174
+ async barrelImports(context: CsrDevContext): Promise<DevBarrelImports | null> {
175
+ return await DevBarrelImports.create(this.app, context.barrelImports ?? []);
176
+ }
177
+
178
+ merge(
179
+ graph: CsrDevGraph,
180
+ resolver: CsrDevResolver,
181
+ compiled: CsrDevCompiledModule[],
182
+ barrels: Record<string, CsrDevBarrelStamp> = {},
183
+ ): void {
174
184
  for (const module of compiled)
175
185
  graph.modules[module.id] = {
176
186
  vendor: module.vendor,
@@ -178,7 +188,12 @@ export abstract class DevRegistryBundler {
178
188
  hash: module.hash,
179
189
  deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : this.paths.idOf(dep))),
180
190
  ...(module.helpers ? { helpers: module.helpers.hash } : {}),
191
+ ...(module.narrowed ? { narrowed: module.narrowed } : {}),
181
192
  };
193
+ const narrowing = new Set(Object.values(graph.modules).flatMap((module) => Object.keys(module.narrowed ?? {})));
194
+ graph.barrels = Object.fromEntries(
195
+ Object.entries({ ...graph.barrels, ...barrels }).filter(([barrel]) => narrowing.has(barrel)),
196
+ );
182
197
  //? Only what this round compiled leaves pending: a root another route's check failed on stays for its retry.
183
198
  const compiledIds = new Set(compiled.map((module) => module.id));
184
199
  graph.pending = graph.pending.filter((id) => !compiledIds.has(id));
@@ -177,6 +177,7 @@ describe("McpAppViewBuilder.build", () => {
177
177
  dist: { cwdPath: file("dist/apps/demo") },
178
178
  workspace: { workspaceRoot: root },
179
179
  getTsConfig: async () => ({ compilerOptions: {} }),
180
+ getConfig: async () => ({ barrelImports: [] }),
180
181
  getPublicEnv: () => ({}),
181
182
  logger: { warn: () => undefined },
182
183
  };
@@ -195,6 +196,72 @@ describe("McpAppViewBuilder.build", () => {
195
196
  });
196
197
  });
197
198
 
199
+ describe("McpAppViewBuilder.build with an app barrel", () => {
200
+ test("narrows the app's client barrel to the module the trimmed page names, never another module's UI", async () => {
201
+ const root = await makeTempRoot();
202
+ const file = (rel: string) => path.join(root, rel);
203
+ const paths = { "@apps/*": ["./apps/*"] };
204
+ await write(file("tsconfig.json"), JSON.stringify({ compilerOptions: { baseUrl: ".", paths } }));
205
+ await write(
206
+ file("node_modules/akanjs/package.json"),
207
+ JSON.stringify({ name: "akanjs", exports: { "./webkit": "./webkit.ts" } }),
208
+ );
209
+ await write(
210
+ file("node_modules/akanjs/webkit.ts"),
211
+ "export const McpAppBoot = { run: (view: unknown) => console.info(view) };\n",
212
+ );
213
+ await write(
214
+ file("apps/demo/client.ts"),
215
+ [
216
+ 'export { usePage } from "./lib/useClient";',
217
+ 'export { st } from "./lib/st";',
218
+ 'export { Memo } from "./lib/memo";',
219
+ 'export { Report } from "./lib/report";',
220
+ "",
221
+ ].join("\n"),
222
+ );
223
+ await write(
224
+ file("apps/demo/lib/useClient.ts"),
225
+ 'console.info("the client runtime loaded");\nexport const usePage = () => null;\n',
226
+ );
227
+ await write(file("apps/demo/lib/st.ts"), 'console.info("the store loaded");\nexport const st = null;\n');
228
+ await write(file("apps/demo/lib/memo/index.ts"), 'export const Memo = "a memo card";\n');
229
+ await write(
230
+ file("apps/demo/lib/report/index.ts"),
231
+ 'console.info("the report module loaded");\nexport const Report = "a report card";\n',
232
+ );
233
+ await write(
234
+ file("apps/demo/page/home.tsx"),
235
+ [
236
+ 'import { Memo, Report } from "@apps/demo/client";',
237
+ "const page = () => ({ app: (name: string, description: string, draw: () => unknown) => ({ render: (render: () => unknown) => [name, description, draw, render] }) });",
238
+ 'export default page().app("showHome", "Show home.", () => Memo).render(() => Report);',
239
+ ].join("\n"),
240
+ );
241
+ const app = {
242
+ cwdPath: file("apps/demo"),
243
+ dist: { cwdPath: file("dist/apps/demo") },
244
+ workspace: { workspaceRoot: root },
245
+ getTsConfig: async () => ({ compilerOptions: { paths } }),
246
+ getConfig: async () => ({ barrelImports: ["@apps/demo/client"] }),
247
+ getPublicEnv: () => ({}),
248
+ logger: { warn: () => undefined },
249
+ };
250
+
251
+ await new McpAppViewBuilder(app as never, "build").build(
252
+ [{ key: "./home.tsx", moduleAbsPath: file("apps/demo/page/home.tsx") }],
253
+ { basePaths: [], cssAssets: {}, options: CsrArtifactBuilder.bundleOptions(app as never, "build") },
254
+ );
255
+
256
+ const view = await Bun.file(file("dist/apps/demo/mcp-apps/showHome.html")).text();
257
+ expect(view).toContain("a memo card");
258
+ expect(view).toContain("the client runtime loaded");
259
+ expect(view).toContain("the store loaded");
260
+ expect(view).not.toContain("a report card");
261
+ expect(view).not.toContain("the report module loaded");
262
+ });
263
+ });
264
+
198
265
  describe("McpAppViewBuilder.entryOf", () => {
199
266
  test("boots the one app with its page, the root layout and overrides it sits under, by relative path", () => {
200
267
  const view = {
@@ -5,6 +5,7 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import { computeRouteSeedIndex } from "../artifact/routeSeedIndex";
6
6
  import { StoreBootstrap } from "../artifact/storeBootstrap";
7
7
  import type { App } from "../commandDecorators";
8
+ import { workspaceBarrels } from "../transforms/barrelImportsPlugin";
8
9
  import { bundleDefine } from "./bundleDefine";
9
10
  import type { ClientEntryDiscovery } from "./clientBuildTypes";
10
11
  import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
@@ -17,7 +18,7 @@ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
17
18
  //* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
18
19
  //* so the page keeps one React and one store registry.
19
20
  export class SsrDevBundler extends DevRegistryBundler {
20
- static readonly #formatVersion = 2;
21
+ static readonly #formatVersion = 4;
21
22
  static readonly bootstrapKey = "";
22
23
  readonly reloadsOnEntryChange = false;
23
24
  #discovery: ClientEntryDiscovery | null = null;
@@ -33,8 +34,17 @@ export class SsrDevBundler extends DevRegistryBundler {
33
34
  const akanConfig = await this.app.getConfig();
34
35
  const define = bundleDefine(this.app, "start", "ssr");
35
36
  const optimizeImports = [...akanConfig.optimizeImports];
37
+ const barrelImports = workspaceBarrels(akanConfig.barrelImports);
36
38
  const configKey = Bun.hash(
37
- JSON.stringify(["ssr", SsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, VENDOR_SPECIFIERS]),
39
+ JSON.stringify([
40
+ "ssr",
41
+ SsrDevBundler.#formatVersion,
42
+ Bun.version,
43
+ define,
44
+ optimizeImports,
45
+ VENDOR_SPECIFIERS,
46
+ barrelImports,
47
+ ]),
38
48
  ).toString(36);
39
49
  return {
40
50
  pageEntries: [],
@@ -45,6 +55,7 @@ export class SsrDevBundler extends DevRegistryBundler {
45
55
  configKey,
46
56
  refreshFile: null,
47
57
  externals: VENDOR_SPECIFIERS,
58
+ barrelImports,
48
59
  };
49
60
  }
50
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.2.0-rc.1",
3
+ "version": "3.2.0-rc.2",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -41,7 +41,7 @@
41
41
  "./*": "./*.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@akanjs/language": "3.2.0-rc.1",
44
+ "@akanjs/language": "3.2.0-rc.2",
45
45
  "@earendil-works/pi-agent-core": "1.3.0",
46
46
  "@earendil-works/pi-ai": "1.3.0",
47
47
  "@earendil-works/pi-codemode": "1.3.0",
@@ -50,7 +50,7 @@
50
50
  "@earendil-works/pi-tui": "1.3.0",
51
51
  "@inquirer/prompts": "^8.7.3",
52
52
  "@tailwindcss/node": "^4.3.3",
53
- "akanjs": "3.2.0-rc.1",
53
+ "akanjs": "3.2.0-rc.2",
54
54
  "chalk": "^6.0.1",
55
55
  "commander": "^15.0.0",
56
56
  "dayjs": "^1.11.23",
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { Logger } from "akanjs/common";
3
4
  import { parseNamedList } from "./moduleSyntax";
@@ -41,6 +42,8 @@ export class BarrelAnalyzer {
41
42
  readonly #logger = new Logger("BarrelAnalyzer");
42
43
  readonly #opts: BarrelAnalyzerOptions;
43
44
  readonly #cache = new Map<string, Promise<BarrelExportMap | null>>();
45
+ readonly #read = new Map<string, Set<string>>();
46
+ readonly #realDirs = new Map<string, string>();
44
47
  readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
45
48
  readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
46
49
 
@@ -56,12 +59,44 @@ export class BarrelAnalyzer {
56
59
  return promise;
57
60
  }
58
61
 
62
+ async filesOf(pkgName: string): Promise<string[]> {
63
+ await this.analyze(pkgName);
64
+ return [...(this.#read.get(pkgName) ?? [])];
65
+ }
66
+
67
+ //? A file beside one the analysis read counts too: created, it can take over a star export's specifier (`Foo.tsx`
68
+ //? beside `Foo/index.tsx`) with no read file changed. Both sides by realpath: a watcher names a symlinked lib's
69
+ //? files by their real path, where the walk went through the link.
70
+ forget(files: string[]): Map<string, Promise<BarrelExportMap | null>> {
71
+ const dirs = new Set(files.map((file) => realDirOf(file)));
72
+ const forgotten = new Map<string, Promise<BarrelExportMap | null>>();
73
+ for (const [pkgName, read] of this.#read) {
74
+ const analysis = this.#cache.get(pkgName);
75
+ if (!analysis || ![...read].some((file) => dirs.has(this.#realDirOf(file)))) continue;
76
+ forgotten.set(pkgName, analysis);
77
+ this.#cache.delete(pkgName);
78
+ this.#read.delete(pkgName);
79
+ }
80
+ return forgotten;
81
+ }
82
+
83
+ #realDirOf(file: string): string {
84
+ let dir = this.#realDirs.get(file);
85
+ if (dir === undefined) {
86
+ dir = realDirOf(file);
87
+ this.#realDirs.set(file, dir);
88
+ }
89
+ return dir;
90
+ }
91
+
59
92
  async #analyzeSafe(pkgName: string): Promise<BarrelExportMap | null> {
60
93
  try {
61
94
  const pkg = await this.#opts.resolvePackage(pkgName);
62
95
  if (!pkg) return null;
63
96
  const map: BarrelExportMap = new Map();
64
- await this.#walk(pkg.entryFile, pkg, map, new Set());
97
+ const read = new Set<string>();
98
+ this.#read.set(pkgName, read);
99
+ await this.#walk(pkg.entryFile, pkg, map, read);
65
100
  return map;
66
101
  } catch (err) {
67
102
  this.#logger.error(`analyze failed for ${pkgName}: ${(err as Error).message}`);
@@ -170,6 +205,16 @@ export class BarrelAnalyzer {
170
205
  }
171
206
  }
172
207
 
208
+ const realDirOf = (file: string): string => {
209
+ const dir = path.dirname(path.resolve(file));
210
+ try {
211
+ return fs.realpathSync.native(dir);
212
+ } catch {
213
+ // A directory removed with the file keeps its own path.
214
+ return dir;
215
+ }
216
+ };
217
+
173
218
  const isRelative = (spec: string): boolean => {
174
219
  return spec.startsWith("./") || spec.startsWith("../") || spec === "." || spec === "..";
175
220
  };