@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/linter.test.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type BiomeReport, parseBiomeReport } from "./linter";
3
+
4
+ const reportOf = (diagnostics: BiomeReport["diagnostics"]): string =>
5
+ JSON.stringify({ summary: { errors: diagnostics?.length ?? 0, warnings: 0 }, diagnostics });
6
+
7
+ const oneDiagnostic: BiomeReport["diagnostics"] = [
8
+ {
9
+ severity: "error",
10
+ message: "Unexpected any",
11
+ category: "lint/suspicious/noExplicitAny",
12
+ location: { path: "apps/demo/ui/Card.tsx", start: { line: 3, column: 10 } },
13
+ },
14
+ ];
15
+
16
+ describe("parseBiomeReport", () => {
17
+ test("reads a report that is the whole output", () => {
18
+ const report = parseBiomeReport(reportOf(oneDiagnostic));
19
+ expect(report.diagnostics).toHaveLength(1);
20
+ expect(report.diagnostics?.[0]?.category).toBe("lint/suspicious/noExplicitAny");
21
+ expect(report.summary?.errors).toBe(1);
22
+ });
23
+
24
+ test("reads a report behind leading text", () => {
25
+ const output = `Skipped 3 files.\n${reportOf(oneDiagnostic)}`;
26
+ expect(parseBiomeReport(output).diagnostics).toHaveLength(1);
27
+ });
28
+
29
+ test("reads a report behind leading text that carries a brace", () => {
30
+ // The whole reason the first-brace-to-last-brace slice was wrong: Biome prints configuration and IO
31
+ // diagnostics onto the same stream as the report, and those messages quote source and config.
32
+ const output = [
33
+ "configuration/deserialize: unknown key `overrides[0].includes { }`",
34
+ " the file `biome.jsonc` cannot be read",
35
+ reportOf(oneDiagnostic),
36
+ ].join("\n");
37
+ expect(parseBiomeReport(output).diagnostics).toHaveLength(1);
38
+ });
39
+
40
+ test("skips a complete JSON object in leading text that is not a report", () => {
41
+ const output = `hint: try {"linter":{"enabled":true}} in your config\n${reportOf(oneDiagnostic)}`;
42
+ const report = parseBiomeReport(output);
43
+ expect(report.diagnostics).toHaveLength(1);
44
+ expect(report.summary?.errors).toBe(1);
45
+ });
46
+
47
+ test("reads a report followed by trailing text", () => {
48
+ const output = `${reportOf(oneDiagnostic)}\nChecked 12 files in 40ms. Found 1 error.`;
49
+ expect(parseBiomeReport(output).diagnostics).toHaveLength(1);
50
+ });
51
+
52
+ test("keeps a diagnostic whose message contains braces and quotes", () => {
53
+ const message = 'Replace {" a "} with { " a " } — the `{}` object syntax is not allowed';
54
+ const report = parseBiomeReport(reportOf([{ severity: "error", message }]));
55
+ expect(report.diagnostics?.[0]?.message).toBe(message);
56
+ });
57
+
58
+ test("keeps a diagnostic whose message ends in an escaped backslash before the closing quote", () => {
59
+ const message = "path separator is \\\\";
60
+ const report = parseBiomeReport(reportOf([{ severity: "warning", message }]));
61
+ expect(report.diagnostics?.[0]?.message).toBe(message);
62
+ expect(report.diagnostics).toHaveLength(1);
63
+ });
64
+
65
+ test("accepts a report carrying only a summary", () => {
66
+ expect(parseBiomeReport('{"summary":{"errors":0,"warnings":0}}').summary?.errors).toBe(0);
67
+ });
68
+
69
+ test("throws the output when it holds no JSON at all", () => {
70
+ expect(() => parseBiomeReport("biome: command not found\n")).toThrow("biome: command not found");
71
+ });
72
+
73
+ test("throws the output when the only brace never closes", () => {
74
+ expect(() => parseBiomeReport('internal error: {"diagnostics":[')).toThrow("internal error");
75
+ });
76
+
77
+ test("throws a named error for empty output", () => {
78
+ expect(() => parseBiomeReport(" \n")).toThrow("No Biome JSON output");
79
+ });
80
+ });
package/linter.ts CHANGED
@@ -3,14 +3,14 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
5
 
