@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.
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +65 -562
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +412 -0
- package/akanApp/index.ts +2 -0
- package/akanContext.ts +17 -3
- package/akanMcpContract.ts +25 -4
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/commandDecorators/command.ts +30 -6
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +45 -0
- package/executors.ts +29 -5
- package/fileEditor.ts +19 -19
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +4 -0
- package/frontendBuild/buildRouteClient.test.ts +19 -22
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/index.ts +0 -1
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +6 -2
- package/index.ts +0 -5
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +2 -2
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +82 -13
- package/package.json +3 -3
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +64 -0
- package/qualityScanner.ts +43 -3
- package/recipeScanner.ts +4 -1
- package/scanInfo.ts +3 -3
- package/ssrScanner.test.ts +301 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +5 -1
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/ui/ScrollList.tsx +6 -8
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -180
- package/frontendBuild/styleGuard.ts +0 -339
- package/getCredentials.ts +0 -17
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- 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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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({
|
package/qualityScanner.test.ts
CHANGED
|
@@ -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)
|
|
602
|
+
.filter((statement): statement is ts.ClassDeclaration => ts.isClassDeclaration(statement))
|
|
600
603
|
.filter((statement) => isExported(statement))
|
|
601
|
-
.map((statement) => statement.name
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
563
|
+
this.#scanResult = scanResult;
|
|
564
564
|
}
|
|
565
565
|
getScanResult() {
|
|
566
|
-
return this
|
|
566
|
+
return this.#scanResult;
|
|
567
567
|
}
|
|
568
568
|
}
|
|
569
569
|
|