@akanjs/devkit 3.0.0 → 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 (67) hide show
  1. package/CHANGELOG.md +1832 -0
  2. package/akanConfig/akanConfig.test.ts +3 -0
  3. package/akanConfig/akanConfig.ts +3 -1
  4. package/akanContext.ts +18 -1
  5. package/artifact/implicitRootLayout.test.ts +2 -1
  6. package/artifact/implicitRootLayout.ts +2 -1
  7. package/artifact/routeSeedIndex.test.ts +0 -14
  8. package/biome.base.json +12 -1
  9. package/commandDecorators/command.ts +3 -2
  10. package/commandDecorators/commandDecorators.test.ts +7 -0
  11. package/csrE2e/csrE2eHarness.fixture.ts +3 -2
  12. package/csrE2e/csrPageStack.e2e.test.ts +25 -3
  13. package/executors.test.ts +329 -4
  14. package/executors.ts +49 -47
  15. package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
  16. package/frontendBuild/csrArtifactBuilder.ts +14 -8
  17. package/frontendBuild/csrDevModuleCompiler.ts +0 -1
  18. package/frontendBuild/csrDevResolver.ts +0 -2
  19. package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
  20. package/frontendBuild/devGeneratedIndexSync.ts +21 -4
  21. package/frontendBuild/frontendBuild.test.ts +1 -1
  22. package/frontendBuild/hmrWatcher.ts +6 -0
  23. package/integration/devStability.integration.test.ts +8 -5
  24. package/integration/devStabilityHarness.ts +2 -0
  25. package/jsonFormatter.test.ts +64 -0
  26. package/jsonFormatter.ts +78 -0
  27. package/lint/__fixtures__/README.md +12 -3
  28. package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
  29. package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
  30. package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
  31. package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
  32. package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
  33. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +43 -0
  34. package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
  35. package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -0
  36. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
  37. package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
  38. package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
  39. package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
  40. package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
  41. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
  42. package/lint/gritRules.test.ts +23 -15
  43. package/lint/no-double-brace-placeholder.grit +15 -0
  44. package/lint/no-inline-color.grit +21 -5
  45. package/lint/no-static-in-object-light-model.grit +27 -0
  46. package/lint/no-throw-raw-error.grit +18 -33
  47. package/lint/no-unguarded-endpoint.grit +73 -0
  48. package/manifestDependencies.ts +65 -0
  49. package/mobile/desktopBin.test.ts +1 -1
  50. package/mobile/desktopServerStage.test.ts +7 -4
  51. package/mobile/nativeApp.test.ts +57 -8
  52. package/mobile/nativeApp.ts +27 -9
  53. package/mobile/nativeConfig.test.ts +26 -4
  54. package/mobile/nativeConfig.ts +19 -1
  55. package/package.json +2 -2
  56. package/platformTest/LinuxDockerTarget.ts +1 -1
  57. package/platformTest/PlatformTestTarget.ts +3 -1
  58. package/platformTest/WindowsSshTarget.ts +1 -1
  59. package/qualityScanner.test.ts +51 -0
  60. package/qualityScanner.ts +22 -1
  61. package/scanInfo.ts +14 -2
  62. package/subspace.test.ts +10 -1
  63. package/testHelpers.ts +17 -0
  64. package/transforms/transforms.test.ts +13 -0
  65. package/tsconfig.json +4 -15
  66. package/workspaceLayout.test.ts +24 -1
  67. package/workspaceLayout.ts +19 -2
package/executors.ts CHANGED
@@ -44,13 +44,16 @@ import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from
44
44
  import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
45
45
  import { CodegenLock } from "./codegenLock";
46
46
  import { FileSys, getDirname } from "./fileSys";
47
+ import { JsonFormatter } from "./jsonFormatter";
47
48
  import { Linter } from "./linter";
49
+ import { ManifestDependencies } from "./manifestDependencies";
48
50
  import { resolveRepoName } from "./repoIdentity";
49
- import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
51
+ import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
50
52
  import { Spinner } from "./spinner";
51
53
  // Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
52
54
  import type { TypeChecker } from "./typeChecker";
53
55
  import type { FileContent, PackageJson, TsConfigJson } from "./types";
56
+ import { barrelFacetDirs } from "./workspaceLayout";
54
57
 