6
- type BiomeSeverity = "error" | "warning" | "information" | "hint";
6
+ export type BiomeSeverity = "error" | "warning" | "information" | "hint";
7
7
 
8
- interface BiomePosition {
8
+ export interface BiomePosition {
9
9
  line: number;
10
10
  column: number;
11
11
  }
12
12
 
13
- interface BiomeDiagnostic {
13
+ export interface BiomeDiagnostic {
14
14
  severity: BiomeSeverity;
15
15
  message: string;
16
16
  category?: string;
@@ -21,7 +21,7 @@ interface BiomeDiagnostic {
21
21
  };
22
22
  }
23
23
 
24
- interface BiomeReport {
24
+ export interface BiomeReport {
25
25
  summary?: {
26
26
  changed?: number;
27
27
  errors?: number;
@@ -58,6 +58,56 @@ interface LintResponse {
58
58
  warnings: LintMessage[];
59
59
  }
60
60
 
61
+ /**
62
+ * Extracts the `--reporter=json` report from Biome's output.
63
+ *
64
+ * Biome mixes configuration and IO diagnostics into the same stream as the report, so the output is not
65
+ * always JSON alone. Slicing from the first `{` to the last `}` mis-parses as soon as that leading text
66
+ * carries a brace of its own, which is not hypothetical: a config error printed ahead of the report made
67
+ * one unchanged command alternate between 2 and 10 diagnostics. So every `{` is tried as a start, its
68
+ * match is found by depth while string literals are skipped, and the first candidate that both parses and
69
+ * carries a report field wins — a brace in prose parses as nothing, and `{"a":1}` in prose is not a report.
70
+ */
71
+ export const parseBiomeReport = (output: string): BiomeReport => {
72
+ for (let start = output.indexOf("{"); start !== -1; start = output.indexOf("{", start + 1)) {
73
+ const end = objectEndAt(output, start);
74
+ if (end === -1) break;
75
+ const report = asBiomeReport(output.slice(start, end + 1));
76
+ if (report) return report;
77
+ }
78
+ throw new Error(output.trim() || "No Biome JSON output");
79
+ };
80
+
81
+ const objectEndAt = (output: string, start: number): number => {
82
+ let depth = 0;
83
+ let inString = false;
84
+ let escaped = false;
85
+ for (let idx = start; idx < output.length; idx += 1) {
86
+ const char = output[idx];
87
+ if (inString) {
88
+ if (escaped) escaped = false;
89
+ else if (char === "\\") escaped = true;
90
+ else if (char === '"') inString = false;
91
+ continue;
92
+ }
93
+ if (char === '"') inString = true;
94
+ else if (char === "{") depth += 1;
95
+ else if (char === "}" && --depth === 0) return idx;
96
+ }
97
+ return -1;
98
+ };
99
+
100
+ const asBiomeReport = (candidate: string): BiomeReport | null => {
101
+ try {
102
+ const parsed = JSON.parse(candidate) as unknown;
103
+ if (!parsed || typeof parsed !== "object") return null;
104
+ if (!("diagnostics" in parsed) && !("summary" in parsed)) return null;
105
+ return parsed as BiomeReport;
106
+ } catch {
107
+ return null;
108
+ }
109
+ };
110
+
61
111
  /** Biome reads `biome.json` first and `biome.jsonc` second; only the latter may carry comments. */
62
112
  const BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"] as const;
63
113
 
@@ -123,14 +173,6 @@ export class Linter {
123
173
  });
124
174
  }
125
175
 
126
- #parseBiomeReport(output: string): BiomeReport {
127
- const jsonStart = output.indexOf("{");
128
- const jsonEnd = output.lastIndexOf("}");
129
- if (jsonStart === -1 || jsonEnd === -1 || jsonEnd < jsonStart)
130
- throw new Error(output.trim() || "No Biome JSON output");
131
- return JSON.parse(output.slice(jsonStart, jsonEnd + 1)) as BiomeReport;
132
- }
133
-
134
176
  #diagnosticFilePath(diagnostic: BiomeDiagnostic, fallbackFilePath: string) {
135
177
  const diagnosticPath = diagnostic.location?.path;
136
178
  if (!diagnosticPath) return fallbackFilePath;
@@ -208,7 +250,7 @@ export class Linter {
208
250
  this.configPath,
209
251
  this.#toBiomePath(filePath),
210
252
  ]);
211
- const report = this.#parseBiomeReport(stdout || stderr);
253
+ const report = parseBiomeReport(stdout || stderr);
212
254
  const results = this.#toLintResults(report, filePath);
213
255
  const { errors, warnings } = this.#splitMessages(results);
214
256
  const output = write && existsSync(filePath) ? readFileSync(filePath, "utf8") : undefined;
@@ -414,6 +456,33 @@ export class Linter {
414
456
  * @param filePath - Path to the file
415
457
  * @returns Biome configuration object
416
458
  */
459
+ /**
460
+ * Applies Biome's safe fixes to many files in one process.
461
+ *
462
+ * One spawn, not one per file: Biome's startup dominates a small file, so the per-file path turns a
463
+ * fifteen-file scaffold into three seconds of process launches. Paths that do not exist are dropped
464
+ * rather than thrown on, because the caller is usually reporting what a template just wrote and a
465
+ * template is allowed to decline a file.
466
+ */
467
+ async fixFiles(filePaths: string[]): Promise<{ fixed: string[] }> {
468
+ const resolved = filePaths.map((filePath) => this.#resolveFilePath(filePath)).filter(existsSync);
469
+ if (resolved.length === 0) return { fixed: [] };
470
+ const before = new Map(resolved.map((filePath) => [filePath, readFileSync(filePath, "utf8")]));
471
+ await this.#runBiome([
472
+ "check",
473
+ "--write",
474
+ "--reporter=json",
475
+ "--max-diagnostics=none",
476
+ "--no-errors-on-unmatched",
477
+ "--config-path",
478
+ this.configPath,
479
+ ...resolved.map((filePath) => this.#toBiomePath(filePath)),
480
+ ]);
481
+ return {
482
+ fixed: resolved.filter((filePath) => readFileSync(filePath, "utf8") !== before.get(filePath)),
483
+ };
484
+ }
485
+
417
486
  async getConfigForFile(filePath: string): Promise<unknown> {
418
487
  const resolvedFilePath = this.#resolveFilePath(filePath);
419
488
  if (!existsSync(resolvedFilePath)) throw new Error(`File not found: ${filePath}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.71",
3
+ "version": "3.0.0-alpha.72",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -45,7 +45,7 @@
45
45
  "@langchain/openai": "^1.4.6",
46
46
  "@tailwindcss/node": "^4.3.0",
47
47
  "@trapezedev/project": "^7.1.4",
48
- "akanjs": "3.0.0-alpha.71",
48
+ "akanjs": "3.0.0-alpha.72",
49
49
  "chalk": "^5.6.2",
50
50
  "commander": "^14.0.3",
51
51
  "dayjs": "^1.11.20",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "peerDependencies": {
64
64
  "@capacitor/cli": "^8.3.4",
65
- "react": "19.2.6"
65
+ "react": "19.2.7"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "@capacitor/cli": {
package/prompter.ts CHANGED
@@ -24,7 +24,6 @@ export class Prompter {
24
24
  }
25
25
 
26
26
  static async selectGuideline() {
27
- const guidelineRoot = await Prompter.#getGuidelineRoot();
28
27
  const guideNames = await Prompter.listGuidelines();
29
28
  return await select({
30
29
  message: "Select a guideline",
@@ -41,11 +40,17 @@ export class Prompter {
41
40
  const guideJson = await fsPromise.readFile(filePath, "utf-8");
42
41
  return JSON.parse(guideJson) as GuideGenerateJson;
43
42
  }
43
+ /**
44
+ * The name is checked against the directory listing rather than interpolated straight into a path:
45
+ * it arrives from an MCP tool argument, and an unknown one has to name the valid set instead of
46
+ * surfacing an ENOENT that carries the host's own paths.
47
+ */
44
48
  static async getInstruction(guideName: string): Promise<string> {
49
+ const guideNames = await Prompter.listGuidelines();
50
+ if (!guideNames.includes(guideName))
51
+ throw new Error(`Unknown guideline "${guideName}". Available: ${guideNames.join(", ")}`);
45
52
  const guidelineRoot = await Prompter.#getGuidelineRoot();
46
- const filePath = `${guidelineRoot}/${guideName}/${guideName}.instruction.md`;
47
- const content = await fsPromise.readFile(filePath, "utf-8");
48
- return content;
53
+ return await fsPromise.readFile(`${guidelineRoot}/${guideName}/${guideName}.instruction.md`, "utf-8");
49
54
  }
50
55
  static async getUpdateRequest(guideName: string) {
51
56
  return await input({
@@ -249,3 +249,67 @@ describe("AkanQualityScanner layout rules", () => {
249
249
  expect(warnings[0]?.file).toBe("libs/demo/lib/helper.ts");
250
250
  });
251
251
  });
252
+
253
+ describe("bang comments in browser-reachable files", () => {
254
+ const bangWarningsIn = async (files: Record<string, string>) => {
255
+ const result = await new AkanQualityScanner().scan(await makeWorkspace(files));
256
+ return rulesOf(result, "akan.file.bang-comment-in-client");
257
+ };
258
+
259
+ test("flags the first and last line, which the grit rule cannot reach", async () => {
260
+ const warnings = await bangWarningsIn({
261
+ "apps/demo/ui/First.tsx": "//! FIXME: broken\nexport const First = () => null;\n",
262
+ "apps/demo/ui/Last.tsx": "export const Last = () => null;\n//! left behind\n",
263
+ });
264
+
265
+ expect(warnings.map((warning) => `${warning.file}:${warning.line}`).sort()).toEqual([
266
+ "apps/demo/ui/First.tsx:1",
267
+ "apps/demo/ui/Last.tsx:2",
268
+ ]);
269
+ expect(warnings[0]?.fix).toContain("// FIXME:");
270
+ });
271
+
272
+ test("flags the block form and a marker trailing code", async () => {
273
+ const warnings = await bangWarningsIn({
274
+ "apps/demo/lib/task/Task.Zone.tsx": ["/*! keep me */", "export const Zone = () => null; //! and me", ""].join(
275
+ "\n",
276
+ ),
277
+ });
278
+
279
+ expect(warnings.map((warning) => warning.line)).toEqual([1, 2]);
280
+ });
281
+
282
+ test("ignores a double slash inside a string literal", async () => {
283
+ expect(
284
+ await bangWarningsIn({ "apps/demo/ui/Url.tsx": 'export const url = "https://host//!path";\n' }),
285
+ ).toHaveLength(0);
286
+ });
287
+
288
+ test("stays out of server files and tests", async () => {
289
+ const body = "//! server-only marker\nexport const value = 1;\n";
290
+ expect(
291
+ await bangWarningsIn({
292
+ "apps/demo/srvkit/storage.ts": body,
293
+ "apps/demo/lib/task/task.service.ts": body,
294
+ "apps/demo/ui/Card.test.tsx": body,
295
+ "apps/demo/lib/task/task.signal.spec.ts": body,
296
+ }),
297
+ ).toHaveLength(0);
298
+ });
299
+
300
+ test("covers every path role the grit rule is scoped to", async () => {
301
+ const body = "//! marker\nexport const value = 1;\n";
302
+ const files = [
303
+ "apps/demo/ui/Card.tsx",
304
+ "apps/demo/webkit/useThing.tsx",
305
+ "apps/demo/common/format.ts",
306
+ "apps/demo/page/task/_index.tsx",
307
+ "apps/demo/lib/task/task.constant.ts",
308
+ "apps/demo/lib/task/task.store.ts",
309
+ "apps/demo/lib/task/Task.Unit.tsx",
310
+ ];
311
+ const warnings = await bangWarningsIn(Object.fromEntries(files.map((file) => [file, body])));
312
+
313
+ expect(warnings.map((warning) => warning.file).sort()).toEqual([...files].sort());
314
+ });
315
+ });
package/qualityScanner.ts CHANGED
@@ -117,6 +117,8 @@ const RULE_FIXES: Record<string, string> = {
117
117
  "Run `akan compact <app-or-lib>` to rewrite the abstract with the AI editor, keeping only the invariants and workflows the source files cannot show.",
118
118
  "akan.file.placeholder-export":
119
119
  "Remove the placeholder export; generated indexes should only re-export real modules.",
120
+ "akan.file.bang-comment-in-client":
121
+ "Rewrite the marker as `// FIXME:` or `// TODO:`. Keep the bang form for server, srvkit, and CLI files.",
120
122
  "akan.file.dictionary-stale-text": "Replace the scaffold text with real localized copy for this dictionary entry.",
121
123
  "akan.file.global-declaration":
122
124
  "Move the global declaration into an approved low-level integration file (e.g. webkit) and keep it isolated.",
@@ -314,6 +316,7 @@ export class AkanQualityScanner {
314
316
  });
315
317
  }
316
318
 
319
+ warnings.push(...getBangCommentWarnings(sourceFile));
317
320
  warnings.push(...getPlaceholderExportWarnings(sourceFile));
318
321
  warnings.push(...getDictionaryTextWarnings(sourceFile));
319
322
  warnings.push(...getGlobalMutationWarnings(sourceFile));
@@ -596,9 +599,10 @@ function isEnumClassStatement(sourceFile: ts.SourceFile, statement: ts.Statement
596
599
 
597
600
  function getExportedClassNames(sourceFile: ts.SourceFile) {
598
601
  return sourceFile.statements
599
- .filter((statement): statement is ts.ClassDeclaration => ts.isClassDeclaration(statement) && !!statement.name)
602
+ .filter((statement): statement is ts.ClassDeclaration => ts.isClassDeclaration(statement))
600
603
  .filter((statement) => isExported(statement))
601
- .map((statement) => statement.name!.text);
604
+ .map((statement) => statement.name?.text)
605
+ .filter((name): name is string => !!name);
602
606
  }
603
607
 
604
608
  function isComponentDeclarationFile(file: string) {
@@ -678,7 +682,43 @@ function isPascalCaseName(name: string) {
678
682
 
679
683
  function isDefaultExportStatement(statement: ts.Statement) {
680
684
  if (ts.isExportAssignment(statement)) return !statement.isExportEquals;
681
- return !!(ts.getCombinedModifierFlags(statement as ts.Declaration) & ts.ModifierFlags.Default);
685
+ if (!ts.canHaveModifiers(statement)) return false;
686
+ return !!ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
687
+ }
688
+
689
+ /**
690
+ * Bun's bundler keeps `//!` and `/*!` through minification (it reads them as the `@license` class), so a
691
+ * bang marker in browser-reachable code ships verbatim to every visitor. `no-bang-comment-in-client.grit`
692
+ * is the build gate; this exists because that rule cannot see the whole file.
693
+ *
694
+ * A comment is trivia, which Biome's GritQL exposes to no node pattern, so the rule matches through
695
+ * `file($name, $body)` — and `$body` is the module's *token* span. A marker above the first statement or
696
+ * below the last one sits in leading or trailing trivia and is unreachable from GritQL at all. Reading the
697
+ * raw text here covers both, and re-reporting the markers grit already catches costs nothing: the two run
698
+ * from different commands, and a file with one is failing lint either way.
699
+ */
700
+ function getBangCommentWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
701
+ if (!isClientReachableFile(sourceFile.file)) return [];
702
+ // Anchored to line start or to whitespace after code, so a literal like `"https://host//!path"` is not a hit.
703
+ return findPatternLines(sourceFile.content, /(?:^|[^\s/][ \t]+)\/[*/]!/).map((line) => ({
704
+ rule: "akan.file.bang-comment-in-client",
705
+ scope: "file" as const,
706
+ severity: "warning" as const,
707
+ file: sourceFile.file,
708
+ line,
709
+ message: "A `//!` or `/*!` marker survives minification and ships to the browser.",
710
+ }));
711
+ }
712
+
713
+ /** Mirrors the path scope of `no-bang-comment-in-client.grit` in `biome.base.json`. */
714
+ function isClientReachableFile(file: string) {
715
+ if (/\.(test|spec)\.tsx?$/.test(file)) return false;
716
+ if (/\.(constant|store)\.ts$/.test(file)) return true;
717
+ if (/\.(Template|Unit|Util|View|Zone)\.tsx$/.test(file)) return true;
718
+ const segments = file.split("/");
719
+ if (segments.includes("ui") || segments.includes("webkit")) return /\.tsx?$/.test(file);
720
+ if (segments.includes("common")) return file.endsWith(".ts");
721
+ return segments.includes("page") && file.endsWith(".tsx");
682
722
  }
683
723
 
684
724
  function getPlaceholderExportWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
package/recipeScanner.ts CHANGED
@@ -118,7 +118,10 @@ const extractVariants = (config: ts.ObjectLiteralExpression) => {
118
118
  if (!ts.isPropertyAssignment(variant) || !isNamed(variant.name)) continue;
119
119
  if (!ts.isObjectLiteralExpression(variant.initializer)) continue;
120
120
  variants[propName(variant.name)] = variant.initializer.properties
121
- .filter((option): option is ts.PropertyAssignment => ts.isPropertyAssignment(option) && isNamed(option.name))
121
+ .filter(
122
+ (option): option is ts.PropertyAssignment & { name: ts.Identifier | ts.StringLiteral } =>
123
+ ts.isPropertyAssignment(option) && isNamed(option.name),
124
+ )
122
125
  .map((option) => propName(option.name));
123
126
  }
124
127
  } else if (key === "defaultVariants" && ts.isObjectLiteralExpression(property.initializer)) {
package/scanInfo.ts CHANGED
@@ -518,7 +518,7 @@ export class LibInfo extends ScanInfo {
518
518
  export class PkgInfo {
519
519
  readonly exec: PkgExecutor;
520
520
  readonly name: string;
521
- private scanResult: PkgScanResult;
521
+ #scanResult: PkgScanResult;
522
522
 
523
523
  static async scanExecutor(exec: PkgExecutor) {
524
524
  const [tsconfig, rootPackageJson] = await Promise.all([exec.getTsConfig(), exec.workspace.getPackageJson()]);
@@ -560,10 +560,10 @@ export class PkgInfo {
560
560
  constructor(exec: PkgExecutor, scanResult: PkgScanResult) {
561
561
  this.exec = exec;
562
562
  this.name = exec.name;
563
- this.scanResult = scanResult;
563
+ this.#scanResult = scanResult;
564
564
  }
565
565
  getScanResult() {
566
- return this.scanResult;
566
+ return this.#scanResult;
567
567
  }
568
568
  }
569
569