@akanjs/devkit 3.2.0-rc.0 → 3.2.0-rc.10

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 (110) hide show
  1. package/CHANGELOG.md +238 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/createTunnel.test.ts +12 -2
  44. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  45. package/errorCodeReference.test.ts +282 -0
  46. package/errorCodeReference.ts +71 -0
  47. package/errorCodeScan.ts +551 -0
  48. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  49. package/frontendBuild/allRoutesBuilder.ts +24 -6
  50. package/frontendBuild/buildRouteClient.test.ts +43 -0
  51. package/frontendBuild/bundleDefine.ts +2 -0
  52. package/frontendBuild/clientBuildTypes.ts +5 -2
  53. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  54. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  55. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  56. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  57. package/frontendBuild/csrDevBundler.ts +12 -2
  58. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  59. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  60. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  61. package/frontendBuild/csrDevPatcher.ts +47 -7
  62. package/frontendBuild/csrDevTypes.ts +16 -0
  63. package/frontendBuild/cssCompiler.test.ts +63 -1
  64. package/frontendBuild/devBarrelImports.ts +132 -0
  65. package/frontendBuild/devRegistryBundler.ts +19 -4
  66. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  67. package/frontendBuild/nativeCore.test.ts +148 -0
  68. package/frontendBuild/precompressArtifacts.ts +1 -1
  69. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  70. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  71. package/frontendBuild/ssrDevBundler.ts +13 -2
  72. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  73. package/frontendBuild/vendorImportScanner.ts +25 -10
  74. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  75. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  76. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  77. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  78. package/lint/no-import-common-err.grit +31 -0
  79. package/lint/no-init-fetch-in-client.grit +1 -1
  80. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  81. package/maintainabilityBaseline.test.ts +49 -0
  82. package/maintainabilityBaseline.ts +73 -8
  83. package/maintainabilityContract.test.ts +302 -1
  84. package/maintainabilityContract.ts +87 -22
  85. package/maintainabilityExceptionScanner.test.ts +14 -0
  86. package/maintainabilityExceptionScanner.ts +5 -1
  87. package/maintainabilityLimits.ts +12 -0
  88. package/maintainabilityReport.ts +20 -3
  89. package/moduleFieldNames.test.ts +56 -0
  90. package/moduleFieldNames.ts +25 -0
  91. package/package.json +3 -3
  92. package/preview/PreviewChrome.ts +3 -3
  93. package/preview/PreviewView.ts +48 -9
  94. package/preview/previewView.test.ts +154 -3
  95. package/routeJsBudget.test.ts +47 -11
  96. package/routeJsBudget.ts +88 -25
  97. package/transforms/barrelAnalyzer.ts +46 -1
  98. package/transforms/barrelImportsPlugin.ts +61 -45
  99. package/transforms/transforms.test.ts +67 -1
  100. package/typecheck/projectTypecheck.ts +5 -1
  101. package/typecheck/typecheck.proc.ts +3 -2
  102. package/typecheck/typecheckFailure.ts +6 -1
  103. package/workflow/artifacts.ts +25 -7
  104. package/workflow/fieldType.ts +92 -0
  105. package/workflow/index.ts +2 -0
  106. package/workflow/plainName.ts +28 -0
  107. package/workflow/plan.ts +67 -22
  108. package/workflow/source.ts +0 -5
  109. package/workspaceGraph.test.ts +200 -3
  110. package/workspaceGraph.ts +46 -8
@@ -1,10 +1,18 @@
1
1
  import path from "node:path";
2
2
  import type { BunPlugin } from "bun";
3
3
  import type { App } from "../commandDecorators";
4
+ import { loaderFor } from "../transforms/moduleSyntax";
4
5
  import { CsrDevFactoryCheck } from "./csrDevFactoryCheck";
5
6
  import { CsrDevPaths } from "./csrDevPaths";
6
7
  import { CsrDevResolver } from "./csrDevResolver";