55
58
  export interface PageRoot {
56
59
  /** App-relative location of the route files, i.e. the symlink for a synced lib. */
@@ -303,8 +306,7 @@ export class Executor {
303
306
  const writePath = this.getPath(filePath);
304
307
  const dir = path.dirname(writePath);
305
308
  if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
306
- //? Biome ends every .json with a newline; without one, `akan lint` and `akan sync` would flip-flop the file.
307
- let contentStr = typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`;
309
+ let contentStr = typeof content === "string" ? content : JsonFormatter.stringify(content, writePath);
308
310
 
309
311
  if (await FileSys.fileExists(writePath)) {
310
312
  const currentContent = await FileSys.readText(writePath);
@@ -839,8 +841,6 @@ interface SysExecutorOptions extends NamedExecutorOptions {
839
841
  type: "app" | "lib";
840
842
  }
841
843
 
842
- const scanFacetDirs = ["ui", "webkit", "srvkit", "common", "plugin"] as const;
843
-
844
844
  export class SysExecutor extends Executor {
845
845
  workspace: WorkspaceExecutor;
846
846
  override name: string;
@@ -896,7 +896,7 @@ export class SysExecutor extends Executor {
896
896
  this._applyTemplate({ basePath: ".", template: "server.ts", scanInfo }),
897
897
  this._applyTemplate({ basePath: ".", template: "client.ts", scanInfo }),
898
898
  this.type === "lib" ? this._applyTemplate({ basePath: ".", template: "index.ts", scanInfo }) : null,
899
- ...scanFacetDirs.map((facet) =>
899
+ ...barrelFacetDirs.map((facet) =>
900
900
  this._applyTemplate({
901
901
  basePath: facet,
902
902
  template: "facetIndex/index.ts",
@@ -936,11 +936,9 @@ export class SysExecutor extends Executor {
936
936
  await CodegenLock.run(this.workspace.workspaceRoot, `scan:${this.name}`, async () => {
937
937
  await Promise.all(this.#getScanTemplateTasks(scanInfo));
938
938
  await this.writeJson(`akan.${this.type}.json`, scanInfo.getScanResult());
939
- if (this.type === "lib") this.#updateDependencies(scanInfo);
940
-
939
+ await this.#updateDependencies(scanInfo);
941
940
  if (writeLib) {
942
941
  const libInfos = [...scanInfo.getLibInfos().values()];
943
- await this.#updateDependencies(scanInfo);
944
942
  await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
945
943
  }
946
944
  await this.syncAgentsIndex(scanInfo);
@@ -962,39 +960,35 @@ export class SysExecutor extends Executor {
962
960
  // CLAUDE.md 는 얇은 포인터라 최초 1회만 깔아준다 — 사용자가 지우거나 고친 것을 되살리지 않는다.
963
961
  if (!(await this.exists("CLAUDE.md"))) await this.writeFile("CLAUDE.md", renderScopeClaudeMd(scope));
964
962
  }
963
+ //* The root is the cached copy the dependency scan read, so a name it declares is judged by the same snapshot.
965
964
  async #updateDependencies(scanInfo: AppInfo | LibInfo) {
966
- const rootPackageJson = await this.workspace.getPackageJson();
967
- const libPackageJson = await this.getPackageJson();
968
- const dependencies = scanInfo.getScanResult().dependencies;
969
- const devDependencies = scanInfo.getScanResult().devDependencies;
970
- const dependencySet = new Set(dependencies);
971
- const devDependencySet = new Set(devDependencies);
972
- const libPkgJsonWithDeps: PackageJson = {
973
- ...libPackageJson,
974
- dependencies: {
975
- ...Object.fromEntries(
976
- Object.entries(libPackageJson.dependencies ?? {}).filter(([dep]) => !devDependencySet.has(dep)),
977
- ),
978
- ...(Object.fromEntries(
979
- dependencies
980
- .filter((dep) => rootPackageJson.dependencies?.[dep])
981
- .sort()
982
- .map((dep) => [dep, rootPackageJson.dependencies?.[dep]]),
983
- ) as Record<string, string>),
984
- },
985
- devDependencies: {
986
- ...Object.fromEntries(
987
- Object.entries(libPackageJson.devDependencies ?? {}).filter(([dep]) => !dependencySet.has(dep)),
988
- ),
989
- ...(Object.fromEntries(
990
- devDependencies
991
- .filter((dep) => rootPackageJson.dependencies?.[dep] || rootPackageJson.devDependencies?.[dep])
992
- .sort()
993
- .map((dep) => [dep, rootPackageJson.devDependencies?.[dep] ?? rootPackageJson.dependencies?.[dep]]),
994
- ) as Record<string, string>),
995
- },
996
- };
997
- await this.setPackageJson(libPkgJsonWithDeps);
965
+ const [rootPackageJson, packageJson] = await Promise.all([
966
+ this.workspace.getPackageJson(),
967
+ this.getPackageJson({ refresh: true }),
968
+ ]);
969
+ const { dependencies: runtime, devDependencies: typeOnly, akanConfig } = scanInfo.getScanResult();
970
+ const kept = new Set([
971
+ ...ManifestDependencies.keptBy(packageJson),
972
+ ...akanConfig.externalLibs,
973
+ ...akanConfig.trustedDependencies,
974
+ ]);
975
+ //? The scan leaves the framework out of its imports on purpose, so it is never a prune candidate.
976
+ const isKept = (dep: string) => kept.has(dep) || isAkanFrameworkDependency(dep);
977
+ const { dependencies, devDependencies, removed, unverifiable } = new ManifestDependencies(
978
+ rootPackageJson,
979
+ { runtime, typeOnly },
980
+ isKept,
981
+ ).realign(packageJson);
982
+ const manifestPath = `${this.type}s/${this.name}/package.json`;
983
+ if (removed.length)
984
+ this.logger.info(
985
+ `Removed from ${manifestPath}, declared by the root and imported nowhere: ${removed.join(", ")} (list one in akan.keepDependencies to keep it)`,
986
+ );
987
+ if (unverifiable.length)
988
+ this.logger.warn(
989
+ `${manifestPath} lists ${unverifiable.join(", ")}, which the root package.json does not declare, so sync cannot tell whether it is used; kept as is`,
990
+ );
991
+ await this.setPackageJson({ ...packageJson, dependencies, devDependencies });
998
992
  }
999
993
  override async getLocalFile(targetPath: string) {
1000
994
  const filePath = path.isAbsolute(targetPath) ? targetPath : `${this.type}s/${this.name}/${targetPath}`;
@@ -1119,13 +1113,21 @@ export class AppExecutor extends SysExecutor {
1119
1113
  static from(executor: SysExecutor | WorkspaceExecutor, name: string) {
1120
1114
  return new AppExecutor({ workspace: executor instanceof WorkspaceExecutor ? executor : executor.workspace, name });
1121
1115
  }
1122
- //* Not dev output: native builds and the update releases waiting to be uploaded, and the downloaded `bin` sources.
1123
- static readonly #keptOnStart = [path.join(".akan", "native"), path.join(".akan", "cache", "bin")];
1116
+ //* Not dev output: the downloaded `bin` sources.
1117
+ static readonly #keptOnStart = [path.join(".akan", "cache", "bin")];
1118
+ //* Native builds moved to dist/native/<app>; the old folder may hold a signed release nobody uploaded yet.
1119
+ static readonly #legacyNative = path.join(".akan", "native");
1124
1120
  async #removeDevOutput(dir = ".akan") {
1125
1121
  const entries = await readDirEntries(this.getPath(dir)).catch(() => [] as string[]);
1126
1122
  await Promise.all(
1127
1123
  entries.map(async (name) => {
1128
1124
  const entry = path.join(dir, name);
1125
+ if (entry === AppExecutor.#legacyNative) {
1126
+ this.logger.warn(
1127
+ `apps/${this.name}/.akan/native holds native builds from before they moved to dist/native/${this.name}: upload any release waiting in its updates folders, then delete it.`,
1128
+ );
1129
+ return;
1130
+ }
1129
1131
  if (AppExecutor.#keptOnStart.includes(entry)) return;
1130
1132
  if (AppExecutor.#keptOnStart.some((kept) => kept.startsWith(`${entry}${path.sep}`)))
1131
1133
  return await this.#removeDevOutput(entry);
@@ -1209,10 +1211,10 @@ export class AppExecutor extends SysExecutor {
1209
1211
  // Ports and the operation mode are dropped: `define` would bake them in and outrank the container's own values.
1210
1212
  if (type === "build") {
1211
1213
  const buildEnv = { ...env };
1212
- delete buildEnv.AKAN_PUBLIC_CLIENT_PORT;
1213
- delete buildEnv.AKAN_PUBLIC_SERVER_PORT;
1214
- delete buildEnv.AKAN_PUBLIC_OPERATION_MODE;
1215
- delete process.env.AKAN_PUBLIC_OPERATION_MODE;
1214
+ for (const key of ["AKAN_PUBLIC_CLIENT_PORT", "AKAN_PUBLIC_SERVER_PORT", "AKAN_PUBLIC_OPERATION_MODE"]) {
1215
+ delete buildEnv[key];
1216
+ delete process.env[key];
1217
+ }
1216
1218
  Object.assign(process.env, buildEnv);
1217
1219
  }
1218
1220
  return { env };
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { tempDirs, writeText } from "../testHelpers";
4
+
5
+ const makeTempRoot = tempDirs("akan-csr-artifact-");
6
+
7
+ describe("CsrArtifactBuilder.bundleOptions", () => {
8
+ //? A child process: Bun reads `.env` only at start, and the bug was that this snapshot outranked `define`.
9
+ test("bakes the env a native build sets on process.env, not the .env value Bun loaded at start", async () => {
10
+ const root = await makeTempRoot();
11
+ await writeText(path.join(root, ".env"), "AKAN_PUBLIC_ENV=local\n");
12
+ await writeText(path.join(root, "entry.ts"), 'console.info("envmark:" + process.env.AKAN_PUBLIC_ENV);\n');
13
+ await writeText(
14
+ path.join(root, "build.ts"),
15
+ `import { CsrArtifactBuilder } from ${JSON.stringify(path.join(import.meta.dir, "csrArtifactBuilder.ts"))};
16
+ process.env.AKAN_PUBLIC_ENV = "debug";
17
+ const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: process.env.AKAN_PUBLIC_ENV }) };
18
+ const result = await Bun.build({ ...CsrArtifactBuilder.bundleOptions(app as never, "build"), entrypoints: ["./entry.ts"] });
19
+ console.info(await result.outputs[0].text());
20
+ `,
21
+ );
22
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("AKAN_PUBLIC_")));
23
+ const proc = Bun.spawn([process.execPath, "build.ts"], { cwd: root, env, stdout: "pipe", stderr: "pipe" });
24
+ const [stdout, stderr, exitCode] = await Promise.all([
25
+ new Response(proc.stdout).text(),
26
+ new Response(proc.stderr).text(),
27
+ proc.exited,
28
+ ]);
29
+ expect(stderr).toBe("");
30
+ expect(exitCode).toBe(0);
31
+ expect(stdout).toContain("envmark:debug");
32
+ expect(stdout).not.toContain("envmark:local");
33
+ });
34
+ });
@@ -54,18 +54,11 @@ export class CsrArtifactBuilder {
54
54
  );
55
55
 
56
56
  const result = await Bun.build({
57
- target: "browser",
57
+ ...CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports),
58
58
  entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
59
59
  files: generatedFiles,
60
60
  root: this.#generatedDir,
61
61
  outdir: this.#outputDir,
62
- splitting: false,
63
- minify: true,
64
- env: "AKAN_PUBLIC_*",
65
- define: bundleDefine(this.#app, this.#command, "csr"),
66
- optimizeImports: akanConfig.optimizeImports,
67
- // A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
68
- plugins: [PagesBundleBuilder.createCssStubPlugin()],
69
62
  });
70
63
 
71
64
  if (!result.success) {
@@ -78,6 +71,19 @@ export class CsrArtifactBuilder {
78
71
  return { outputDir: this.#outputDir };
79
72
  }
80
73
 
74
+ //? No `env` option: Bun inlines the `.env` it read at process start over `define`, so a native build's `--env` lost.
75
+ static bundleOptions(app: App, command: "build" | "start", optimizeImports: string[] = []) {
76
+ return {
77
+ target: "browser" as const,
78
+ splitting: false,
79
+ minify: true,
80
+ define: bundleDefine(app, command, "csr"),
81
+ optimizeImports,
82
+ // A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
83
+ plugins: [PagesBundleBuilder.createCssStubPlugin()],
84
+ };
85
+ }
86
+
81
87
  /** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
82
88
  static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
83
89
  return CsrEntryFiles.pageEntriesForBasePath(pageEntries, basePath, basePaths);
@@ -154,7 +154,6 @@ export class CsrDevModuleCompiler {
154
154
  naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
155
155
  publicPath: this.#routePrefix,
156
156
  metafile: true,
157
- env: "AKAN_PUBLIC_*",
158
157
  //? A factory runs as a classic script, where `import.meta` is a SyntaxError that takes the whole vendor file
159
158
  //? down, and Bun's cjs output keeps `import.meta.env` and spells `import.meta.url` as this disk's `file://` path.
