@akanjs/devkit 3.0.0-alpha.71 → 3.0.0-alpha.72

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 (117) hide show
  1. package/akanApp/BackendImportGraph.test.ts +120 -0
  2. package/akanApp/BackendImportGraph.ts +167 -0
  3. package/akanApp/akanApp.host.test.ts +16 -678
  4. package/akanApp/akanApp.host.ts +65 -562
  5. package/akanApp/devHostPolicy.test.ts +542 -0
  6. package/akanApp/devHostPolicy.ts +412 -0
  7. package/akanApp/index.ts +2 -0
  8. package/akanContext.ts +17 -3
  9. package/akanMcpContract.ts +25 -4
  10. package/artifact/routeSeedIndex.test.ts +1 -0
  11. package/capacitorApp.ts +10 -0
  12. package/cloud/globalConfig.ts +9 -2
  13. package/commandDecorators/command.ts +30 -6
  14. package/commandDecorators/commandBuilder.ts +20 -5
  15. package/commandDecorators/commandDecorators.test.ts +115 -3
  16. package/dependencyScanner.test.ts +99 -0
  17. package/dependencyScanner.ts +27 -24
  18. package/devkitUtils.test.ts +0 -104
  19. package/executors.test.ts +45 -0
  20. package/executors.ts +29 -5
  21. package/fileEditor.ts +19 -19
  22. package/frontendBuild/autoImportSync.test.ts +58 -0
  23. package/frontendBuild/autoImportSync.ts +4 -0
  24. package/frontendBuild/buildRouteClient.test.ts +19 -22
  25. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  26. package/frontendBuild/fontOptimizer.ts +37 -18
  27. package/frontendBuild/index.ts +0 -1
  28. package/frontendBuild/styleContract.ts +14 -20
  29. package/frontendBuild/themeValidator.ts +22 -17
  30. package/incrementalBuilder/devWatchBatch.ts +6 -2
  31. package/incrementalBuilder/incrementalBuilder.proc.ts +6 -2
  32. package/index.ts +0 -5
  33. package/lint/__fixtures__/README.md +40 -0
  34. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  35. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  36. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  37. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  38. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  39. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  40. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  41. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  42. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  43. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  44. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  45. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  46. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  47. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  48. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  49. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  50. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  51. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  52. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  53. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  54. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  55. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  56. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  57. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  58. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  59. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  60. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  61. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  62. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  63. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  64. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  65. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  66. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  67. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  68. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  69. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  70. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  71. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  72. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  73. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  74. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  75. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  76. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  77. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  78. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  79. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  80. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  81. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  82. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  83. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  84. package/lint/gritRules.test.ts +178 -0
  85. package/lint/no-arbitrary-color.grit +1 -1
  86. package/lint/no-bang-comment-in-client.grit +23 -10
  87. package/lint/no-daisyui-legacy-class.grit +2 -2
  88. package/lint/no-inline-color.grit +10 -8
  89. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  90. package/lint/no-raw-palette-class.grit +3 -3
  91. package/lint/non-scalar-props-restricted.grit +16 -7
  92. package/linter.test.ts +80 -0
  93. package/linter.ts +82 -13
  94. package/package.json +3 -3
  95. package/prompter.ts +9 -4
  96. package/qualityScanner.test.ts +64 -0
  97. package/qualityScanner.ts +43 -3
  98. package/recipeScanner.ts +4 -1
  99. package/scanInfo.ts +3 -3
  100. package/ssrScanner.test.ts +301 -0
  101. package/transforms/barrelImportsPlugin.ts +1 -1
  102. package/transforms/transforms.test.ts +5 -5
  103. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  104. package/tsconfig.json +5 -1
  105. package/typeChecker.ts +1 -1
  106. package/types.ts +1 -0
  107. package/ui/ScrollList.tsx +6 -8
  108. package/uploadRelease.ts +2 -2
  109. package/workflow/executor.test.ts +146 -0
  110. package/builder.ts +0 -164
  111. package/extractDeps.ts +0 -86
  112. package/frontendBuild/styleGuard.test.ts +0 -180
  113. package/frontendBuild/styleGuard.ts +0 -339
  114. package/getCredentials.ts +0 -17
  115. package/getModelFileData.ts +0 -62
  116. package/src/capacitorApp.ts +0 -282
  117. package/streamAi.ts +0 -45
