@akanjs/devkit 3.0.1 → 3.0.2-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 (55) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/akanConfig/akanConfig.test.ts +3 -0
  3. package/akanConfig/akanConfig.ts +3 -1
  4. package/akanContext.ts +18 -1
  5. package/biome.base.json +8 -1
  6. package/commandDecorators/command.ts +3 -2
  7. package/commandDecorators/commandDecorators.test.ts +7 -0
  8. package/csrE2e/csrPageStack.e2e.test.ts +12 -3
  9. package/executors.test.ts +329 -4
  10. package/executors.ts +49 -47
  11. package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
  12. package/frontendBuild/csrArtifactBuilder.ts +14 -8
  13. package/frontendBuild/csrDevModuleCompiler.ts +0 -1
  14. package/frontendBuild/csrDevResolver.ts +0 -2
  15. package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
  16. package/frontendBuild/devGeneratedIndexSync.ts +21 -4
  17. package/frontendBuild/frontendBuild.test.ts +1 -1
  18. package/jsonFormatter.test.ts +64 -0
  19. package/jsonFormatter.ts +78 -0
  20. package/lint/__fixtures__/README.md +12 -3
  21. package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
  22. package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
  23. package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
  24. package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
  25. package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
  26. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
  27. package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
  28. package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
  29. package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
  30. package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
  31. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
  32. package/lint/gritRules.test.ts +23 -15
  33. package/lint/no-double-brace-placeholder.grit +15 -0
  34. package/lint/no-inline-color.grit +21 -5
  35. package/lint/no-throw-raw-error.grit +18 -33
  36. package/lint/no-unguarded-endpoint.grit +73 -0
  37. package/manifestDependencies.ts +65 -0
  38. package/mobile/desktopBin.test.ts +1 -1
  39. package/mobile/desktopServerStage.test.ts +7 -4
  40. package/mobile/nativeApp.test.ts +57 -8
  41. package/mobile/nativeApp.ts +27 -9
  42. package/mobile/nativeConfig.test.ts +26 -4
  43. package/mobile/nativeConfig.ts +19 -1
  44. package/package.json +2 -2
  45. package/platformTest/LinuxDockerTarget.ts +1 -1
  46. package/platformTest/PlatformTestTarget.ts +3 -1
  47. package/platformTest/WindowsSshTarget.ts +1 -1
  48. package/qualityScanner.test.ts +51 -0
  49. package/qualityScanner.ts +22 -1
  50. package/scanInfo.ts +14 -2
  51. package/testHelpers.ts +17 -0
  52. package/transforms/transforms.test.ts +13 -0
  53. package/tsconfig.json +4 -15
  54. package/workspaceLayout.test.ts +24 -1
  55. package/workspaceLayout.ts +19 -2
@@ -63,7 +63,8 @@ export class NativeApp {
63
63
  readonly target: ResolvedMobileTarget,
64
64
  readonly env?: NativeEnv,
65
65
  ) {
66
- this.targetRoot = path.join(app.cwdPath, ".akan", "native", target.name);
66
+ //* Outside `dist/apps/<app>`, which every `akan build` empties: a signed release waiting for upload lives here.
67
+ this.targetRoot = path.join(app.workspace.workspaceRoot, "dist", "native", app.name, target.name);
67
68
  this.web = new NativeWebDir(path.join(this.targetRoot, "web"));
68
69
  }
69
70
 
@@ -161,13 +162,18 @@ export class NativeApp {
161
162
  };
162
163
 
163
164
  //* `AKAN_PUBLIC_*` is already inlined into the CSR bundle, so the runtime reads no .env file of its own.