7
- import type { CsrDevCompiledModule, CsrDevContext, CsrDevSharedHelpers } from "./csrDevTypes";
8
+ import type {
9
+ CsrDevBarrelStamp,
10
+ CsrDevCompiledModule,
11
+ CsrDevContext,
12
+ CsrDevNarrowedImport,
13
+ CsrDevSharedHelpers,
14
+ } from "./csrDevTypes";
15
+ import { DevBarrelImports } from "./devBarrelImports";
8
16
  import { RouteClientBuilder } from "./routeClientBuilder";
9
17
 
10
18
  interface OutputMetafile {
@@ -38,6 +46,7 @@ export interface CsrDevCompileResult {
38
46
  modules: CsrDevCompiledModule[];
39
47
  refusedVendors: string[];
40
48
  unresolved: string[];
49
+ barrels?: Record<string, CsrDevBarrelStamp>;
41
50
  }
42
51
 
43
52
  //* Every module becomes its own CJS factory: one Bun.build with each import external, so a save re-runs only the
@@ -58,6 +67,8 @@ export class CsrDevModuleCompiler {
58
67
  readonly #outDir: string;
59
68
  readonly #routePrefix: string;
60
69
  readonly #assets = new Set<string>();
70
+ #barrels: Promise<DevBarrelImports | null> | null = null;
71
+ readonly #narrowed = new Map<string, Record<string, CsrDevNarrowedImport>>();
61
72
 
62
73
  constructor({ app, paths, resolver, context, outDir, routePrefix }: CsrDevModuleCompilerOptions) {
63
74
  this.#app = app;
@@ -109,7 +120,14 @@ export class CsrDevModuleCompiler {
109
120
  this.#app.verbose(
110
121
  `[csr-dev] ${fallbacks.size} import(s) outside the browser build's graph resolved with Bun's runtime resolver: ${[...fallbacks].slice(0, 5).join(", ")}${fallbacks.size > 5 ? ", ..." : ""}`,
111
122
  );
112
- return { modules: [...compiled.values()], refusedVendors: [], unresolved: [] };
123
+ const modules = [...compiled.values()];
124
+ return { modules, refusedVendors: [], unresolved: [], barrels: await this.#barrelStamps(modules) };
125
+ }
126
+
127
+ async #barrelStamps(modules: CsrDevCompiledModule[]): Promise<Record<string, CsrDevBarrelStamp>> {
128
+ const barrels = new Set(modules.flatMap((module) => Object.keys(module.narrowed ?? {})));
129
+ const barrelImports = barrels.size > 0 ? await this.#barrelImports() : null;
130
+ return barrelImports ? await barrelImports.stamps(barrels, (file) => this.#paths.idOf(file)) : {};
113
131
  }
114
132
 
115
133
  async #compileRound(files: string[]): Promise<Omit<BuildRound, "tangled">> {
@@ -197,6 +215,7 @@ export class CsrDevModuleCompiler {
197
215
  continue;
198
216
  }
199
217
  const sourceMap = sourceMaps.get(`${artifact.path}.map`);
218
+ const narrowed = this.#narrowed.get(file);
200
219
  modules.push({
201
220
  id,
202
221
  file,
@@ -206,6 +225,7 @@ export class CsrDevModuleCompiler {
206
225
  deps: this.#emittedDeps(code),
207
226
  mtimeMs: mtimes.get(file) ?? CsrDevPaths.mtimeOf(file),
208
227
  hash: hashes.get(file) ?? "",
228
+ ...(narrowed && Object.keys(narrowed).length > 0 ? { narrowed } : {}),
209
229
  });
210
230
  }
211
231
  return { modules, misses, tangled };
@@ -302,13 +322,17 @@ export class CsrDevModuleCompiler {
302
322
  });
303
323
  }
304
324
  if (!vendor)
305
- build.onLoad({ filter: /\.tsx$/ }, async (args) => {
325
+ build.onLoad({ filter: /\.(?:tsx?|jsx?)$/ }, async (args) => {
306
326
  if (CsrDevPaths.isVendorFile(args.path)) return undefined;
307
- const normalized = RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(
308
- await Bun.file(args.path).text(),
309
- { path: args.path },
310
- );
311
- return normalized ? { contents: normalized, loader: "tsx" } : undefined;
327
+ const source = await Bun.file(args.path).text();
328
+ const rewrite = await (await this.#barrelImports())?.rewrite(source, args.path);
329
+ if (rewrite) this.#narrowed.set(CsrDevPaths.realpath(args.path), rewrite.narrowed);
330
+ const narrowed = rewrite?.contents ?? source;
331
+ const normalized = args.path.endsWith(".tsx")
332
+ ? RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(narrowed, { path: args.path })
333
+ : null;
334
+ const contents = normalized ?? narrowed;
335
+ return contents === source ? undefined : { contents, loader: loaderFor(args.path) };
312
336
  });
313
337
  build.onResolve({ filter: /.*/ }, (args) => {
314
338
  if (args.kind === "entry-point-build" || !args.importer) return undefined;
@@ -334,6 +358,11 @@ export class CsrDevModuleCompiler {
334
358
  };
335
359
  }
336
360
 
361
+ #barrelImports(): Promise<DevBarrelImports | null> {
362
+ this.#barrels ??= DevBarrelImports.create(this.#app, this.#context.barrelImports ?? []);
363
+ return this.#barrels;
364
+ }
365
+
337
366
  //? Bun writes sources relative to the working directory; the page shows them by workspace path instead. A package
338
367
  //? barrel inlined into its importer (react-icons) would carry its whole text into every importer's map.
339
368
  #rebaseSourceMap(text: string): string {
@@ -119,12 +119,25 @@ describe("CsrDevPatcher", () => {
119
119
  workspace: { workspaceRoot: root },
120
120
  cwdPath: path.join(root, "apps/demo"),
121
121
  verbose: () => undefined,
122
+ getTsConfig: async () => ({ compilerOptions: { paths: tsPaths } }),
122
123
  };
123
124
  super(app as unknown as App, { dirName: "registry", routePrefix: "/_akan/registry/", library: true });
124
125
  this.#entry = entry;
125
126
  }
127
+ barrels: string[] = [];
126
128
  async context() {
127
- return context;
129
+ return { ...context, barrelImports: this.barrels };
130
+ }
131
+ fingerprints = 0;
132
+ override async barrelImports(barrelContext: CsrDevContext) {
133
+ const barrelImports = await super.barrelImports(barrelContext);
134
+ if (!barrelImports) return null;
135
+ const fingerprint = barrelImports.fingerprint.bind(barrelImports);
136
+ barrelImports.fingerprint = async (barrel, names) => {
137
+ this.fingerprints += 1;
138
+ return await fingerprint(barrel, names);
139
+ };
140
+ return barrelImports;
128
141
  }
129
142
  async writeEntries() {
130
143
  return { files: { "": this.#entry }, changed: [] };
@@ -145,13 +158,22 @@ describe("CsrDevPatcher", () => {
145
158
  }
146
159
  }
147
160
 
148
- const movedRegistry = async (files: Record<string, string>, { roots = [] }: { roots?: string[] } = {}) => {
161
+ const tsPaths = { "@apps/*": ["./apps/*"] };
162
+ const movedRegistry = async (
163
+ files: Record<string, string>,
164
+ { roots = [], barrels = [] }: { roots?: string[]; barrels?: string[] } = {},
165
+ ) => {
149
166
  const root = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-move-"));
150
167
  dirs.push(root);
151
168
  const file = (relative: string) => path.join(CsrDevPaths.realpath(root), "apps/demo", relative);
152
169
  for (const [relative, text] of Object.entries(files)) await Bun.write(file(relative), text);
170
+ await Bun.write(
171
+ path.join(root, "tsconfig.json"),
172
+ JSON.stringify({ compilerOptions: { baseUrl: ".", paths: tsPaths } }),
173
+ );
153
174
  const bundler = new MovedRegistry(root, file("entry.ts"));
154
175
  bundler.extraRoots = roots.map((relative) => file(relative));
176
+ bundler.barrels = barrels;
155
177
  expect(await bundler.update([])).toMatchObject({ generation: 1, reload: true, first: true });
156
178
  const patcher = new CsrDevPatcher(bundler, { resident: true });
157
179
  expect(await patcher.update([])).toEqual({ kind: "unchanged" });
@@ -562,6 +584,71 @@ describe("CsrDevPatcher", () => {
562
584
  expect(await app()).toContain('__akan.define("apps/demo/ui/Foo.tsx"');
563
585
  });
564
586
 
587
+ test("an export moved to another file of a narrowed barrel compiles the importers of that name, also after a restart", async () => {
588
+ const { bundler, patcher, file, app, changedIds } = await movedRegistry(
589
+ {
590
+ "ui/index.ts": 'export * from "./Foo";\n',
591
+ "ui/Foo.tsx": 'export const Foo = () => "foo";\nexport const Bar = () => "bar";\n',
592
+ "page.ts": 'import { Foo } from "@apps/demo/ui";\nexport const page = Foo;\n',
593
+ "entry.ts":
594
+ 'import { Bar } from "@apps/demo/ui";\nimport { page } from "./page";\nexport const run = () => Bar() + page();\n',
595
+ },
596
+ { barrels: ["@apps/demo/ui"] },
597
+ );
598
+ const factoryOf = (text: string, id: string) =>
599
+ text.slice(text.indexOf(`__akan.define("${id}"`)).split("__akan.define(")[1];
600
+
601
+ await Bun.write(file("ui/Foo.tsx"), 'export const Foo = () => "foo v2";\nexport const Bar = () => "bar";\n');
602
+ expect(changedIds(await patcher.update([file("ui/Foo.tsx")]))).toEqual(["apps/demo/ui/Foo.tsx"]);
603
+ expect(bundler.fingerprints).toBe(0);
604
+
605
+ await Bun.write(file("ui/Foo.tsx"), 'export const Foo = () => "foo";\n');
606
+ await Bun.write(file("ui/Bar.tsx"), 'export const Bar = () => "bar moved";\n');
607
+ await Bun.write(file("ui/index.ts"), 'export * from "./Bar";\nexport * from "./Foo";\n');
608
+ const moved = await patcher.update([file("ui/Foo.tsx"), file("ui/Bar.tsx"), file("ui/index.ts")]);
609
+ expect(changedIds(moved)).toEqual(["apps/demo/entry.ts", "apps/demo/ui/Bar.tsx", "apps/demo/ui/Foo.tsx"]);
610
+ expect(bundler.fingerprints).toBe(2);
611
+ expect(factoryOf(await app(), "apps/demo/entry.ts")).toContain('require("akan-module:apps/demo/ui/Bar.tsx")');
612
+
613
+ patcher.forget();
614
+ await Bun.write(file("ui/Foo.tsx"), 'export const Foo = () => "foo";\nexport const Bar = () => "bar back";\n');
615
+ await Bun.write(file("ui/Bar.tsx"), "export const Gone = 1;\n");
616
+ const restarted = await bundler.update([]);
617
+ expect(restarted?.changedIds.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual([
618
+ "apps/demo/entry.ts",
619
+ "apps/demo/ui/Bar.tsx",
620
+ "apps/demo/ui/Foo.tsx",
621
+ ]);
622
+ expect(factoryOf(await app(), "apps/demo/entry.ts")).toContain('require("akan-module:apps/demo/ui/Foo.tsx")');
623
+ expect(await new CsrDevPatcher(bundler).update([])).toEqual({ kind: "unchanged" });
624
+ });
625
+
626
+ test("a move whose compile fails leaves the barrel's stamps on disk, so the next save reads it again", async () => {
627
+ const { bundler, file, app, changedIds } = await movedRegistry(
628
+ {
629
+ "ui/index.ts": 'export * from "./Foo";\n',
630
+ "ui/Foo.tsx": 'export const Foo = () => "foo";\nexport const Bar = () => "bar";\n',
631
+ "entry.ts": 'import { Bar } from "@apps/demo/ui";\nexport const run = Bar;\n',
632
+ },
633
+ { barrels: ["@apps/demo/ui"] },
634
+ );
635
+ const barrelsOnDisk = async () => (await bundler.writer.readJson<CsrDevGraph>("graph.json"))?.barrels;
636
+ const before = await barrelsOnDisk();
637
+
638
+ await Bun.write(file("ui/Foo.tsx"), 'export const Foo = () => "foo";\n');
639
+ await Bun.write(file("ui/Bar.tsx"), 'import "./Missing";\nexport const Bar = () => "bar moved";\n');
640
+ await Bun.write(file("ui/index.ts"), 'export * from "./Bar";\nexport * from "./Foo";\n');
641
+ const saved = [file("ui/Foo.tsx"), file("ui/Bar.tsx"), file("ui/index.ts")];
642
+ await expect(new CsrDevPatcher(bundler).update(saved)).rejects.toThrow();
643
+ expect(await barrelsOnDisk()).toEqual(before);
644
+
645
+ await Bun.write(file("ui/Bar.tsx"), 'export const Bar = () => "bar moved";\n');
646
+ const fixed = await new CsrDevPatcher(bundler).update([file("ui/Bar.tsx")]);
647
+ expect(changedIds(fixed)).toEqual(["apps/demo/entry.ts", "apps/demo/ui/Bar.tsx", "apps/demo/ui/Foo.tsx"]);
648
+ expect(await app()).toContain('"bar moved"');
649
+ expect(await barrelsOnDisk()).not.toEqual(before);
650
+ });
651
+
565
652
  test("a registry with no manifest is a first build, which no tab holds a module of", async () => {
566
653
  const { bundler, dir } = await makeRegistry("same");
567
654
  await rm(path.join(dir, CSR_DEV_MANIFEST_FILE), { force: true });
@@ -4,7 +4,7 @@ import { appGenerationOf, CSR_DEV_MANIFEST_FILE, type CsrDevManifest } from "aka
4
4
  import type { CsrDevCompileResult } from "./csrDevModuleCompiler";
5
5
  import { CsrDevPaths } from "./csrDevPaths";
6
6
  import { CsrDevResolver } from "./csrDevResolver";
7
- import type { CsrDevCode, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
7
+ import type { CsrDevBarrelStamp, CsrDevCode, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
8
8
  import type { CsrDevUpdate, CsrDevUpdateOptions, DevRegistryBundler } from "./devRegistryBundler";
9
9
 
10
10
  interface CsrDevPatcherState {
@@ -109,6 +109,7 @@ export class CsrDevPatcher {
109
109
  const present = CsrDevPatcher.#presence(changedFiles);
110
110
  const forgotten = this.#forgetDeletedModules(state, present);
111
111
  const changed = onlyRoots ? new Set<string>() : await this.#changedModules(graph, changedFiles, present);
112
+ const restamped = onlyRoots ? null : await this.#renarrowed(context, graph, changedFiles, changed);
112
113
  for (const id of this.#withoutFactory(state, present)) changed.add(id);
113
114
  for (const file of entries.changed) changed.add(paths.idOf(file));
114
115
  const rootFiles = [...Object.values(entries.files), ...roots.filter((file) => fs.existsSync(file))];
@@ -121,6 +122,8 @@ export class CsrDevPatcher {
121
122
  this.#bundler.reloadsOnEntryChange &&
122
123
  (entries.changed.length > 0 || JSON.stringify(entryIds) !== JSON.stringify(graph.entries));
123
124
  if (changed.size === 0) {
125
+ //? No importer moved (none compiles), so the restamped barrels already hold.
126
+ if (restamped) graph.barrels = { ...graph.barrels, ...restamped };
124
127
  if (carried.length === 0) return { kind: "unchanged" };
125
128
  return await this.#retryCarried(context, state, carried, { announce, allowWholeAppBuilds });
126
129
  }
@@ -188,15 +191,11 @@ export class CsrDevPatcher {
188
191
  forgotten.push(id);
189
192
  }
190
193
  graph.pending = graph.pending.filter((id) => !orphans.includes(id));
191
- this.#bundler.merge(graph, resolver, compiled);
194
+ this.#bundler.merge(graph, resolver, compiled, { ...restamped, ...result.barrels });
192
195
  if (!this.#resident) delete graph.failed;
193
196
  for (const module of compiled) this.#delegated.delete(module.id);
194
197
  graph.entries = entryIds;
195
- for (const module of compiled) {
196
- if (module.vendor || !code) continue;
197
- code.modules.set(module.id, module.factory);
198
- if (module.helpers) code.helpers.set(module.helpers.hash, module.helpers.definition);
199
- }
198
+ if (code) CsrDevPatcher.#remember(code, compiled);
200
199
  const changedIds = compiled.filter((module) => !module.vendor).map((module) => module.id);
201
200
  const constantId = changedIds.find((id) => id.endsWith(".constant.ts"));
202
201
  // A model class swapped under live store state would mix old and new instances; reload instead.
@@ -359,6 +358,7 @@ export class CsrDevPatcher {
359
358
  modules: [...result.modules, ...more.modules],
360
359
  refusedVendors: [...result.refusedVendors, ...more.refusedVendors],
361
360
  unresolved: [...result.unresolved, ...more.unresolved],
361
+ barrels: { ...result.barrels, ...more.barrels },
362
362
  };
363
363
  }
364
364
 
@@ -508,6 +508,46 @@ export class CsrDevPatcher {
508
508
  return changed;
509
509
  }
510
510
 
511
+ //? The new stamps go into the graph with the compile that follows (`merge`), never ahead of it: a round that fails or
512
+ //? is handed back must leave the barrel stale for the next one.
513
+ async #renarrowed(
514
+ context: CsrDevContext,
515
+ graph: CsrDevGraph,
516
+ changedFiles: string[],
517
+ changed: Set<string>,
518
+ ): Promise<Record<string, CsrDevBarrelStamp>> {
519
+ const { paths } = this.#bundler;
520
+ const saved = new Set(changedFiles.map((file) => paths.idOf(file)));
521
+ const stale = Object.entries(graph.barrels ?? {})
522
+ .filter(([, { read }]) =>
523
+ Object.entries(read).some(
524
+ ([id, mtimeMs]) => saved.has(id) || CsrDevPaths.mtimeOf(paths.fileOf(id)) !== mtimeMs,
525
+ ),
526
+ )
527
+ .map(([barrel]) => barrel);
528
+ const barrelImports = stale.length > 0 ? await this.#bundler.barrelImports(context) : null;
529
+ if (!barrelImports) return {};
530
+ const barrels = await barrelImports.stamps(stale, (file) => paths.idOf(file));
531
+ const moved = stale.filter((barrel) => barrels[barrel]?.digest !== graph.barrels?.[barrel]?.digest);
532
+ for (const [id, module] of Object.entries(graph.modules)) {
533
+ for (const barrel of moved) {
534
+ const narrowed = module.narrowed?.[barrel];
535
+ if (!narrowed || (await barrelImports.fingerprint(barrel, narrowed.names)) === narrowed.hash) continue;
536
+ changed.add(id);
537
+ break;
538
+ }
539
+ }
540
+ return barrels;
541
+ }
542
+
543
+ static #remember(code: CsrDevCode, compiled: CsrDevCompileResult["modules"]) {
544
+ for (const module of compiled) {
545
+ if (module.vendor) continue;
546
+ code.modules.set(module.id, module.factory);
547
+ if (module.helpers) code.helpers.set(module.helpers.hash, module.helpers.definition);
548
+ }
549
+ }
550
+
511
551
  //? A new file can take over a relative import another module already resolved (`Foo.tsx` created beside
512
552
  //? `Foo/index.tsx`), with no change to that module's own text: it compiles again, and resolves against the disk.
513
553
  #shadowedImporters(graph: CsrDevGraph, changedFiles: string[]): string[] {
@@ -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));