package/fileEditor.ts CHANGED
@@ -1,23 +1,23 @@
1
1
  export class FileEditor {
2
- private filePath: string;
3
- private content: string;
2
+ #filePath: string;
3
+ #content: string;
4
4
 
5
5
  private constructor(filePath: string, content: string) {
6
- this.filePath = filePath;
7
- this.content = content;
6
+ this.#filePath = filePath;
7
+ this.#content = content;
8
8
  }
9
9
 
10
10
  static async create(filePath: string): Promise<FileEditor> {
11
11
  try {
12
12
  const content = await Bun.file(filePath).text();
13
13
  return new FileEditor(filePath, content);
14
- } catch (error) {
14
+ } catch (_error) {
15
15
  throw new Error(`Failed to read file: ${filePath}`);
16
16
  }
17
17
  }
18
18
 
19
19
  find(pattern: string | RegExp): number {
20
- const lines = this.content.split("\n");
20
+ const lines = this.#content.split("\n");
21
21
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
22
22
 
23
23
  for (let i = 0; i < lines.length; i++) {
@@ -30,7 +30,7 @@ export class FileEditor {
30
30
  }
31
31
 
32
32
  findAll(pattern: string | RegExp): number[] {
33
- const lines = this.content.split("\n");
33
+ const lines = this.#content.split("\n");
34
34
  const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
35
35
  const matches: number[] = [];
36
36
 
@@ -50,9 +50,9 @@ export class FileEditor {
50
50
  throw new Error(`Pattern not found: ${pattern}`);
51
51
  }
52
52
 
53
- const lines = this.content.split("\n");
53
+ const lines = this.#content.split("\n");
54
54
  lines.splice(lineIndex + 1, 0, data);
55
- this.content = lines.join("\n");
55
+ this.#content = lines.join("\n");
56
56
 
57
57
  return this;
58
58
  }
@@ -64,43 +64,43 @@ export class FileEditor {
64
64
  throw new Error(`Pattern not found: ${pattern}`);
65
65
  }
66
66
 
67
- const lines = this.content.split("\n");
67
+ const lines = this.#content.split("\n");
68
68
  lines.splice(lineIndex, 0, data);
69
- this.content = lines.join("\n");
69
+ this.#content = lines.join("\n");
70
70
 
71
71
  return this;
72
72
  }
73
73
 
74
74
  replace(pattern: string | RegExp, replacement: string): this {
75
75
  const regex = typeof pattern === "string" ? new RegExp(pattern, "g") : pattern;
76
- this.content = this.content.replace(regex, replacement);
76
+ this.#content = this.#content.replace(regex, replacement);
77
77
  return this;
78
78
  }
79
79
 
80
80
  append(data: string): this {
81
- this.content += `\n${data}`;
81
+ this.#content += `\n${data}`;
82
82
  return this;
83
83
  }
84
84
 
85
85
  prepend(data: string): this {
86
- this.content = `${data}\n${this.content}`;
86
+ this.#content = `${data}\n${this.#content}`;
87
87
  return this;
88
88
  }
89
89
 
90
90
  async save(): Promise<void> {
91
91
  try {
92
- await Bun.write(this.filePath, this.content);
93
- } catch (error) {
94
- throw new Error(`Failed to save file: ${this.filePath}`);
92
+ await Bun.write(this.#filePath, this.#content);
93
+ } catch (_error) {
94
+ throw new Error(`Failed to save file: ${this.#filePath}`);
95
95
  }
96
96
  }
97
97
 
98
98
  getContent(): string {
99
- return this.content;
99
+ return this.#content;
100
100
  }
101
101
 
102
102
  setContent(content: string): this {
103
- this.content = content;
103
+ this.#content = content;
104
104
  return this;
105
105
  }
106
106
  }
@@ -355,3 +355,61 @@ describe("AutoImportSync.syncForBatch", () => {
355
355
  expect(await readFile(indexFile, "utf8")).not.toContain("@libs/shared/client");
356
356
  });
357
357
  });