164
- #task(platform: NativePlatform, config: AkanNativeConfig, outDir = this.outDir(platform)): TaskOptions {
165
+ #task(
166
+ platform: NativePlatform,
167
+ config: AkanNativeConfig,
168
+ { outDir = this.outDir(platform), env = {} }: { outDir?: string; env?: Record<string, string> } = {},
169
+ ): TaskOptions {
165
170
  return {
166
171
  appDir: this.app.cwdPath,
167
172
  config,
168
173
  platform,
169
174
  outDir,
170
175
  envFiles: false,
176
+ env,
171
177
  skipWebBuild: true,
172
178
  log: (event) => {
173
179
  if (event.level === "error") this.app.logger.error(event.message);
@@ -179,6 +185,13 @@ export class NativeApp {
179
185
  };
180
186
  }
181
187
 
188
+ //* A local release has no dev gateway behind it, so its page calls the app's dev server (baseEnv csrServerUrl), on
189
+ //* the port `akan start` gives this app. A carried server hands the page its own URL at launch instead.
190
+ async #runtimeEnv(server?: DesktopServerBundle): Promise<Record<string, string>> {
191
+ if (this.env !== "local" || server || process.env.AKAN_PUBLIC_SERVER_URL) return {};
192
+ return { PUBLIC_AKAN_SERVER_URL: `http://localhost:${await this.app.getDevPort()}` };
193
+ }
194
+
182
195
  async build(
183
196
  platform: NativePlatform,
184
197
  { profile = "release", server, installer = false, arch }: NativeBuildOptions = {},
@@ -188,7 +201,7 @@ export class NativeApp {
188
201
  await this.assembleWeb(platform);
189
202
  const { api, config } = await this.prepare(platform, server, arch);
190
203
  return await api.build({
191
- ...this.#task(platform, config),
204
+ ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
192
205
  profile,
193
206
  ...(arch ? { arch } : {}),
194
207
  ...(platform === "windows"
@@ -219,7 +232,7 @@ export class NativeApp {
219
232
  await this.assembleWeb(platform);
220
233
  const { api, config } = await this.prepare(platform, server);
221
234
  return await api.run({
222
- ...this.#task(platform, config),
235
+ ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
223
236
  profile,
224
237
  onLine: this.#appLine,
225
238
  ...(device ? { device } : {}),
@@ -238,7 +251,7 @@ export class NativeApp {
238
251
  await this.web.clear();
239
252
  const [{ api, config }, { api: routes }] = await Promise.all([this.prepare(platform), this.app.getConfig()]);
240
253
  const session = await api.dev({
241
- ...this.#task(platform, config, this.devOutDir(platform)),
254
+ ...this.#task(platform, config, { outDir: this.devOutDir(platform) }),
242
255
  upstream,
243
256
  hmrPath: "/_akan/hmr",
244
257
  //? A dev page calls its own origin (akanjs baseEnv), so the gateway relays the socket it opens for the API too.
@@ -279,7 +292,7 @@ export class NativeApp {
279
292
  await this.assembleWeb("ios");
280
293
  const { api, config } = await this.prepare("ios");
281
294
  return await api.release({
282
- ...this.#task("ios", config),
295
+ ...this.#task("ios", config, { env: await this.#runtimeEnv() }),
283
296
  platform: "ios",
284
297
  signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
285
298
  });
@@ -300,7 +313,7 @@ export class NativeApp {
300
313
  await this.assembleWeb(platform);
301
314
  const { api, config } = await this.prepare(platform, server);
302
315
  return await api.publishUpdate({
303
- ...this.#task(platform, config),
316
+ ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
304
317
  platform,
305
318
  out: this.updatesDir,
306
319
  ...(channel ? { channel } : {}),
@@ -363,7 +376,12 @@ export class NativeApp {
363
376
  const signing = NativeApp.androidSigning();
364
377
  await this.assembleWeb("android");
365
378
  const { api, config } = await this.prepare("android");
366
- return await api.release({ ...this.#task("android", config), platform: "android", signing, formats });
379
+ return await api.release({
380
+ ...this.#task("android", config, { env: await this.#runtimeEnv() }),
381
+ platform: "android",
382
+ signing,
383
+ formats,
384
+ });
367
385
  }
368
386
 
369
387
  /** An unsigned OTA update of this target's web bundle for `platform`, and the channel its binary follows. */
@@ -377,7 +395,7 @@ export class NativeApp {
377
395
  const packed = await api.packUpdate({
378
396
  ...this.#task(platform, config),
379
397
  platform,
380
- out: out ?? path.join(this.targetRoot, "updates", platform),
398
+ out: out ?? path.join(this.updatesDir, platform),
381
399
  });
382
400
  return { ...packed, channel: config.updates.channel ?? "production" };
383
401
  }
@@ -45,11 +45,33 @@ const adminTarget: AkanNativeTarget = {
45
45
  const updatesKey = Buffer.alloc(32, 7).toString("base64");
46
46
 
47
47
  describe("NativeConfig.build", () => {
48
+ test("hands the dmg window over with its pictures resolved against the app folder", () => {
49
+ const build = (dmg: NonNullable<AkanNativeTarget["desktop"]>["dmg"]) =>
50
+ NativeConfig.build({
51
+ appPath: "/repo/apps/minimal",
52
+ target: { ...minimalTarget, desktop: { dmg } },
53
+ webDir: "/repo/dist/native/minimal/default/web",
54
+ contributions: [],
55
+ locales: ["en"],
56
+ platform: "macos",
57
+ }).config.desktop?.dmg;
58
+
59
+ expect(build({ background: "assets/dmg.png", background2x: "assets/dmg@2x.png", app: { x: 150, y: 190 } })).toEqual(
60
+ {
61
+ background: path.resolve("/repo/apps/minimal", "assets/dmg.png"),
62
+ background2x: path.resolve("/repo/apps/minimal", "assets/dmg@2x.png"),
63
+ app: { x: 150, y: 190 },
64
+ },
65
+ );
66
+ expect(build({ background: false, iconSize: 96 })).toEqual({ background: false, iconSize: 96 });
67
+ expect(build(undefined)).toBeUndefined();
68
+ });
69
+
48
70
  test("turns the minimal app's push target into the runtime's config, single-instance carrying its link scheme", () => {
49
71
  const { config, warnings } = NativeConfig.build({
50
72
  appPath: "/repo/apps/minimal",
51
73
  target: minimalTarget,
52
- webDir: "/repo/apps/minimal/.akan/native/default/web",
74
+ webDir: "/repo/dist/native/minimal/default/web",
53
75
  contributions: [{ permission: "push", plugins: ["push"] }],
54
76
  locales: ["en", "ko"],
55
77
  platform: "android",
@@ -58,7 +80,7 @@ describe("NativeConfig.build", () => {
58
80
  expect(warnings).toEqual([]);
59
81
  expect(config).toEqual({
60
82
  app: { id: "com.minimal.dev.app", name: "minimal", fileName: "minimal", version: "0.0.1", build: 1 },
61
- web: { dir: "/repo/apps/minimal/.akan/native/default/web" },
83
+ web: { dir: "/repo/dist/native/minimal/default/web" },
62
84
  plugins: [...NativeConfig.basePlugins, "single-instance", "push"],
63
85
  capabilities: [
64
86
  {
@@ -80,7 +102,7 @@ describe("NativeConfig.build", () => {
80
102
  const { config, warnings } = NativeConfig.build({
81
103
  appPath: "/repo/apps/portal",
82
104
  target: adminTarget,
83
- webDir: "/repo/apps/portal/.akan/native/admin/web",
105
+ webDir: "/repo/dist/native/portal/admin/web",
84
106
  contributions: [],
85
107
  locales: ["en", "ko"],
86
108
  platform: "android",
@@ -89,7 +111,7 @@ describe("NativeConfig.build", () => {
89
111
  expect(warnings).toEqual(["Permission 'speech' has no native plugin yet; the app ships without it."]);
90
112
  expect(config).toEqual({
91
113
  app: { id: "com.portal.admin", name: "Portal Admin", fileName: "portal-admin", version: "2.1.0", build: 42 },
92
- web: { dir: "/repo/apps/portal/.akan/native/admin/web" },
114
+ web: { dir: "/repo/dist/native/portal/admin/web" },
93
115
  plugins: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"],
94
116
  capabilities: [
95
117
  {
@@ -1,7 +1,14 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { AkanNativeConfig } from "@akanjs/native/config";
4
- import type { AkanNativeTarget, AkanNativeValue, AkanPluginNativeConfig, NativeEnv, NativePermission } from "akanjs";
4
+ import type {
5
+ AkanNativeDmgConfig,
6
+ AkanNativeTarget,
7
+ AkanNativeValue,
8
+ AkanPluginNativeConfig,
9
+ NativeEnv,
10
+ NativePermission,
11
+ } from "akanjs";
5
12
  import type { DesktopServerBundle } from "./desktopServerStage";
6
13
  import { type NativePlatform, resolveAppId } from "./mobileTarget";
7
14
  import type { NativePluginFolder } from "./nativePluginFolders";
@@ -171,6 +178,7 @@ export class NativeConfig {
171
178
  window: NativeConfig.#compact({ ...target.desktop?.window }),
172
179
  server: desktopServer,
173
180
  bin: desktopBin,
181
+ dmg: NativeConfig.#dmg(target.desktop?.dmg, abs),
174
182
  });
175
183
  const config: AkanNativeConfig = {
176
184
  app: {
@@ -234,6 +242,16 @@ export class NativeConfig {
234
242
  return { ...splash, ...(splash.image ? { image: abs(splash.image) } : {}) };
235
243
  }
236
244
 
245
+ static #dmg(dmg: AkanNativeDmgConfig | undefined, abs: (relative: string) => string) {
246
+ if (!dmg) return undefined;
247
+ const { background, background2x, ...layout } = dmg;
248
+ return {
249
+ ...layout,
250
+ ...(background === undefined ? {} : { background: background === false ? false : abs(background) }),
251
+ ...(background2x ? { background2x: abs(background2x) } : {}),
252
+ };
253
+ }
254
+
237
255
  //? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
238
256
  static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
239
257
  const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.1",
3
+ "version": "3.0.2-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -47,7 +47,7 @@
47
47
  "@earendil-works/pi-tui": "0.86.1",
48
48
  "@inquirer/prompts": "^8.4.3",
49
49
  "@tailwindcss/node": "^4.3.0",
50
- "akanjs": "3.0.1",
50
+ "akanjs": "3.0.2-rc.0",
51
51
  "chalk": "^5.6.2",
52
52
  "commander": "^14.0.3",
53
53
  "dayjs": "^1.11.20",
@@ -73,7 +73,7 @@ export class LinuxDockerTarget extends PlatformTestTarget {
73
73
  [
74
74
  "docker",
75
75
  "exec",
76
- ...Object.entries(PlatformTestTarget.gitIdentityEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
76
+ ...Object.entries(PlatformTestTarget.remoteEnv).flatMap(([key, value]) => ["-e", `${key}=${value}`]),
77
77
  container,
78
78
  "sh",
79
79
  "-c",
@@ -36,7 +36,9 @@ export interface PlatformRunContext {
36
36
  export abstract class PlatformTestTarget {
37
37
  static readonly installIdleMs = 5 * 60_000;
38
38
  static readonly testIdleMs = 2 * 60_000;
39
- protected static readonly gitIdentityEnv = {
39
+ protected static readonly remoteEnv = {
40
+ //? No suite drives puppeteer's Chrome, and downloading it into every fresh container fails install on a DNS blip.
41
+ PUPPETEER_SKIP_DOWNLOAD: "true",
40
42
  GIT_AUTHOR_NAME: "akan-test",
41
43
  GIT_AUTHOR_EMAIL: "akan-test@localhost",
42
44
  GIT_COMMITTER_NAME: "akan-test",
@@ -71,7 +71,7 @@ export class WindowsSshTarget extends PlatformTestTarget {
71
71
  "$ProgressPreference = 'SilentlyContinue'",
72
72
  "[Console]::OutputEncoding = [Text.Encoding]::UTF8",
73
73
  "'PID=' + $PID",
74
- ...Object.entries(PlatformTestTarget.gitIdentityEnv).map(([key, value]) => `$env:${key} = '${value}'`),
74
+ ...Object.entries(PlatformTestTarget.remoteEnv).map(([key, value]) => `$env:${key} = '${value}'`),
75
75
  `Set-Location -LiteralPath '${runDir}\\w'`,
76
76
  `cmd /c "${command} 2>&1"`,
77
77
  "exit $LASTEXITCODE",
@@ -219,6 +219,20 @@ describe("AkanQualityScanner layout rules", () => {
219
219
  ]);
220
220
  });
221
221
 
222
+ test("flags a hand-written facet index beside the generated barrel, but not a folder namespace", async () => {
223
+ const root = await makeWorkspace({
224
+ "libs/demo/ui/index.tsx": 'export { Chat } from "./Chat";\n',
225
+ "libs/demo/ui/Chat.tsx": "export const Chat = () => null;\n",
226
+ "libs/demo/ui/Page/index.tsx": 'export { Inner } from "./Inner";\n',
227
+ });
228
+
229
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.facet-index-shadow");
230
+
231
+ expect(warnings.map((warning) => warning.file)).toEqual(["libs/demo/ui/index.tsx"]);
232
+ expect(warnings[0]?.message).toContain("index.tsx shadows the generated ui/index.ts");
233
+ expect(warnings[0]?.fix).toContain("akan sync");
234
+ });
235
+
222
236
  test("keeps a root signal test out of the lib facet rule", async () => {
223
237
  const root = await makeWorkspace({
224
238
  "libs/demo/lib/cnst.ts": "export const cnst = 1;\n",
@@ -233,6 +247,43 @@ describe("AkanQualityScanner layout rules", () => {
233
247
  });
234
248
  });
235
249
 
250
+ describe("AkanQualityScanner class export files", () => {
251
+ test("allow a type only the class reads, and flag an exported type and any helper beside the class", async () => {
252
+ const root = await makeWorkspace({
253
+ "libs/demo/srvkit/vendorApi.ts": [
254
+ "interface VendorUser {",
255
+ " id: string;",
256
+ "}",
257
+ "type VendorPage = { items: VendorUser[] };",
258
+ "export interface VendorApiOptions {",
259
+ " key: string;",
260
+ "}",
261
+ "export interface VendorReceipt {",
262
+ " id: string;",
263
+ "}",
264
+ 'export type VendorStatus = "ok" | "fail";',
265
+ "enum VendorRegion {",
266
+ " Kr,",
267
+ "}",
268
+ "const pageSize = 100;",
269
+ "const toUser = (page: VendorPage) => page.items[0];",
270
+ "export class VendorApi {}",
271
+ "",
272
+ ].join("\n"),
273
+ });
274
+
275
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.file.class-export-global-declaration");
276
+
277
+ expect(warnings.map((warning) => /"(\w+)"/.exec(warning.message)?.[1])).toEqual([
278
+ "VendorReceipt",
279
+ "VendorStatus",
280
+ "VendorRegion",
281
+ "pageSize",
282
+ "toUser",
283
+ ]);
284
+ });
285
+ });
286
+
236
287
  describe("bang comments in browser-reachable files", () => {
237
288
  const bangWarningsIn = async (files: Record<string, string>) => {
238
289
  const result = await new AkanQualityScanner().scan(await makeWorkspace(files));
package/qualityScanner.ts CHANGED
@@ -7,7 +7,13 @@ import ts from "typescript";
7
7
  import { AbstractDoc } from "./abstractDoc";
8
8
  import { FormSetterScanner } from "./formSetterScanner";
9
9
  import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
10
- import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles, rootEntryHintOf } from "./workspaceLayout";
10
+ import {
11
+ facetIndexShadowOf,
12
+ isAllowedLibFacetRootFile,
13
+ rootAllowedDirs,
14
+ rootAllowedFiles,
15
+ rootEntryHintOf,
16
+ } from "./workspaceLayout";
11
17
 
12
18
  type QualitySeverity = "warning";
13
19
  type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
@@ -140,6 +146,8 @@ const RULE_FIXES: Record<string, string> = {
140
146
  "Move the folder's contents into a conventional lib folder (common, env, lib, page, private, public, srvkit, ui, or webkit) and delete it.",
141
147
  "akan.layout.lib-facet-file":
142
148
  "Move the file into a domain module folder under lib/; keep the lib facet root limited to generated support facets.",
149
+ "akan.layout.facet-index-shadow":
150
+ "Delete the hand-written index file and run akan sync; move a namespace it builds into <facet>/<Folder>/index.tsx.",
143
151
  "akan.layout.module-ui-file":
144
152
  "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
145
153
  "akan.ssr.unnecessary-use-client":
@@ -325,6 +333,8 @@ export class AkanQualityScanner {
325
333
  for (const declaration of getTopLevelDeclarations(sourceFile)) {
326
334
  if (declaration.kind === "class" && exportedClassNames.includes(declaration.name)) continue;
327
335
  if (declaration.kind === "interface" && allowedInterfaceNames.has(declaration.name)) continue;
336
+ //? A shape only this class reads (a vendor response it parses) is erased at build; a sibling file buys nothing.
337
+ if ((declaration.kind === "interface" || declaration.kind === "type") && !declaration.exported) continue;
328
338
  warnings.push(
329
339
  fileWarning(
330
340
  "akan.file.class-export-global-declaration",
@@ -432,6 +442,17 @@ export class AkanQualityScanner {
432
442
  );
433
443
  }
434
444
 
445
+ const facetShadow = rootEntry?.isDir ? facetIndexShadowOf(sourceFile.file.split("/").slice(2).join("/")) : null;
446
+ if (facetShadow)
447
+ warnings.push(
448
+ fileWarning(
449
+ "akan.layout.facet-index-shadow",
450
+ "layout",
451
+ sourceFile.file,
452
+ `${path.basename(sourceFile.file)} ${facetShadow}.`,
453
+ ),
454
+ );
455
+
435
456
  const libFacetFile = getLibFacetRootFile(sourceFile.file);
436
457
  if (libFacetFile && !isAllowedLibFacetRootFile(libFacetFile))
437
458
  warnings.push(
package/scanInfo.ts CHANGED
@@ -10,7 +10,14 @@ import type {
10
10
  } from "./akanConfig";
11
11
  import { AkanAppConfig } from "./akanConfig";
12
12
  import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
13
- import { isAllowedLibFacetRootFile, rootAllowedDirs, rootAllowedFiles, rootEntryHintOf } from "./workspaceLayout";
13
+ import {
14
+ barrelFacetDirs,
15
+ facetIndexShadowOf,
16
+ isAllowedLibFacetRootFile,
17
+ rootAllowedDirs,
18
+ rootAllowedFiles,
19
+ rootEntryHintOf,
20
+ } from "./workspaceLayout";
14
21
 
15
22
  const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
16
23
  type ScalarFileType = (typeof scalarFileTypes)[number];
@@ -74,6 +81,11 @@ async function assertScanConvention(exec: AppExecutor | LibExecutor, libRoot: {
74
81
  for (const dirname of dirs)
75
82
  if (!allowedRootDirs.has(dirname))
76
83
  addViolation(dirname, rootEntryHintOf(exec.type, dirname) ?? `unsupported ${exec.type} root folder`);
84
+ for (const facet of barrelFacetDirs)
85
+ for (const filename of await exec.readdir(facet)) {
86
+ const shadow = facetIndexShadowOf(`${facet}/${filename}`);
87
+ if (shadow) addViolation(path.join(facet, filename), shadow);
88
+ }
77
89
 
78
90
  //* A lib has no `getPageKeys`, so its own route files are validated here.
79
91
  if (exec.type === "lib")
@@ -275,7 +287,7 @@ class ScanInfo {
275
287
  }
276
288
  }
277
289
 
278
- const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
290
+ export const isAkanFrameworkDependency = (dep: string) => dep === "akanjs" || dep.startsWith("akanjs/");
279
291
  export class AppInfo extends ScanInfo {
280
292
  readonly type = "app";
281
293
  readonly exec: AppExecutor;
package/testHelpers.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach } from "bun:test";
2
+ import { existsSync } from "node:fs";
2
3
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -122,6 +123,22 @@ export const writeJson = async (filePath: string, value: object) => {
122
123
  await writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
123
124
  };
124
125
 
126
+ const repoRoot = path.resolve(import.meta.dir, "../../..");
127
+ const biomeBin = path.join(repoRoot, "node_modules", ".bin", process.platform === "win32" ? "biome.exe" : "biome");
128
+ export const hasBiome = existsSync(biomeBin);
129
+
130
+ //? Through stdin under a repo-relative name, so the repo's own Biome config and overrides judge content kept elsewhere.
131
+ export const formatWithBiome = async (content: string, repoPath: string) => {
132
+ const proc = Bun.spawn([biomeBin, "check", "--write", `--stdin-file-path=${repoPath}`], {
133
+ cwd: repoRoot,
134
+ stdin: new Blob([content]),
135
+ stdout: "pipe",
136
+ stderr: "pipe",
137
+ });
138
+ const [output] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
139
+ return output;
140
+ };
141
+
125
142
  const tsconfigJson = { compilerOptions: { target: "ESNext", paths: {} } };
126
143
 
127
144
  export const createTempApp = async (appName = "demo", parentDir?: string) => {
@@ -230,6 +230,19 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
230
230
  expect(secondMap?.get("b2")).toEqual({ subpath: "second/b2", originalName: "b2" });
231
231
  });
232
232
 
233
+ test("an empty facet barrel maps no name, so an import from it is left as written", async () => {
234
+ const root = await makeTempRoot();
235
+ const pkgDir = path.join(root, "common");
236
+ await write(path.join(pkgDir, "index.ts"), "export {};\n");
237
+ const analyzer = new BarrelAnalyzer({
238
+ resolvePackage: async () => ({ pkgName: "@libs/util/common", entryFile: path.join(pkgDir, "index.ts"), pkgDir }),
239
+ });
240
+
241
+ expect(await analyzer.analyze("@libs/util/common")).toEqual(new Map());
242
+ const source = 'import { pad } from "@libs/util/common";\n';
243
+ expect(await rewriteBarrelImports(source, ["@libs/util/common"], analyzer)).toBeNull();
244
+ });
245
+
233
246
  test("rewrites flattenable named imports and preserves default, type, and unknown imports", async () => {
234
247
  const analyzer = fakeAnalyzer([
235
248
  ["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
package/tsconfig.json CHANGED
@@ -1,17 +1,12 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "lib": [
4
- "ESNext",
5
- "DOM"
6
- ],
3
+ "lib": ["ESNext", "DOM"],
7
4
  "target": "ESNext",
8
5
  "module": "esnext",
9
6
  "moduleDetection": "force",
10
7
  "jsx": "react-jsx",
11
8
  "allowJs": true,
12
- "types": [
13
- "bun"
14
- ],
9
+ "types": ["bun"],
15
10
  "moduleResolution": "bundler",
16
11
  "allowImportingTsExtensions": true,
17
12
  "verbatimModuleSyntax": true,
@@ -30,12 +25,6 @@
30
25
  },
31
26
  "extends": "../../../tsconfig.json",
32
27
  "files": [],
33
- "include": [
34
- "**/*.ts",
35
- "**/*.tsx",
36
- "../../akanjs/server/types/*.d.ts"
37
- ],
38
- "exclude": [
39
- "lint/__fixtures__"
40
- ]
28
+ "include": ["**/*.ts", "**/*.tsx", "../../akanjs/server/types/*.d.ts"],
29
+ "exclude": ["lint/__fixtures__"]
41
30
  }
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  appRootAllowedDirs,
4
4
  appRootAllowedFiles,
5
+ facetIndexShadowOf,
5
6
  isAllowedLibFacetRootFile,
6
7
  isScannedRootEntry,
7
8
  libRootAllowedDirs,
@@ -34,7 +35,7 @@ describe("app root layout allowlist", () => {
34
35
  test("names what a Capacitor app kept in its root as a leftover, not an unknown entry", () => {
35
36
  for (const name of ["android", "ios", "mobile", "capacitor.config.ts", "capacitor.config.json"]) {
36
37
  expect(appRootAllowedFiles.has(name) || appRootAllowedDirs.has(name)).toBe(false);
37
- expect(rootEntryHintOf("app", name)).toContain(".akan/native/<target>");
38
+ expect(rootEntryHintOf("app", name)).toContain("dist/native/<app>/<target>");
38
39
  }
39
40
  expect(rootEntryHintOf("app", "base")).toBeNull();
40
41
  expect(rootEntryHintOf("lib", "ios")).toBeNull();
@@ -78,6 +79,28 @@ describe("lib root layout allowlist", () => {
78
79
  });
79
80
  });
80
81
 
82
+ describe("hand-written facet index", () => {
83
+ test("flags every index Bun resolves ahead of the generated barrel, at the root of each barrel facet", () => {
84
+ for (const facet of ["ui", "webkit", "srvkit", "common", "plugin"])
85
+ for (const filename of ["index.tsx", "index.jsx", "index.mts"])
86
+ expect(facetIndexShadowOf(`${facet}/${filename}`)).toContain(`shadows the generated ${facet}/index.ts`);
87
+ expect(facetIndexShadowOf("ui/index.tsx")).toContain("Delete it");
88
+ });
89
+
90
+ test("leaves the generated barrel, a folder namespace, and folders without a barrel alone", () => {
91
+ for (const relativePath of [
92
+ "ui/index.ts",
93
+ "ui/index.js",
94
+ "ui/Chat/index.tsx",
95
+ "ui/Chat/index_.tsx",
96
+ "page/index.tsx",
97
+ "lib/index.tsx",
98
+ "index.tsx",
99
+ ])
100
+ expect(facetIndexShadowOf(relativePath)).toBeNull();
101
+ });
102
+ });
103
+
81
104
  describe("lib facet root allowlist", () => {
82
105
  test("admits the generated support facets and a root signal test", () => {
83
106
  expect(isAllowedLibFacetRootFile("cnst.ts")).toBe(true);
@@ -84,7 +84,24 @@ const libFacetRootTestPattern = /^[A-Za-z][A-Za-z0-9_-]*\.signal\.(test|spec)\.(
84
84
  export const isAllowedLibFacetRootFile = (filename: string) =>
85
85
  libFacetRootAllowedFiles.has(filename) || libFacetRootTestPattern.test(filename);
86
86
 
87
- //* What a Capacitor app kept in its root. The native runtime generates its projects under `.akan/native/<target>`, so
87
+ export const barrelFacetDirs = ["ui", "webkit", "srvkit", "common", "plugin"] as const;
88
+
89
+ //* Bun resolves a folder import to `index.tsx`, `index.jsx` or `index.mts` ahead of `index.ts`, and TypeScript to
90
+ //* `index.ts` first, so one of these beside the generated barrel typechecks every name the barrel adds and serves none.
91
+ const barrelShadowingIndexFiles = new Set(["index.tsx", "index.jsx", "index.mts"]);
92
+
93
+ export const facetIndexShadowOf = (sysRelativePath: string) => {
94
+ const [facet, filename = "", ...nested] = sysRelativePath.split("/");
95
+ if (nested.length || !barrelShadowingIndexFiles.has(filename) || !barrelFacetDirs.some((dir) => dir === facet))
96
+ return null;
97
+ return (
98
+ `shadows the generated ${facet}/index.ts: Bun loads ${filename} and TypeScript checks index.ts, so a name only ` +
99
+ `the generated barrel exports typechecks and is undefined at runtime. Delete it, since the generated barrel ` +
100
+ `re-exports every file and folder in ${facet}/; a namespace it builds belongs in ${facet}/<Folder>/index.tsx`
101
+ );
102
+ };
103
+
104
+ //* What a Capacitor app kept in its root. The native runtime generates its projects under `dist/native/<app>/<target>`, so
88
105
  //* these are named as leftovers instead of as unknown entries.
89
106
  const retiredAppRootEntries = new Set([
90
107
  "android",
@@ -97,7 +114,7 @@ const retiredAppRootEntries = new Set([
97
114
 
98
115
  export const rootEntryHintOf = (type: SysType, name: string) =>
99
116
  type === "app" && retiredAppRootEntries.has(name)
100
- ? "a Capacitor-era entry; the native runtime generates its projects under .akan/native/<target>, so delete it"
117
+ ? "a Capacitor-era entry; the native runtime generates its projects under dist/native/<app>/<target>, so delete it"
101
118
  : null;
102
119
 
103
120
  // scanSync reads roots through `Bun.Glob("*")`, which skips dotfiles, so doctor skips them the same way.