@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
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 () => {
@@ -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
+ });
@@ -1,3 +1,20 @@
1
1
  export const HexInStyleObject = () => <div style={{ color: "#fff" }} />; // @flag
2
2
  export const RgbInStyleObject = () => <div style={{ background: "rgb(0,0,0)" }} />; // @flag
3
3
  export const ColorLiteralInStyleTag = () => <style>{`.x { color: #abcdef; }`}</style>; // @flag
4
+ export const FillInAStyleObject = () => <path style={{ fill: "#2b7fff" }} />; // @flag
5
+ export const FillAttribute = () => <path fill="#2b7fff" />; // @warn
6
+ export const StrokeAttribute = () => <path stroke="#909090" />; // @warn
7
+ export const StopColorAttribute = () => <stop stopColor="rgba(255,72,64,0)" />; // @warn
8
+ export const FloodColorAttribute = () => <feDropShadow floodColor="#000" />; // @warn
9
+ export const LightingColorAttribute = () => <feDiffuseLighting lightingColor="hsl(0 0% 100%)" />; // @warn
10
+ export const ColorAttribute = () => <svg color="#5bbad5" />; // @warn
11
+ export const ComponentFillProp = () => <Icon fill="#ffd700" />; // @warn
12
+ export const ExpressionValue = () => <path fill={isActive ? "#ffffff" : fillColor} />; // @warn
13
+ export const TemplateValue = () => <path stroke={`rgb(${red}, 0, 0)`} />; // @warn
14
+ export const GradientWithFallback = () => <path fill="url(#fade) #2b7fff" />; // @warn
15
+ export const StyleWrite = (path: SVGPathElement) => { path.style.fill = "#2b7fff"; }; // @warn
16
+ export const StyleFilterWrite = (path: SVGPathElement) => { path.style.filter = "drop-shadow(0 0 12px rgba(96, 165, 250, 0.7))"; }; // @warn
17
+ export const NestedStyleWrite = (ref: { current: HTMLElement }) => { ref.current.style.background = "#fff"; }; // @warn
18
+ export const ComputedStyleWrite = (el: HTMLElement) => { el.style["background-color"] = "#fff"; }; // @warn
19
+ export const TemplateStyleWrite = (el: HTMLElement) => { el.style.boxShadow = `0 0 ${blur}px rgba(0, 0, 0, 0.3)`; }; // @warn
20
+ export const SetPropertyWrite = (el: HTMLElement) => { el.style.setProperty("--glow", "#60a5fa"); }; // @warn
@@ -1,3 +1,16 @@
1
1
  export const VarReferenceInStyleObject = () => <div style={{ color: "var(--primary)" }} />; // @ok
2
2
  export const NonColorStyleProperty = () => <div style={{ width: "100%" }} />; // @ok
3
3
  export const ComputedNonColorStyle = () => <div style={{ minHeight }} />; // @ok
4
+ export const CurrentColorFill = () => <path fill="currentColor" className="text-primary" />; // @ok
5
+ export const NoneAndTransparent = () => <path fill="none" stroke="transparent" />; // @ok
6
+ export const VarStopColor = () => <stop stopColor="var(--primary)" />; // @ok
7
+ export const RuntimeFill = () => <path fill={fillColor} />; // @ok
8
+ export const GradientReference = () => <path fill="url(#fade)" />; // @ok
9
+ export const QuotedGradientReference = () => <path fill="url('#bead')" />; // @ok
10
+ export const HrefIsNotAColorAttribute = () => <use href="#cafe" />; // @ok
11
+ export const VarStyleWrite = (el: HTMLElement) => { el.style.color = "var(--primary)"; }; // @ok
12
+ export const CurrentColorStyleWrite = (el: HTMLElement) => { el.style.fill = "currentColor"; }; // @ok
13
+ export const NonColorStyleWrite = (el: HTMLElement) => { el.style.display = "none"; }; // @ok
14
+ export const FilterReferenceStyleWrite = (el: HTMLElement) => { el.style.filter = "url(#dead)"; }; // @ok
15
+ // biome-ignore lint/plugin: the Google mark keeps its brand colors
16
+ export const SuppressedBrandMark = () => <path fill="#4285f4" />; // @ok
@@ -2,3 +2,11 @@ export const throwsNewError = () => { throw new Error("boom"); }; // @flag
2
2
  export const throwsBareError = () => { throw Error("boom"); }; // @flag
3
3
  export const throwsWithCause = () => { throw new Error("boom", { cause: reason }); }; // @flag
4
4
  export const throwsWithNoArgument = () => { throw new Error(); }; // @flag
5
+ export const throwsWithoutParens = () => { throw new Error; }; // @flag
6
+ export const rejectsInAPromise = () => new Promise((_, reject) => reject(new Error(`k8s ${path} failed`))); // @warn
7
+ export const rejectsDirectly = () => Promise.reject(new Error("boom")); // @warn
8
+ export const throwsAStoredError = () => { const error = new Error("boom"); throw error; }; // @warn
9
+ export const returnsAnError = () => { return new Error("boom"); }; // @warn
10
+ export const callsWithoutNew = () => Error("boom"); // @warn
11
+ export const causesAnErr = () => { throw new Err("ticket.error.failed", { cause: new Error("boom") }); }; // @warn
12
+ export const throwsAWrappedError = () => { throw wrapError(new Error("boom")); }; // @warn