358
+
359
+ describe("AutoImportSync — scope", () => {
360
+ const writeAt = async (root: string, rel: string, source: string) => {
361
+ const abs = path.join(root, rel);
362
+ await mkdir(path.dirname(abs), { recursive: true });
363
+ await writeFile(abs, source);
364
+ return abs;
365
+ };
366
+
367
+ // `lib/__lib/` is generated and gitignored: `akan sync` rewrites it, which is also what makes the
368
+ // watcher hand it to the syncer, so an edit there is churn that the next sync discards.
369
+ test("leaves the generated lib/__lib stubs alone and still syncs lib/__scalar", async () => {
370
+ const root = await makeTempRoot();
371
+ const needsImport = "export class A {\n count = field(Int);\n}\n";
372
+ const generated = await writeAt(root, "libs/shared/lib/__lib/lib.constant.ts", needsImport);
373
+ const scalar = await writeAt(root, "libs/shared/lib/__scalar/money/money.constant.ts", needsImport);
374
+
375
+ const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([generated, scalar]);
376
+
377
+ expect(result.errors).toEqual([]);
378
+ expect(result.changedFiles).toEqual([scalar]);
379
+ expect(await readFile(generated, "utf8")).toBe(needsImport);
380
+ expect(await readFile(scalar, "utf8")).toContain('import { Int } from "akanjs/base";');
381
+ });
382
+
383
+ // The transform is pure and the write is conditional on a difference, so a second pass over its own
384
+ // output writes nothing — without that the watcher would re-fire on the syncer's own edit.
385
+ test("a second pass over its own output changes nothing", async () => {
386
+ const root = await makeTempRoot();
387
+ const file = await writeAt(
388
+ root,
389
+ "libs/shared/lib/task/task.constant.ts",
390
+ "export class A {\n count = field(Int);\n}\n",
391
+ );
392
+ const sync = new AutoImportSync({ workspaceRoot: root });
393
+
394
+ expect((await sync.syncForBatch([file])).changedFiles).toEqual([file]);
395
+ const afterFirst = await readFile(file, "utf8");
396
+ expect((await sync.syncForBatch([file])).changedFiles).toEqual([]);
397
+ expect(await readFile(file, "utf8")).toBe(afterFirst);
398
+ });
399
+
400
+ // One try/catch per file: a file that cannot be read is reported and the rest of the batch still runs.
401
+ test("a failing file is reported without abandoning the batch", async () => {
402
+ const root = await makeTempRoot();
403
+ const missing = path.join(root, "libs/shared/lib/task/gone.constant.ts");
404
+ const good = await writeAt(
405
+ root,
406
+ "libs/shared/lib/task/task.constant.ts",
407
+ "export class A {\n count = field(Int);\n}\n",
408
+ );
409
+
410
+ const result = await new AutoImportSync({ workspaceRoot: root }).syncForBatch([missing, good]);
411
+
412
+ expect(result.changedFiles).toEqual([good]);
413
+ expect(result.errors).toEqual([]);
414
+ });
415
+ });
@@ -250,6 +250,10 @@ export class AutoImportSync {
250
250
  const parts = rel.split(path.sep).filter(Boolean);
251
251
  const [scope, project, facet] = parts;
252
252
  if ((scope !== "apps" && scope !== "libs") || !project || !facet) return null;
253
+ //* `lib/__lib/` holds the generated per-lib re-export stubs — gitignored, and rewritten by every
254
+ //* `akan sync`, which is also what makes the watcher report them. An edit there is churn at best and
255
+ //* silently discarded at worst. `lib/__scalar/` is real source and stays in scope.
256
+ if (facet === "lib" && parts[3] === "__lib") return null;
253
257
  const role = roleFor(facet, base);
254
258
  if (!role) return null;
255
259
 
@@ -159,28 +159,25 @@ describe("route client store bootstrap", () => {
159
159
  await write(uiEntry, 'export { ClientPathWrapper } from "./System/Client";\n');
160
160
  await write(clientEntry, '"use client";\nexport const ClientPathWrapper = () => null;\n');
161
161
 
162
- const discovery = new GraphClientEntryDiscovery(
163
- { barrelImports: ["akanjs/ui"], externalLibs: [], optimizeImports: true },
164
- async (specifier) => {
165
- if (specifier === "akanjs/ui") {
166
- return {
167
- pkgName: "akanjs/ui",
168
- entryFile: uiEntry,
169
- pkgDir: path.dirname(uiEntry),
170
- preserveFilePath: true,
171
- };
172
- }
173
- if (specifier === "akanjs/ui/System/Client.tsx") {
174
- return {
175
- pkgName: "akanjs/ui/System/Client.tsx",
176
- entryFile: clientEntry,
177
- pkgDir: path.dirname(clientEntry),
178
- preserveFilePath: true,
179
- };
180
- }
181
- return null;
182
- },
183
- );
162
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: ["akanjs/ui"] }, async (specifier) => {
163
+ if (specifier === "akanjs/ui") {
164
+ return {
165
+ pkgName: "akanjs/ui",
166
+ entryFile: uiEntry,
167
+ pkgDir: path.dirname(uiEntry),
168
+ preserveFilePath: true,
169
+ };
170
+ }
171
+ if (specifier === "akanjs/ui/System/Client.tsx") {
172
+ return {
173
+ pkgName: "akanjs/ui/System/Client.tsx",
174
+ entryFile: clientEntry,
175
+ pkgDir: path.dirname(clientEntry),
176
+ preserveFilePath: true,
177
+ };
178
+ }
179
+ return null;
180
+ });
184
181
 
185
182
  expect(await discovery.discover([seed])).toEqual([clientEntry]);
186
183
  });
@@ -34,7 +34,7 @@ const shouldSkipNodeModule = (absPath: string) => NODE_MODULES_RE.test(absPath)
34
34
  * the traversal matches the module graph the bundler will actually see.
35
35
  */