160
159
  define: { ...this.#context.define, "import.meta": "__akanMeta" },
@@ -88,7 +88,6 @@ export class CsrDevResolver {
88
88
  target: "browser",
89
89
  metafile: true,
90
90
  external: [...(this.#context.externals ?? [])],
91
- env: "AKAN_PUBLIC_*",
92
91
  define: this.#context.define,
93
92
  optimizeImports: this.#context.optimizeImports,
94
93
  plugins: [PagesBundleBuilder.createCssStubPlugin()],
@@ -112,7 +111,6 @@ export class CsrDevResolver {
112
111
  metafile: true,
113
112
  throw: false,
114
113
  external: [...(this.#context.externals ?? [])],
115
- env: "AKAN_PUBLIC_*",
116
114
  define: this.#context.define,
117
115
  plugins: [
118
116
  {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { tempDirs } from "../testHelpers";
5
5
  import { DevGeneratedIndexSync } from "./devGeneratedIndexSync";
@@ -40,6 +40,18 @@ describe("DevGeneratedIndexSync facet barrels", () => {
40
40
  expect(content).toBe(`export * from "./aes";\nexport * from "./cloudflareApi";\nexport * from "./storageApi";\n`);
41
41
  });
42
42
 
43
+ test("a facet with nothing to export keeps an empty barrel, and a deleted facet gets none", async () => {
44
+ const root = await makeTempRoot();
45
+ const content = await barrelFor(root, "common", { files: ["foo.helper.ts"], dirs: [], trigger: "foo.helper.ts" });
46
+ expect(content).toBe("export {};\n");
47
+
48
+ const dir = path.join(root, "libs", "util", "common");
49
+ await rm(dir, { recursive: true });
50
+ const sync = new DevGeneratedIndexSync({ workspaceRoot: root });
51
+ expect(await sync.syncForBatch([path.join(dir, "foo.helper.ts")])).toEqual({ changedFiles: [], errors: [] });
52
+ expect(await Bun.file(path.join(dir, "index.ts")).exists()).toBe(false);
53
+ });
54
+
43
55
  test("ui facet exports only clean PascalCase names", async () => {
44
56
  const content = await barrelFor(await makeTempRoot(), "ui", {
45
57
  files: ["Globe.tsx", "AkanLogo.tsx", "Globe_Dynamic.tsx", "lowerStart.tsx"],
@@ -114,7 +114,8 @@ export class DevGeneratedIndexSync {
114
114
 
115
115
  async #facetContent(dir: string): Promise<string | null> {
116
116
  const nameCasePattern = path.basename(dir) === "ui" ? FACET_PASCAL_CASE_RE : FACET_CAMEL_CASE_RE;
117
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
117
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
118
+ if (!entries) return null;
118
119
  const exportNames = entries
119
120
  .flatMap((entry) => {
120
121
  const name = entry.name;
@@ -125,8 +126,8 @@ export class DevGeneratedIndexSync {
125
126
  const exportName = name.replace(FACET_SOURCE_FILE_RE, "");
126
127
  return nameCasePattern.test(exportName) ? [exportName] : [];
127
128
  })
128
- .sort();
129
- if (exportNames.length === 0) return null;
129
+ .sort(compareExportNames);
130
+ if (exportNames.length === 0) return "export {};\n";
130
131
  return `${exportNames.map((name) => `export * from "./${name}";`).join("\n")}\n`;
131
132
  }
132
133
 
@@ -149,10 +150,26 @@ export class DevGeneratedIndexSync {
149
150
  if (await exists(path.join(dir, `${modelName}.${type}.tsx`))) fileTypes.push(type);
150
151
  }
151
152
  if (fileTypes.length === 0) return null;
152
- return `\n${fileTypes.map((type) => `import * as ${type} from "./${modelName}.${type}";`).join("\n")}\n\nexport const ${modelName} = { ${fileTypes.join(", ")} };`;
153
+ return `${fileTypes.map((type) => `import * as ${type} from "./${modelName}.${type}";`).join("\n")}\n\nexport const ${modelName} = { ${fileTypes.join(", ")} };\n`;
153
154
  }
154
155
  }
155
156
 
157
+ //? Biome's organizeImports order, or `akan lint` reorders the barrel: A < a < B < b, a digit before a letter, and a
158
+ //? run of digits compared by its length first. The facetIndex template sorts the same way.
159
+ const letterRankOf = (char: string) => char.toLowerCase().charCodeAt(0) * 2 + (char === char.toLowerCase() ? 1 : 0);
160
+ const digitRunAt = (name: string, idx: number) => /^\d+/.exec(name.slice(idx))?.[0] ?? "";
161
+ const compareExportNames = (a: string, b: string) => {
162
+ for (let idx = 0; idx < a.length && idx < b.length; ) {
163
+ const [runA, runB] = [digitRunAt(a, idx), digitRunAt(b, idx)];
164
+ if (runA && runB && runA !== runB) return runA.length - runB.length || (runA < runB ? -1 : 1);
165
+ if (runA && runB) idx += runA.length;
166
+ else if (runA || runB) return runA ? -1 : 1;
167
+ else if (a.charAt(idx) !== b.charAt(idx)) return letterRankOf(a.charAt(idx)) - letterRankOf(b.charAt(idx));
168
+ else idx++;
169
+ }
170
+ return a.length - b.length;
171
+ };
172
+
156
173
  const exists = async (file: string) =>
157
174
  stat(file)
158
175
  .then(() => true)
@@ -354,7 +354,7 @@ describe("DevGeneratedIndexSync", () => {
354
354
 
355
355
  expect(removed.errors).toEqual([]);
356
356
  expect(removed.changedFiles).toEqual([index]);
357
- expect(await Bun.file(index).exists()).toBe(false);
357
+ expect(await readFile(index, "utf8")).toBe("export {};\n");
358
358
  });
359
359
 
360
360
  test("ignores server/client folders as barrel facets", async () => {
@@ -61,6 +61,12 @@ export class HmrWatcher {
61
61
  const abs = path.resolve(root, filename.toString());
62
62
  this.#queue(abs);
63
63
  });
64
+ //? Linux emits an unreadable subdirectory as an async EPERM error event, which throws when unhandled.
65
+ //? The coverage-gap scan already warns about it, so only other errors are worth a warning here.
66
+ w.on("error", (err: NodeJS.ErrnoException) => {
67
+ if (err.code === "EACCES" || err.code === "EPERM") this.#logger.verbose(`[hmr] ${err.message}`);
68
+ else this.#logger.warn(`[hmr] watcher error under ${root}: ${err.message}`);
69
+ });
64
70
  this.#watchers.push(w);
65
71
  this.#logger.verbose(`[hmr] watching ${root}`);
66
72
  } catch (err) {
@@ -583,10 +583,13 @@ describe("dev resource budgets", () => {
583
583
  expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
584
584
  });
585
585
 
586
+ //? Between a fresh builder and one that has built a route, or the host stops enforcing it or never recycles.
587
+ //? macOS keeps the bundler arenas (~380MiB built); Linux measured 101-115MiB fresh, 157-176MiB built and settled.
588
+ const CEILING_MB = process.platform === "linux" ? 135 : 200;
589
+
586
590
  budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
587
591
  const harness = await createHarness();
588
- // Below the builder once it has served the first route build (about 380MiB), which arms the recycle.
589
- const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
592
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: String(CEILING_MB) } });
590
593
  const start = host.markLog();
591
594
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
592
595
 
@@ -598,7 +601,7 @@ describe("dev resource budgets", () => {
598
601
 
599
602
  const recycleLog = await host.waitForLogSince(
600
603
  start,
601
- /recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
604
+ new RegExp(`recycling builder pid=(\\d+) \\((rss=\\d+MiB>=${CEILING_MB}MiB after \\d+ build\\(s\\))[;)]`),
602
605
  WAIT_MS,
603
606
  );
604
607
  await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
@@ -634,7 +637,7 @@ describe("dev resource budgets", () => {
634
637
  const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
635
638
  const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
636
639
  //? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
637
- if ((settled?.rssBytes ?? 0) >= 200 * MB && !warned)
640
+ if ((settled?.rssBytes ?? 0) >= CEILING_MB * MB && !warned)
638
641
  await host.waitForLogSince(
639
642
  settledFrom,
640
643
  /ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
@@ -645,7 +648,7 @@ describe("dev resource budgets", () => {
645
648
 
646
649
  budgetTest("serves a page requested while the builder is being replaced", async () => {
647
650
  const harness = await createHarness();
648
- const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
651
+ const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: String(CEILING_MB) } });
649
652
  const port = await harness.resolvePort();
650
653
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
651
654
 
@@ -269,6 +269,8 @@ export const dictionary = serviceDictionary(["en", "ko"])
269
269
  // The dev-plan/hmr assertions match verbose-level lines.
270
270
  AKAN_PUBLIC_LOG_LEVEL: "verbose",
271
271
  NODE_NO_WARNINGS: "1",
272
+ //? CI's `.env` names the branch; any env but local makes `akan start` ssh-tunnel to that cluster's Redis.
273
+ AKAN_PUBLIC_ENV: "local",
272
274
  PORT_OFFSET: String(offset),
273
275
  // Pinned: `getDevPort()` derives from the fixture's index in `apps/`, which parallel runs keep moving.
274
276
  AKAN_DEV_PORT: String(port),
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { JsonFormatter } from "./jsonFormatter";
3
+ import { formatWithBiome, hasBiome } from "./testHelpers";
4
+
5
+ const pad = (length: number) => "x".repeat(length);
6
+ const thousands = Array.from({ length: 40 }, (_, idx) => idx * 1000);
7
+
8
+ const fixtures: Record<string, unknown> = {
9
+ collapsed: { scalars: ["accessToken", "accessLog"], empty: [], none: {}, nested: [["a"], [{}]] },
10
+ commaAtTheEdge: { a: [pad(108)], b: [pad(109)], c: [pad(109)] },
11
+ objectsInArray: { routes: [{ path: "/" }, { path: "/a" }] },
12
+ numbers: { sizes: thousands, words: thousands.map(String) },
13
+ wide: { a: ["한".repeat(54)], b: ["한".repeat(55)] },
14
+ rootArray: [pad(60), pad(60)],
15
+ };
16
+
17
+ describe("JsonFormatter", () => {
18
+ test("collapses an array that fits the line with its trailing comma, and keeps every object expanded", () => {
19
+ expect(JsonFormatter.stringify(fixtures.collapsed, "akan.lib.json")).toBe(
20
+ '{\n "scalars": ["accessToken", "accessLog"],\n "empty": [],\n "none": {},\n "nested": [["a"], [{}]]\n}\n',
21
+ );
22
+ expect(JsonFormatter.stringify(fixtures.commaAtTheEdge, "akan.lib.json").split("\n")).toEqual([
23
+ "{",
24
+ ` "a": ["${pad(108)}"],`,
25
+ ' "b": [',
26
+ ` "${pad(109)}"`,
27
+ " ],",
28
+ ` "c": ["${pad(109)}"]`,
29
+ "}",
30
+ "",
31
+ ]);
32
+ expect(JsonFormatter.stringify(fixtures.objectsInArray, "akan.lib.json")).toContain('"routes": [\n {\n');
33
+ });
34
+
35
+ test("fills an array of numbers that overflows, and keeps any other overflowing array one item a line", () => {
36
+ const [, sizesOpen, firstRow, secondRow] = JsonFormatter.stringify(fixtures.numbers, "akan.app.json").split("\n");
37
+ expect(sizesOpen).toBe(' "sizes": [');
38
+ expect(firstRow).toStartWith(" 0, 1000, 2000,");
39
+ expect(firstRow?.length).toBeLessThanOrEqual(JsonFormatter.lineWidth);
40
+ expect(secondRow).toStartWith(" 18000,");
41
+ expect(JsonFormatter.stringify(fixtures.numbers, "akan.app.json")).toContain('"words": [\n "0",\n "1000",');
42
+ });
43
+
44
+ test("measures a wide character as two columns", () => {
45
+ const [, fits, overflows] = JsonFormatter.stringify(fixtures.wide, "akan.lib.json").split("\n");
46
+ expect(fits).toBe(` "a": ["${"한".repeat(54)}"],`);
47
+ expect(overflows).toBe(' "b": [');
48
+ });
49
+
50
+ test("keeps a package.json expanded the way JSON.stringify writes it", () => {
51
+ const manifest = { name: "x", workspaces: ["pkgs/*"], dependencies: {} };
52
+ expect(JsonFormatter.stringify(manifest, "libs/x/package.json")).toBe(`${JSON.stringify(manifest, null, 2)}\n`);
53
+ });
54
+
55
+ test.skipIf(!hasBiome)("prints what Biome prints", async () => {
56
+ expect(await formatWithBiome('{"a":[1]}', "libs/fixture/akan.lib.json")).toBe('{ "a": [1] }\n');
57
+ for (const [name, fixture] of Object.entries(fixtures)) {
58
+ const printed = JsonFormatter.stringify(fixture, `libs/fixture/${name}.json`);
59
+ expect(await formatWithBiome(printed, `libs/fixture/${name}.json`)).toBe(printed);
60
+ }
61
+ const manifest = JsonFormatter.stringify({ name: "x", workspaces: ["a", "b"] }, "package.json");
62
+ expect(await formatWithBiome(manifest, "libs/fixture/package.json")).toBe(manifest);
63
+ });
64
+ });
@@ -0,0 +1,78 @@
1
+ import path from "node:path";
2
+
3
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
4
+
5
+ interface Placement {
6
+ column: number;
7
+ trailing: number;
8
+ }
9
+
10
+ //* Prints what `biome format` would, or `akan lint` and `akan sync` flip-flop a generated .json. Biome keeps a
11
+ //* package.json fully expanded (`expand: always`); elsewhere (`auto`) it keeps an object expanded, collapses an array
12
+ //* that fits the 120-column line (a trailing comma included), fills an array of numbers, and ends with a newline.
13
+ export class JsonFormatter {
14
+ static readonly lineWidth = 120;
15
+
16
+ static stringify(value: unknown, filePath: string) {
17
+ const json = JSON.stringify(value, null, 2);
18
+ if (path.basename(filePath) === "package.json") return `${json}\n`;
19
+ return `${JsonFormatter.#print(JSON.parse(json) as JsonValue, 0, { column: 0, trailing: 0 })}\n`;
20
+ }
21
+
22
+ static #print(value: JsonValue, depth: number, placement: Placement): string {
23
+ if (Array.isArray(value)) return JsonFormatter.#printArray(value, depth, placement);
24
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
25
+ const entries = Object.entries(value);
26
+ if (!entries.length) return "{}";
27
+ const indent = " ".repeat(depth + 1);
28
+ const members = entries.map(([key, member], idx) => {
29
+ const prefix = `${indent}${JSON.stringify(key)}: `;
30
+ const trailing = idx < entries.length - 1 ? 1 : 0;
31
+ return `${prefix}${JsonFormatter.#print(member, depth + 1, { column: Bun.stringWidth(prefix), trailing })}`;
32
+ });
33
+ return `{\n${members.join(",\n")}\n${" ".repeat(depth)}}`;
34
+ }
35
+
36
+ static #printArray(items: JsonValue[], depth: number, { column, trailing }: Placement) {
37
+ if (!items.length) return "[]";
38
+ const flat = JsonFormatter.#flat(items);
39
+ if (flat !== null && column + Bun.stringWidth(flat) + trailing <= JsonFormatter.lineWidth) return flat;
40
+ const indent = " ".repeat(depth + 1);
41
+ const isLast = (idx: number) => idx === items.length - 1;
42
+ const body = items.every((item) => typeof item === "number")
43
+ ? JsonFormatter.#fill(
44
+ items.map((item, idx) => `${JSON.stringify(item)}${isLast(idx) ? "" : ","}`),
45
+ indent,
46
+ )
47
+ : items
48
+ .map((item, idx) => {
49
+ const placement = { column: indent.length, trailing: isLast(idx) ? 0 : 1 };
50
+ return `${indent}${JsonFormatter.#print(item, depth + 1, placement)}`;
51
+ })
52
+ .join(",\n");
53
+ return `[\n${body}\n${" ".repeat(depth)}]`;
54
+ }
55
+
56
+ static #fill(tokens: string[], indent: string) {
57
+ const lines: string[] = [];
58
+ let line = "";
59
+ for (const token of tokens) {
60
+ if (line && Bun.stringWidth(line) + 1 + Bun.stringWidth(token) <= JsonFormatter.lineWidth) {
61
+ line = `${line} ${token}`;
62
+ continue;
63
+ }
64
+ if (line) lines.push(line);
65
+ line = `${indent}${token}`;
66
+ }
67
+ return [...lines, line].join("\n");
68
+ }
69
+
70
+ static #flat(value: JsonValue): string | null {
71
+ if (Array.isArray(value)) {
72
+ const items = value.map((item) => JsonFormatter.#flat(item));
73
+ return items.includes(null) ? null : `[${items.join(", ")}]`;
74
+ }
75
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
76
+ return Object.keys(value).length ? null : "{}";
77
+ }
78
+ }
@@ -7,9 +7,12 @@ fixture` fails when a rule is added without one.
7
7
 
8
8
  ## Contract
9
9
 
10
- - One case per line. `// @flag` marks a line the rule must report in `bad`; `// @ok` marks a line in `good`.
11
- - The `bad` assertion is set equality, not containment: a marked line nobody reported is a pattern that
12
- stopped matching, and a reported line nobody marked is the rule reaching further than the fixture claims.
10
+ - One case per line. In `bad`, `// @flag` marks a line the rule must report as an error and `// @warn` one it
11
+ must report as a warning; `// @ok` marks a line in `good`.
12
+ - The `bad` assertion is exact, not containment: every marked line is reported once, at its marker's severity,
13
+ and nothing else is. A marked line nobody reported is a pattern that stopped matching, a reported line nobody
14
+ marked is the rule reaching further than the fixture claims, a line reported twice is one site reported
15
+ twice, and a different severity is a tier that moved.
13
16
  - `good` is the half that matters most: an over-matching rule is what makes developers distrust the gate.
14
17
  - `fixture.json` is optional:
15
18
  - `path` — where the source is written inside the temp workspace, for a rule that reads `$filename`
@@ -35,6 +38,12 @@ Each of these made a rule silently report nothing, and each was found by adding
35
38
  that is `no-bang-comment-in-client`'s blind spot, and why its fixture puts the marker between statements.
36
39
  - A regex capture (`r"..."($var)`) carries no source range: a diagnostic spanned on one gets no location,
37
40
  and `$var` does not interpolate into a `message`.
41
+ - A code snippet also matches JSX text that parses as it: `` `new Error` `` reports `<code>new Error</code>` in a
42
+ docs page. Where a rule meets `.tsx`, match the node kind instead (`JsNewExpression(callee = `Error`)`).
43
+ - A regex is anchored by bare concatenation, so a top-level alternation leaks out of the anchors: `r"fill|stroke"`
44
+ is `^fill|stroke$` and matches `fillOpacity`. Group it: `r"(?:fill|stroke)"`.
45
+ - A `\"` in a GritQL string is unescaped twice and corrupts it: messages quote with `'`, and a regex spells the
46
+ character `\x22`.
38
47
 
39
48
  The five vocabulary fixtures were ported from `frontendBuild/styleGuard.test.ts`, the pre-grit
40
49
  implementation of the same rules, when that scanner was retired.
@@ -0,0 +1,22 @@
1
+ export const dictionary = modelDictionary(["en", "ko"])
2
+ .of((t) => t(["Video Board", "비디오 보드"]).desc(["A board of cuts", "컷 보드"]))
3
+ .error({
4
+ videoBlockedByPolicy: [
5
+ "The provider refused this video: {{reason}}", // @flag
6
+ "제공자가 거절했습니다: {{reason}}", // @flag
7
+ ],
8
+ invalidValues: [
9
+ "The device rejects these values: {{fields}} ({{command}}).", // @flag
10
+ "기기가 받지 않는 값입니다: {{fields}} ({{command}}).", // @flag
11
+ ],
12
+ })
13
+ .translate({
14
+ greeting: [
15
+ `Hello {{name}}`, // @flag
16
+ `안녕하세요 {{name}}`, // @flag
17
+ ],
18
+ turnSteps: [
19
+ `Turn ${"left"} in {{steps}} steps`, // @flag
20
+ "{{steps}}걸음 뒤 회전", // @flag
21
+ ],
22
+ });
@@ -0,0 +1 @@
1
+ { "path": "apps/demo/lib/videoBoard/videoBoard.dictionary.ts" }
@@ -0,0 +1,15 @@
1
+ export const dictionary = modelDictionary(["en", "ko"])
2
+ .of((t) => t(["Video Board", "비디오 보드"]).desc(["Cuts name a {{slot}} the user types", "컷에 {{슬롯}}을 적습니다"])) // @ok
3
+ .model((t) => ({
4
+ refAssets: t(["Ref Assets", "참고 자산"]).desc(["Each plays a {{slot}} of the plan", "기획안의 {{슬롯}}을 맡습니다"]), // @ok
5
+ }))
6
+ .endpoint((fn) => ({
7
+ bindSlot: fn(["Bind Slot", "슬롯 연결"]).desc(["Bind an asset to a {{slot}}", "{{슬롯}}에 자산을 연결합니다"]), // @ok
8
+ }))
9
+ .error({
10
+ videoBlockedByPolicy: ["The provider refused this video: {reason}", "제공자가 거절했습니다: {reason}"], // @ok
11
+ })
12
+ .translate({
13
+ greeting: [`Hello {name}`, `안녕하세요 {name}`], // @ok
14
+ bracesExplained: ["Wrap a name in { and }", "이름을 { 와 }로 감쌉니다"], // @ok
15
+ });