36
36
  export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
37
- #akanConfig: AkanConfig;
37
+ #akanConfig: Pick<AkanConfig, "barrelImports">;
38
38
  #resolvePackage: PackageResolver;
39
39
  #analyzer: BarrelAnalyzer;
40
40
  #tsTranspiler = new Bun.Transpiler({ loader: "tsx" });
@@ -57,7 +57,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
57
57
  #unresolvedPaths = new Set<string>();
58
58
  #unresolvedSpecifiers = new Set<string>();
59
59
 
60
- constructor(akanConfig: AkanConfig, resolvePackage: PackageResolver) {
60
+ constructor(akanConfig: Pick<AkanConfig, "barrelImports">, resolvePackage: PackageResolver) {
61
61
  this.#akanConfig = akanConfig;
62
62
  this.#resolvePackage = resolvePackage;
63
63
  this.#analyzer = new BarrelAnalyzer({ resolvePackage });
@@ -43,7 +43,9 @@ export class FontOptimizer {
43
43
  #woff2Ready: Promise<void> | null = null;
44
44
 
45
45
  static #ksX1001Text: string | null = null;
46
- static readonly #cacheVersion = 1;
46
+ // 2: the key moved to sha256 over a deterministically ordered `auto` text, so v1 entries cannot be
47
+ // compared against and their subsets were built from `page`/`ui` only.
48
+ static readonly #cacheVersion = 2;
47
49
 
48
50
  constructor(app: App, command: FontOptimizerCommand = "start") {
49
51
  this.#app = app;
@@ -96,9 +98,18 @@ export class FontOptimizer {
96
98
  }
97
99
  // `auto` derives the subset from app source text, which no font config hash can capture.
98
100
  if (this.#getFontSubsets(font).includes("auto"))
99
- sources.push({ autoSubsetText: this.#hashFontConfig(await this.#collectAutoSubsetText()) });
101
+ sources.push({ autoSubsetText: this.#cacheDigest(await this.#collectAutoSubsetText()) });
100
102
  }
101
- return this.#hashFontConfig({ version: FontOptimizer.#cacheVersion, fonts, sources });
103
+ return this.#cacheDigest({ version: FontOptimizer.#cacheVersion, fonts, sources });
104
+ }
105
+
106
+ /**
107
+ * Cache keys get a cryptographic digest, not the 32-bit FNV `#hashFontConfig` computes for filenames:
108
+ * a collision there serves a stale subset as if it were current, while a filename only has to be short
109
+ * and stable.
110
+ */
111
+ #cacheDigest(value: unknown) {
112
+ return new Bun.CryptoHasher("sha256").update(this.#stableStringify(value)).digest("hex");
102
113
  }
103
114
 
104
115
  async #fileStamp(filePath: string): Promise<{ mtimeMs: number; size: number } | null> {
@@ -387,26 +398,34 @@ export class FontOptimizer {
387
398
  return "";
388
399
  }
389
400
 
401
+ /**
402
+ * Every source that can put a glyph on screen, concatenated in a **stable** order.
403
+ *
404
+ * The order is load-bearing even though a glyph set is not: `#buildCacheKey` hashes this string, so
405
+ * reading the roots concurrently and pushing as each file resolved made the key depend on i/o
406
+ * scheduling — measured 8 distinct keys over 8 runs against unchanged sources, which means the cache
407
+ * never hit and every build re-subset the fonts.
408
+ *
409
+ * `lib` is in the roots because that is where user-facing text actually lives: a dictionary's
410
+ * `[en, ko]` pairs are the Korean in the app, and a subset built from `page` and `ui` alone renders
411
+ * them as tofu — while hashing the same partial text also stopped a new label from invalidating.
412
+ */
390
413
  async #collectAutoSubsetText() {
391
414
  //* Synced lib pages hold app-visible text too, and a glob never crosses the symlink that mounts them.
392
415
  const libPageRoots = (await this.#app.getPageRoots()).filter((root) => root.keyPrefix).map((root) => root.dir);
393
- const roots = [...["page", "ui"].map((dir) => path.join(this.#app.cwdPath, dir)), ...libPageRoots];
416
+ const roots = [...["page", "ui", "lib"].map((dir) => path.join(this.#app.cwdPath, dir)), ...libPageRoots];
394
417
  const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,html,md}");
395
418
  const parts: string[] = [];
396
- await Promise.all(
397
- roots.map(async (root) => {
398
- if (
399
- !(await stat(root).then(
400
- (entry) => entry.isDirectory(),
401
- () => false,
402
- ))
403
- )
404
- return;
405
- for await (const filePath of glob.scan({ cwd: root, absolute: true })) {
406
- parts.push(await Bun.file(filePath).text());
407
- }
408
- }),
409
- );
419
+ for (const root of [...new Set(roots)].sort()) {
420
+ const isDir = await stat(root).then(
421
+ (entry) => entry.isDirectory(),
422
+ () => false,
423
+ );
424
+ if (!isDir) continue;
425
+ const filePaths: string[] = [];
426
+ for await (const filePath of glob.scan({ cwd: root, absolute: true })) filePaths.push(filePath);
427
+ for (const filePath of filePaths.sort()) parts.push(await Bun.file(filePath).text());
428
+ }
410
429
  return parts.join("");
411
430
  }
412
431
 
@@ -20,7 +20,6 @@ export * from "./routesManifestArtifactSerializer";
20
20
  export * from "./sourceMtimeIndex";
21
21
  export * from "./ssrBaseArtifactBuilder";
22
22
  export * from "./styleContract";
23
- export * from "./styleGuard";
24
23
  export * from "./themeValidator";
25
24
  export * from "./vendorSpecifiers";
26
25
  export * from "./watchRootResolver";
@@ -1,29 +1,23 @@
1
1
  /**
2
- * styleGuard + themeValidator 결과를 배선(build/dev/lint)이 공유하는 포맷으로 정리한다.
3
- * severity 사다리: style 위반은 severity==="error" 인 것만, theme 위반은 전부 차단(build/CI) 대상.
4
- * dev 결과를 경고로만 출력한다.
2
+ * Reports the style contract that `akan lint` enforces, in the shape build/dev/lint share.
3
+ *
4
+ * Only contrast lives here. The vocabulary closure — raw palette classes, arbitrary colors, dropped
5
+ * daisyUI slots, inline color literals, interpolated arbitrary values — is enforced by the grit plugins
6
+ * in `lint/*.grit` during the biome run, so it is not re-scanned. Contrast cannot be a lint rule at all:
7
+ * it is arithmetic over resolved token *values*, which no syntactic pattern can reach.
5
8
  */
6
- import type { StyleGuardViolation } from "./styleGuard";
7
9
  import type { ThemeContrastViolation } from "./themeValidator";
8
10
 
9
11
  export interface StyleContractViolations {
10
- style: StyleGuardViolation[];
11
12
  theme: ThemeContrastViolation[];
12
13
  }
13
14
 
14
- export const countBlocking = (v: StyleContractViolations): number =>
15
- v.style.filter((s) => s.severity === "error").length + v.theme.length;
15
+ export const countBlocking = (violations: StyleContractViolations): number => violations.theme.length;
16
16
 
17
- export const formatStyleContract = (v: StyleContractViolations): string => {
18
- const lines: string[] = [];
19
- for (const s of v.style) {
20
- lines.push(` [${s.severity}] ${s.rule} ${s.path}:${s.line}`);
21
- lines.push(` ${s.snippet}`);
22
- lines.push(` → ${s.suggestion}`);
23
- }
24
- for (const t of v.theme) {
25
- lines.push(` [error] contrast ${t.scope} ${t.pair} = ${t.ratio}:1 (min ${t.threshold}:1)`);
26
- lines.push(` → ${t.suggestion}`);
27
- }
28
- return lines.join("\n");
29
- };
17
+ export const formatStyleContract = (violations: StyleContractViolations): string =>
18
+ violations.theme
19
+ .flatMap((theme) => [
20
+ ` [error] contrast ${theme.scope} ${theme.pair} = ${theme.ratio}:1 (min ${theme.threshold}:1)`,
21
+ ` ${theme.suggestion}`,
22
+ ])
23
+ .join("\n");
@@ -1,13 +1,17 @@
1
1
  /**
2
- * themeValidator 시맨틱 토큰 페어의 WCAG 콘트라스트 자동 검사 (의존성 0).
2
+ * WCAG contrast checking over the semantic token pairs. No dependencies, pure functions, and no akanjs
3
+ * runtime import — it has to run inside the lint path.
3
4
  *
4
- * 토큰 구조(`--x` `--x-foreground`)라서 가능해진 검사. AI 생성한 팔레트가 "안 읽히는 사이트"가
5
- * 되는 것을 원천 차단한다. akanjs 런타임을 import 하지 않는다 순수 함수만.
5
+ * The `--x` / `--x-foreground` pairing is what makes the check possible at all: it says which two values
6
+ * are going to end up on top of each other. That is the guard against a generated palette shipping a site
7
+ * nobody can read.
6
8
  *
7
- * 임계값(WCAG 2.1):
8
- * - 본문/주요 표면 페어(background·primary·secondary·accent·neutral·card·popover): 4.5:1 (AA normal text)
9
- * - 상태/보조 페어(info·success·warning·destructive·open·muted): 3:1 (UI 컴포넌트 / 큰 텍스트 / 보조 표면)
10
- * 현행 styles.css 기본 팔레트(light/dark) 임계값을 모두 통과한다.
9
+ * Thresholds (WCAG 2.1):
10
+ * - body and primary surfaces (background, primary, secondary, accent, neutral, card, popover): 4.5:1,
11
+ * the AA floor for normal text
12
+ * - status and secondary pairs (info, success, warning, destructive, open, muted): 3:1, the AA floor for
13
+ * UI components and large text
14
+ * The shipped light/dark palette in styles.css clears all of them.
11
15
  */
12
16
 
13
17
  export interface ThemeContrastViolation {
@@ -42,13 +46,13 @@ const PAIRS: PairDef[] = [
42
46
  { base: "open", fg: "open-foreground", threshold: 3 },
43
47
  ];
44
48
 
45
- // 검사 대상 스코프. 셀렉터(.campaign-x 스코프 토큰 등) 페어 검사에서 제외.
49
+ // Only these scopes are paired up; tokens scoped to something else (`.campaign-x`, ) are left alone.
46
50
  const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
47
51
 
48
52
  export type ThemeTokensByScope = Record<string, Record<string, string>>;
49
53
 
50
54
  export class ThemeValidator {
51
- /** CSS 문자열에서 토큰을 추출해 알려진 테마 스코프 전체를 검사한다. */
55
+ /** Extracts tokens from the css text and checks every scope it recognizes. */
52
56
  validate(css: string): ThemeContrastViolation[] {
53
57
  const tokensByScope = ThemeValidator.parseThemeTokens(css);
54
58
  const violations: ThemeContrastViolation[] = [];
@@ -67,7 +71,7 @@ export class ThemeValidator {
67
71
  if (!bg || !front) continue;
68
72
  const bgRgb = ThemeValidator.parseHex(bg);
69
73
  const fgRgb = ThemeValidator.parseHex(front);
70
- if (!bgRgb || !fgRgb) continue; // var()/비-hex 값은 검사 불가 건너뜀
74
+ if (!bgRgb || !fgRgb) continue; // a var() or non-hex value has no ratio to compute
71
75
  const ratio = ThemeValidator.contrastRatio(bgRgb, fgRgb);
72
76
  if (ratio >= threshold) continue;
73
77
  violations.push({
@@ -77,20 +81,21 @@ export class ThemeValidator {
77
81
  foreground: front,
78
82
  ratio: Math.round(ratio * 100) / 100,
79
83
  threshold,
80
- suggestion: `${scope} --${base}(${bg}) --${fg}(${front}) 대비가 ${ratio.toFixed(2)}:1 최소 ${threshold}:1 미만입니다. 한쪽을 밝게/어둡게 조정해 대비를 확보하세요.`,
84
+ suggestion: `In ${scope}, --${base} (${bg}) against --${fg} (${front}) is ${ratio.toFixed(2)}:1, under the ${threshold}:1 minimum. Lighten or darken one of them until it clears.`,
81
85
  });
82
86
  }
83
87
  return violations;
84
88
  }
85
89
 
86
90
  /**
87
- * `:root` / `[data-theme="…"]` 블록에서 `--token: value` 를 파싱한다. 그룹 셀렉터
88
- * (`:root, [data-theme="dark"] { … }`) 셀렉터에 동일 토큰을 분배. 동일 스코프 재등장 나중 값이 이긴다
89
- * (프레임워크-먼저 / 앱-나중 순서로 넘기면 override 반영됨).
91
+ * Reads `--token: value` out of `:root` / `[data-theme="…"]` blocks. A grouped selector
92
+ * (`:root, [data-theme="dark"] { … }`) distributes the same tokens to each selector, and a scope that
93
+ * appears twice keeps the later value so passing framework css first and the app's second reflects the
94
+ * app's overrides.
90
95
  */
91
96
  static parseThemeTokens(css: string): ThemeTokensByScope {
92
97
  const result: ThemeTokensByScope = {};
93
- // 중첩 없는 단순 규칙 블록만 매칭(@theme/@keyframes at-rule 셀렉터에 @ 포함이라 제외).
98
+ // Flat rule blocks only; an at-rule (@theme, @keyframes) carries `@` in the selector and is skipped.
94
99
  const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
95
100
  for (const block of css.matchAll(blockRe)) {
96
101
  const selectors = block[1].split(",").map((s) => s.trim());
@@ -109,11 +114,11 @@ export class ThemeValidator {
109
114
  }
110
115
 
111
116
  static #normalizeScope(selector: string): string {
112
- // 따옴표 정규화: [data-theme=dark] / [data-theme='dark'] [data-theme="dark"]
117
+ // Quote normalization: [data-theme=dark] / [data-theme='dark'] -> [data-theme="dark"]
113
118
  return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
114
119
  }
115
120
 
116
- /** #rgb / #rgba / #rrggbb / #rrggbbaa [r,g,b] (alpha 무시). 비-hex null. */
121
+ /** #rgb / #rgba / #rrggbb / #rrggbbaa -> [r,g,b], alpha ignored. Null for anything not hex. */
117
122
  static parseHex(value: string): [number, number, number] | null {
118
123
  const v = value.trim();
119
124
  if (!v.startsWith("#")) return null;
@@ -1,4 +1,4 @@
1
- import type { BuilderEvent, ChangeBatch } from "akanjs/server";
1
+ import type { BuilderEvent, ChangeBatch, DevChangePlan } from "akanjs/server";
2
2
  import type { DevChangePlanner, GeneratedIndexSyncResult } from "../frontendBuild";
3
3
 
4
4
  export interface PrepareDevWatchBatchOptions {
@@ -12,6 +12,8 @@ export interface PreparedDevWatchBatch {
12
12
  files: string[];
13
13
  kinds: ("code" | "css" | "config")[];
14
14
  expandedBatch: ChangeBatch;
15
+ /** The same plan the event carries, where `devPlan` is optional — read it here, not off the event. */
16
+ devPlan: DevChangePlan;
15
17
  event: Extract<BuilderEvent, { type: "invalidate" }>;
16
18
  hasSyncErrors: boolean;
17
19
  }
@@ -35,13 +37,15 @@ export const prepareDevWatchBatch = ({
35
37
  });
36
38
 
37
39
  if (indexSync.errors.length > 0 && !devPlan.actions.includes("report-error")) {
38
- devPlan.actions = [...devPlan.actions, "report-error"].sort();
40
+ const withReport: DevChangePlan["actions"] = [...devPlan.actions, "report-error"];
41
+ devPlan.actions = withReport.sort();
39
42
  }
40
43
 
41
44
  return {
42
45
  files,
43
46
  kinds,
44
47
  expandedBatch,
48
+ devPlan,
45
49
  event: { type: "invalidate", kinds, files, generation, devPlan },
46
50
  hasSyncErrors: indexSync.errors.length > 0,
47
51
  };
@@ -186,6 +186,11 @@ class IncrementalBuilder {
186
186
  this.#shuttingDown = true;
187
187
  const started = Date.now();
188
188
  this.#logger.debug(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
189
+ // Stopped before the drain, or a save landing mid-drain would enqueue a batch behind the queue tail
190
+ // this method already awaited — and `process.exit(0)` would cut that batch off partway through
191
+ // writing its artifacts. The replacement rebuilds every artifact from its boot build anyway, so the
192
+ // batch is not lost; a half-written one would be.
193
+ this.#watcher?.stop();
189
194
  if (this.#cssRebuildTimer) {
190
195
  // Only reachable if a css batch landed between the idle report and this request: the fresh
191
196
  // boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
@@ -303,13 +308,12 @@ class IncrementalBuilder {
303
308
  //* them to the watcher so its verification scan does not read them back as a user edit and spend a
304
309
  //* second generation rebuilding identical content.
305
310
  await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
306
- const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
311
+ const { files, kinds, expandedBatch, devPlan, event, hasSyncErrors } = prepareDevWatchBatch({
307
312
  generation,
308
313
  batch,
309
314
  indexSync,
310
315
  changePlanner: this.#changePlanner,
311
316
  });
312
- const devPlan = event.devPlan;
313
317
  this.#logger.verbose(
314
318
  `[hmr] batch generation=${generation} kinds=${kinds.join(",")} files=${files.length} generated=${indexSync.changedFiles.length} roles=${devPlan.roles.join(",") || "(none)"} actions=${devPlan.actions.join(",") || "(none)"}`,
315
319
  );
package/index.ts CHANGED
@@ -22,19 +22,15 @@ export type * from "./applicationBuildRunner";
22
22
  export type * from "./applicationReleasePackager";
23
23
  export type * from "./applicationTestPreload";
24
24
  export type * from "./artifact";
25
- export type * from "./builder";
26
25
  export type * from "./capacitorApp";
27
26
  export type * from "./cloud";
28
27
  export type * from "./commandDecorators";
29
28
  export type * from "./createTunnel";
30
29
  export type * from "./dependencyScanner";
31
30
  export type * from "./executors";
32
- export type * from "./extractDeps";
33
31
  export type * from "./fileSys";
34
32
  export type * from "./frontendBuild";
35
- export type * from "./getCredentials";
36
33
  export type * from "./getDirname";
37
- export type * from "./getModelFileData";
38
34
  export type * from "./getRelatedCnsts";
39
35
  export type * from "./guideline";
40
36
  export type * from "./incrementalBuilder";
@@ -46,7 +42,6 @@ export type * from "./recipeScanner";
46
42
  export type * from "./scanInfo";
47
43
  export type * from "./selectModel";
48
44
  export type * from "./spinner";
49
- export type * from "./streamAi";
50
45
  export type * from "./transforms";
51
46
  export type * from "./typeChecker";
52
47
  export type * from "./types";
@@ -0,0 +1,40 @@
1
+ # grit rule fixtures
2
+
3
+ One folder per `lint/*.grit` rule. `gritRules.test.ts` runs biome with that one plugin enabled and asserts
4
+ `bad` reports **exactly** the marked lines and `good` reports nothing — so a rule that silently stops
5
+ matching after a biome upgrade fails the suite instead of going quiet, and `every rule in this folder has a
6
+ fixture` fails when a rule is added without one.
7
+
8
+ ## Contract
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.
13
+ - `good` is the half that matters most: an over-matching rule is what makes developers distrust the gate.
14
+ - `fixture.json` is optional:
15
+ - `path` — where the source is written inside the temp workspace, for a rule that reads `$filename`
16
+ (`no-deep-internal-import`, `no-redeclare-predefined-endpoint`). Its extension also picks which of
17
+ `bad.ts` / `bad.tsx` is read.
18
+ - `expect: "file"` — for a rule whose span is the whole file (`no-bang-comment-in-client`), where there is
19
+ no per-line diagnostic to match. `bad` must report at least once.
20
+ - A rule with two independent gates gets one subfolder per gate (`no-init-fetch-in-client/{use-client,store-file}`).
21
+ - These files are excluded from the repo's own biome run (root `biome.json` `files.includes`) and from
22
+ `tsc` (`tsconfig.json` `exclude`), because they reference undeclared identifiers on purpose and the
23
+ formatter would reorder the class strings the vocabulary fixtures test.
24
+
25
+ ## Biome GritQL constraints these fixtures pinned down
26
+
27
+ Each of these made a rule silently report nothing, and each was found by adding the fixture:
28
+
29
+ - A bare snippet in JSX-attribute shape (`` `$name={$value}` ``) matches **no** JsxAttribute. Match the node
30
+ and read its fields: `JsxAttribute(name = $name, initializer = ...)`.
31
+ - `JsModule()` never matches as a top-level pattern, and no node pattern is given comment trivia. A rule
32
+ about comments has to go through `file($name, $body)`, whose span is the whole file.
33
+ - `file()`'s `$body` is the module's **token** span: interior trivia is visible, the leading and trailing
34
+ trivia are not. So a comment above the first statement or below the last one is unreachable from GritQL —
35
+ that is `no-bang-comment-in-client`'s blind spot, and why its fixture puts the marker between statements.
36
+ - A regex capture (`r"..."($var)`) carries no source range: a diagnostic spanned on one gets no location,
37
+ and `$var` does not interpolate into a `message`.
38
+
39
+ The five vocabulary fixtures were ported from `frontendBuild/styleGuard.test.ts`, the pre-grit
40
+ implementation of the same rules, when that scanner was retired.
@@ -0,0 +1,3 @@
1
+ export const HexValue = () => <div className="bg-[#3b82f6]" />; // @flag
2
+ export const ColorFunctionValue = () => <div className="text-[rgb(0,0,0)]" />; // @flag
3
+ export const HslValue = () => <div className="border-[hsl(210,100%,50%)]" />; // @flag
@@ -0,0 +1,4 @@
1
+ export const CssVariableShorthand = () => <div className="bg-[--brand]" />; // @ok
2
+ export const CssVariableFunction = () => <div className="text-[var(--fg)]" />; // @ok
3
+ export const ArbitrarySizeNotColor = () => <div className="min-h-[300px] w-[42ch]" />; // @ok
4
+ export const SemanticToken = () => <div className="bg-primary" />; // @ok
@@ -0,0 +1,4 @@
1
+ export const Card = async () => <div className="flex" />; // @flag
2
+ export const Typed: FC = async () => <div className="flex" />; // @flag
3
+ export async function Panel() { return <div className="flex" />; } // @flag
4
+ export default async function Shell() { return <div className="flex" />; } // @flag
@@ -0,0 +1,4 @@
1
+ export const Card = () => <div className="flex" />; // @ok
2
+ export const Handler = () => { const onSave = async () => save(); return <button onClick={onSave} />; }; // @ok
3
+ export const loadThing = async () => await fetchThing(); // @ok
4
+ export function Panel() { return <div className="flex" />; } // @ok
@@ -0,0 +1,4 @@
1
+ export const a = 1;
2
+ //! FIXME: this marker survives minification and ships to every visitor
3
+ /*! and so does the block form */
4
+ export const b = 2;
@@ -0,0 +1 @@
1
+ { "expect": "file" }