@akanjs/devkit 3.1.1-rc.14 → 3.1.1-rc.16
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/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +2 -1
- package/biome.base.json +4 -1
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/index.ts +1 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +142 -0
- package/package.json +2 -2
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +159 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/workflow/artifacts.ts +6 -1
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +519 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +54 -0
- package/workflow/render.ts +15 -1
- package/workflow/source.test.ts +130 -0
- package/workflow/source.ts +122 -14
- package/workflow/types.ts +17 -0
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
|
@@ -9,6 +9,7 @@ import type { App } from "./commandDecorators";
|
|
|
9
9
|
import { AppExecutor, Executor } from "./executors";
|
|
10
10
|
import { bundleDefine } from "./frontendBuild/bundleDefine";
|
|
11
11
|
import { createTempApp } from "./testHelpers";
|
|
12
|
+
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
12
13
|
|
|
13
14
|
describe("ApplicationBuildRunner", () => {
|
|
14
15
|
test("externalizes Akan optional backend dependencies", () => {
|
|
@@ -249,4 +250,20 @@ console.info(new SampleService().name);
|
|
|
249
250
|
await rm(root, { recursive: true, force: true });
|
|
250
251
|
}
|
|
251
252
|
});
|
|
253
|
+
|
|
254
|
+
test("akan typecheck names the script/ files nothing imports, and a build's typecheck leaves them out", async () => {
|
|
255
|
+
const { root, app } = await createTempApp("portal");
|
|
256
|
+
const run = spyOn(ProjectTypecheck.prototype, "run").mockImplementation(async () => "");
|
|
257
|
+
try {
|
|
258
|
+
const runner = new ApplicationBuildRunner(app);
|
|
259
|
+
await runner.typecheck({ tests: true });
|
|
260
|
+
await runner.typecheck();
|
|
261
|
+
const [checked, built] = run.mock.calls.map(([entries]) => entries);
|
|
262
|
+
expect(checked).toContain("../../script/**/*.ts");
|
|
263
|
+
expect(built).not.toContain("../../script/**/*.ts");
|
|
264
|
+
} finally {
|
|
265
|
+
run.mockRestore();
|
|
266
|
+
await rm(root, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
252
269
|
});
|
|
@@ -25,7 +25,7 @@ import type { BaseDevEnv, PackageJson } from "./types";
|
|
|
25
25
|
export interface TypecheckOptions {
|
|
26
26
|
clean?: boolean;
|
|
27
27
|
incremental?: boolean;
|
|
28
|
-
/** The app's own tests and
|
|
28
|
+
/** The app's own tests, specs and `script/` files too (`akan typecheck`); a build leaves them out. */
|
|
29
29
|
tests?: boolean;
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -205,6 +205,7 @@ export class ApplicationBuildRunner {
|
|
|
205
205
|
"../../page/**/*.ts",
|
|
206
206
|
"../../page/**/*.tsx",
|
|
207
207
|
...libPageIncludes,
|
|
208
|
+
...(options.tests ? ["../../script/**/*.ts"] : []),
|
|
208
209
|
"../../../../pkgs/akanjs/*/types/**/*.d.ts",
|
|
209
210
|
],
|
|
210
211
|
options,
|
package/biome.base.json
CHANGED
|
@@ -211,7 +211,10 @@
|
|
|
211
211
|
"!**/*.spec.ts",
|
|
212
212
|
"!**/*.spec.tsx"
|
|
213
213
|
],
|
|
214
|
-
"plugins": [
|
|
214
|
+
"plugins": [
|
|
215
|
+
"./node_modules/@akanjs/devkit/lint/no-init-fetch-in-client.grit",
|
|
216
|
+
"./node_modules/@akanjs/devkit/lint/no-oversized-list-limit.grit"
|
|
217
|
+
]
|
|
215
218
|
},
|
|
216
219
|
{
|
|
217
220
|
"includes": [
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { CommentDensityScanner } from "./commentDensityScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/repo/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(
|
|
11
|
+
file,
|
|
12
|
+
content,
|
|
13
|
+
ts.ScriptTarget.Latest,
|
|
14
|
+
true,
|
|
15
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
16
|
+
),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const codeOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => `const value${idx} = ${idx};`);
|
|
20
|
+
const commentsOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => `// note ${idx}`);
|
|
21
|
+
const scan = (files: Record<string, string[]>) =>
|
|
22
|
+
new CommentDensityScanner().scan(Object.entries(files).map(([file, lines]) => fileOf(file, `${lines.join("\n")}\n`)));
|
|
23
|
+
|
|
24
|
+
describe("CommentDensityScanner", () => {
|
|
25
|
+
test("flags a module over 1% with at least five comment lines, summed across its files", () => {
|
|
26
|
+
const warnings = scan({
|
|
27
|
+
"apps/demo/lib/post/post.service.ts": [...commentsOf(3), ...codeOf(100)],
|
|
28
|
+
"apps/demo/lib/post/post.document.ts": [...codeOf(50), ...commentsOf(2)],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(warnings).toHaveLength(1);
|
|
32
|
+
expect(warnings[0]).toMatchObject({
|
|
33
|
+
rule: "akan.comment.density",
|
|
34
|
+
scope: "comment",
|
|
35
|
+
file: "apps/demo/lib/post/post.service.ts",
|
|
36
|
+
line: 1,
|
|
37
|
+
});
|
|
38
|
+
expect(warnings[0]?.message).toContain("apps/demo/lib/post is 3.23% comments (5 of 155 non-blank lines)");
|
|
39
|
+
expect(warnings[0]?.locations).toEqual([
|
|
40
|
+
{ file: "apps/demo/lib/post/post.service.ts", line: 1 },
|
|
41
|
+
{ file: "apps/demo/lib/post/post.document.ts", line: 51 },
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("passes a module under 1%, and a small module with fewer than five comment lines", () => {
|
|
46
|
+
expect(
|
|
47
|
+
scan({
|
|
48
|
+
"apps/demo/lib/post/post.service.ts": [...commentsOf(5), ...codeOf(600)],
|
|
49
|
+
"apps/demo/lib/tag/tag.service.ts": [...commentsOf(4), ...codeOf(10)],
|
|
50
|
+
}),
|
|
51
|
+
).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("does not count lint and TS directives or the store scaffold markers", () => {
|
|
55
|
+
expect(
|
|
56
|
+
scan({
|
|
57
|
+
"apps/demo/lib/post/post.store.ts": [
|
|
58
|
+
"export class PostStore extends store(sig.post, () => ({",
|
|
59
|
+
" // state",
|
|
60
|
+
"})) {",
|
|
61
|
+
" // action",
|
|
62
|
+
"}",
|
|
63
|
+
"// biome-ignore lint/style/noNonNullAssertion: vendor typing",
|
|
64
|
+
"/* biome-ignore lint/plugin: fixed brand color */",
|
|
65
|
+
"// @ts-expect-error vendor typing",
|
|
66
|
+
"// @ts-ignore vendor typing",
|
|
67
|
+
...codeOf(5),
|
|
68
|
+
],
|
|
69
|
+
}),
|
|
70
|
+
).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("does not count comment syntax inside strings or JSX text", () => {
|
|
74
|
+
expect(
|
|
75
|
+
scan({
|
|
76
|
+
"apps/demo/ui/Links.tsx": [
|
|
77
|
+
'export const home = "https://host//home";',
|
|
78
|
+
'export const docs = "https://host//docs";',
|
|
79
|
+
`export const api = \`https://host//\${home}\`;`,
|
|
80
|
+
"export const Links = () => (",
|
|
81
|
+
" <p>",
|
|
82
|
+
" // not a comment",
|
|
83
|
+
" // still text",
|
|
84
|
+
" </p>",
|
|
85
|
+
");",
|
|
86
|
+
],
|
|
87
|
+
}),
|
|
88
|
+
).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("measures neither srvkit nor guards.ts nor tests", () => {
|
|
92
|
+
expect(
|
|
93
|
+
scan({
|
|
94
|
+
"apps/demo/srvkit/vendorApi.ts": [...commentsOf(20), ...codeOf(10)],
|
|
95
|
+
"apps/demo/lib/post/guards.ts": [...commentsOf(20), ...codeOf(10)],
|
|
96
|
+
"apps/demo/lib/post/post.signal.test.ts": [...commentsOf(20), ...codeOf(10)],
|
|
97
|
+
"apps/demo/ui/Card.test.tsx": [...commentsOf(20), ...codeOf(10)],
|
|
98
|
+
}),
|
|
99
|
+
).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("aggregates a ui folder as one entry and a ui file as its own", () => {
|
|
103
|
+
const warnings = scan({
|
|
104
|
+
"apps/demo/ui/Chart/Bar.tsx": [...commentsOf(3), ...codeOf(20)],
|
|
105
|
+
"apps/demo/ui/Chart/Pie.tsx": [...commentsOf(3), ...codeOf(20)],
|
|
106
|
+
"apps/demo/ui/Badge.tsx": [...commentsOf(4), ...codeOf(20)],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(warnings.map((warning) => warning.message.split(" is ")[0])).toEqual(["apps/demo/ui/Chart"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("leaves a recipe's one-line JSDoc description uncounted", () => {
|
|
113
|
+
const recipeOf = (name: string) => [`/** ${name} look. */`, `export const ${name}Recipe = recipe(tv({}));`];
|
|
114
|
+
expect(
|
|
115
|
+
scan({
|
|
116
|
+
"apps/demo/ui/Recipe/panel.ts": [...recipeOf("panel"), ...recipeOf("tile"), ...recipeOf("card")],
|
|
117
|
+
"apps/demo/ui/Recipe/chip.ts": [...recipeOf("chip"), ...recipeOf("pill"), ...recipeOf("tag")],
|
|
118
|
+
}),
|
|
119
|
+
).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
4
|
+
import { type SourceComment, SourceComments } from "./sourceComments";
|
|
5
|
+
|
|
6
|
+
interface CommentedFile {
|
|
7
|
+
file: string;
|
|
8
|
+
line: number;
|
|
9
|
+
commentLineNum: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CommentTally {
|
|
13
|
+
lineNum: number;
|
|
14
|
+
commentLineNum: number;
|
|
15
|
+
files: CommentedFile[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class CommentDensityScanner {
|
|
19
|
+
static readonly rule = "akan.comment.density";
|
|
20
|
+
static readonly maxShare = 0.01;
|
|
21
|
+
static readonly minCommentLineNum = 5;
|
|
22
|
+
static #directive =
|
|
23
|
+
/^(?:biome-ignore|@ts-(?:expect-error|ignore|nocheck|check)\b|eslint-(?:disable|enable)|<reference\b)/;
|
|
24
|
+
static #scaffoldMarkers = new Set(["state", "action"]);
|
|
25
|
+
|
|
26
|
+
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
27
|
+
const tallies = new Map<string, CommentTally>();
|
|
28
|
+
for (const sourceFile of sourceFiles) {
|
|
29
|
+
const unit = CommentDensityScanner.#unitOf(sourceFile.file);
|
|
30
|
+
if (!unit || !CommentDensityScanner.#isMeasured(sourceFile)) continue;
|
|
31
|
+
const tally = tallies.get(unit) ?? { lineNum: 0, commentLineNum: 0, files: [] };
|
|
32
|
+
const commentLines = CommentDensityScanner.#commentLinesOf(sourceFile);
|
|
33
|
+
tally.lineNum += sourceFile.content.split(/\r?\n/).filter((line) => line.trim() !== "").length;
|
|
34
|
+
tally.commentLineNum += commentLines.length;
|
|
35
|
+
if (commentLines[0] !== undefined)
|
|
36
|
+
tally.files.push({ file: sourceFile.file, line: commentLines[0], commentLineNum: commentLines.length });
|
|
37
|
+
tallies.set(unit, tally);
|
|
38
|
+
}
|
|
39
|
+
return [...tallies]
|
|
40
|
+
.filter(([, tally]) => CommentDensityScanner.#isDense(tally))
|
|
41
|
+
.map(([unit, tally]) => CommentDensityScanner.#warningOf(unit, tally));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static #commentLinesOf({ file, sourceFile }: SourceFileInfo) {
|
|
45
|
+
const isRecipe = file.includes("/ui/Recipe/");
|
|
46
|
+
const lines = new Set<number>();
|
|
47
|
+
for (const comment of SourceComments.of(sourceFile)) {
|
|
48
|
+
if (CommentDensityScanner.#isExempt(comment)) continue;
|
|
49
|
+
const contentIdxs = comment.body.flatMap((text, idx) => (text ? [idx] : []));
|
|
50
|
+
//? A recipe's first JSDoc line is its description in the generated AGENTS.md recipe index.
|
|
51
|
+
const countedIdxs = isRecipe && comment.isJsDoc ? contentIdxs.slice(1) : contentIdxs;
|
|
52
|
+
for (const idx of countedIdxs) lines.add(comment.line + idx);
|
|
53
|
+
}
|
|
54
|
+
return [...lines].sort((a, b) => a - b);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static #isExempt({ raw, body }: SourceComment) {
|
|
58
|
+
const head = body.find((text) => text !== "") ?? "";
|
|
59
|
+
if (CommentDensityScanner.#directive.test(head)) return true;
|
|
60
|
+
return raw.startsWith("//") && CommentDensityScanner.#scaffoldMarkers.has(head.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #isDense({ lineNum, commentLineNum }: CommentTally) {
|
|
64
|
+
return (
|
|
65
|
+
commentLineNum >= CommentDensityScanner.minCommentLineNum &&
|
|
66
|
+
commentLineNum > lineNum * CommentDensityScanner.maxShare
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static #warningOf(unit: string, { lineNum, commentLineNum, files }: CommentTally): QualityWarning {
|
|
71
|
+
const [primary] = [...files].sort((a, b) => b.commentLineNum - a.commentLineNum);
|
|
72
|
+
const share = ((commentLineNum / lineNum) * 100).toFixed(2);
|
|
73
|
+
return {
|
|
74
|
+
rule: CommentDensityScanner.rule,
|
|
75
|
+
scope: "comment",
|
|
76
|
+
severity: "warning",
|
|
77
|
+
file: primary?.file ?? unit,
|
|
78
|
+
line: primary?.line,
|
|
79
|
+
message: `${unit} is ${share}% comments (${commentLineNum} of ${lineNum} non-blank lines); product lib/ and ui/ code stays under ${CommentDensityScanner.maxShare * 100}%.`,
|
|
80
|
+
...(files.length > 1 ? { locations: files.map(({ file, line }) => ({ file, line })) } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static #unitOf(file: string) {
|
|
85
|
+
const segments = file.split("/");
|
|
86
|
+
if ((segments[0] !== "apps" && segments[0] !== "libs") || segments.length < 4) return null;
|
|
87
|
+
if (segments[2] === "ui") return segments.slice(0, 4).join("/");
|
|
88
|
+
if (segments[2] !== "lib") return null;
|
|
89
|
+
const depth = segments[3] === "__scalar" ? 5 : 4;
|
|
90
|
+
return segments.length > depth ? segments.slice(0, depth).join("/") : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static #isMeasured({ file, sourceFile }: SourceFileInfo) {
|
|
94
|
+
if (/\.(test|spec)\.tsx?$/.test(file) || path.basename(file) === "guards.ts") return false;
|
|
95
|
+
return !sourceFile.statements.every(
|
|
96
|
+
(statement) =>
|
|
97
|
+
ts.isExportDeclaration(statement) ||
|
|
98
|
+
(ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
4
|
+
|
|
5
|
+
const measure = (content: string) =>
|
|
6
|
+
new ComponentSizeScanner().measure(
|
|
7
|
+
ts.createSourceFile("Board.tsx", content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const bodyOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => ` const v${idx} = ${idx};`).join("\n");
|
|
11
|
+
|
|
12
|
+
describe("ComponentSizeScanner", () => {
|
|
13
|
+
test("measures a function declaration and a const arrow from their first to their last line", () => {
|
|
14
|
+
const sizes = measure(
|
|
15
|
+
`import { cn } from "akanjs/client";\n\nexport function Board() {\n${bodyOf(3)}\n return null;\n}\n\nexport const Cell = () => {\n${bodyOf(10)}\n return null;\n};\n`,
|
|
16
|
+
);
|
|
17
|
+
expect(sizes).toEqual([
|
|
18
|
+
{ name: "Board", line: 3, lineCount: 6 },
|
|
19
|
+
{ name: "Cell", line: 10, lineCount: 13 },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("measures a wrapped component with its wrapper", () => {
|
|
24
|
+
const sizes = measure(
|
|
25
|
+
`export const Canvas = memo(\n forwardRef((props, ref) => {\n${bodyOf(4)}\n return null;\n }),\n);\n`,
|
|
26
|
+
);
|
|
27
|
+
expect(sizes).toEqual([{ name: "Canvas", line: 1, lineCount: 9 }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("skips a helper, a value, a bodiless overload and a nested component", () => {
|
|
31
|
+
const sizes = measure(
|
|
32
|
+
`const toLabel = () => "x";\nexport const Columns = 3;\nexport const Theme = { dark: true };\nexport function Overloaded(): null;\nexport const Outer = () => {\n const Inner = () => null;\n return <Inner />;\n};\n`,
|
|
33
|
+
);
|
|
34
|
+
expect(sizes.map(({ name }) => name)).toEqual(["Outer"]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export interface ComponentSize {
|
|
4
|
+
name: string;
|
|
5
|
+
line: number;
|
|
6
|
+
lineCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ComponentNode {
|
|
10
|
+
name: string;
|
|
11
|
+
node: ts.FunctionDeclaration | ts.VariableDeclaration;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//? A hook is a component without markup: the same function-sized unit of screen code, so the same limits hold.
|
|
15
|
+
export class ComponentSizeScanner {
|
|
16
|
+
static readonly #componentName = /^(?:[A-Z]|use[A-Z])[A-Za-z0-9]*$/;
|
|
17
|
+
|
|
18
|
+
static componentsAndHooksOf(sourceFile: ts.SourceFile): ComponentNode[] {
|
|
19
|
+
return sourceFile.statements.flatMap((statement): ComponentNode[] => {
|
|
20
|
+
if (ts.isFunctionDeclaration(statement))
|
|
21
|
+
return statement.name && statement.body && ComponentSizeScanner.#componentName.test(statement.name.text)
|
|
22
|
+
? [{ name: statement.name.text, node: statement }]
|
|
23
|
+
: [];
|
|
24
|
+
if (!ts.isVariableStatement(statement)) return [];
|
|
25
|
+
return statement.declarationList.declarations.flatMap((declaration) =>
|
|
26
|
+
ts.isIdentifier(declaration.name) &&
|
|
27
|
+
ComponentSizeScanner.#componentName.test(declaration.name.text) &&
|
|
28
|
+
declaration.initializer &&
|
|
29
|
+
ComponentSizeScanner.#holdsFunction(declaration.initializer)
|
|
30
|
+
? [{ name: declaration.name.text, node: declaration }]
|
|
31
|
+
: [],
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
measure(sourceFile: ts.SourceFile): ComponentSize[] {
|
|
37
|
+
return ComponentSizeScanner.componentsAndHooksOf(sourceFile).map(({ name, node }) => {
|
|
38
|
+
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
39
|
+
const endLine = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
40
|
+
return { name, line, lineCount: endLine - line + 1 };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//? A wrapped component (`memo(() => …)`, `forwardRef(…)`) is still one component, measured with its wrapper.
|
|
45
|
+
static #holdsFunction(expression: ts.Expression): boolean {
|
|
46
|
+
let current = expression;
|
|
47
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current))
|
|
48
|
+
current = current.expression;
|
|
49
|
+
if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return true;
|
|
50
|
+
return ts.isCallExpression(current) && current.arguments.some((arg) => ComponentSizeScanner.#holdsFunction(arg));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { ComponentWriteChainScanner } from "./componentWriteChainScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
6
|
+
import { WritingActionIndex } from "./writingActionIndex";
|
|
7
|
+
|
|
8
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
9
|
+
file,
|
|
10
|
+
absolutePath: `/repo/${file}`,
|
|
11
|
+
content,
|
|
12
|
+
sourceFile: ts.createSourceFile(
|
|
13
|
+
file,
|
|
14
|
+
content,
|
|
15
|
+
ts.ScriptTarget.Latest,
|
|
16
|
+
true,
|
|
17
|
+
file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const index = new WritingActionIndex(
|
|
22
|
+
new SignalMutationIndex([
|
|
23
|
+
fileOf(
|
|
24
|
+
"apps/demo/lib/court/court.signal.ts",
|
|
25
|
+
`export class CourtSlice extends slice(srv.court, { guards: { root: Admin, cru: User } }, () => ({})) {}
|
|
26
|
+
export class CourtEndpoint extends endpoint(srv.court, ({ query, mutation }) => ({
|
|
27
|
+
skipNvrOnCourt: mutation(cnst.Court, { guards: [User] }).param("courtId", ID).exec(() => null),
|
|
28
|
+
courtCount: query(Int, { guards: [User] }).exec(() => 0),
|
|
29
|
+
})) {}
|
|
30
|
+
`,
|
|
31
|
+
),
|
|
32
|
+
]),
|
|
33
|
+
[
|
|
34
|
+
fileOf(
|
|
35
|
+
"apps/demo/lib/court/court.store.ts",
|
|
36
|
+
`import { fetch, sig } from "../useClient";
|
|
37
|
+
export class CourtStore extends store(sig.court, () => ({})) {
|
|
38
|
+
async skipNvrOnCourt(courtId: string) {
|
|
39
|
+
this.setCourt(await fetch.skipNvrOnCourt(courtId));
|
|
40
|
+
}
|
|
41
|
+
async loadCourtCount() {
|
|
42
|
+
this.set({ count: await fetch.courtCount() });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
`,
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const chainsIn = (body: string) =>
|
|
51
|
+
new ComponentWriteChainScanner(index).scan(
|
|
52
|
+
fileOf(
|
|
53
|
+
"apps/demo/lib/court/Court.Zone.tsx",
|
|
54
|
+
`"use client";\nimport { st } from "@apps/demo/client";\n\nexport const Install = ({ courtId }: { courtId: string }) => {\n${body}\n};\n`,
|
|
55
|
+
).sourceFile,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
describe("ComponentWriteChainScanner", () => {
|
|
59
|
+
test("reports one handler sending two writing actions at the second", () => {
|
|
60
|
+
const chains = chainsIn(` const register = st
|
|
61
|
+
.tool("registerNvr")
|
|
62
|
+
.exec(async () => {
|
|
63
|
+
await st.do.updateCourt(courtId, {});
|
|
64
|
+
await st.do.loadCourtCount();
|
|
65
|
+
await st.do.skipNvrOnCourt(courtId);
|
|
66
|
+
});
|
|
67
|
+
return <button type="button" onClick={() => void register()} />;`);
|
|
68
|
+
|
|
69
|
+
expect(chains).toEqual([
|
|
70
|
+
{ component: "Install", line: 10, writes: ["st.do.updateCourt", "st.do.skipNvrOnCourt"], writeCount: 2 },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("passes two writes in two separate handlers, a render callback included", () => {
|
|
75
|
+
const chains = chainsIn(` const courtIds = [courtId];
|
|
76
|
+
return (
|
|
77
|
+
<div>
|
|
78
|
+
{courtIds.map((id) => (
|
|
79
|
+
<div key={id}>
|
|
80
|
+
<button type="button" onClick={() => void st.do.removeCourt(id)} />
|
|
81
|
+
<button type="button" onClick={() => void st.do.skipNvrOnCourt(id)} />
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);`);
|
|
86
|
+
|
|
87
|
+
expect(chains).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("reads exclusive branches as one and an array or promise callback as part of its handler", () => {
|
|
91
|
+
const chains = chainsIn(` const onSave = async (isNew: boolean) => {
|
|
92
|
+
if (isNew) await st.do.createCourt({});
|
|
93
|
+
else await st.do.updateCourt(courtId, {});
|
|
94
|
+
};
|
|
95
|
+
const onRemoveAll = async (ids: string[]) => {
|
|
96
|
+
await Promise.all(ids.map((id) => st.do.removeCourt(id)));
|
|
97
|
+
await st.do.submitCourt().then(() => st.do.mergeCourt(courtId, {}));
|
|
98
|
+
};
|
|
99
|
+
return <button type="button" onClick={() => void onSave(true)} />;`);
|
|
100
|
+
|
|
101
|
+
expect(chains).toEqual([
|
|
102
|
+
{
|
|
103
|
+
component: "Install",
|
|
104
|
+
line: 11,
|
|
105
|
+
writes: ["st.do.removeCourt (per item)", "st.do.submitCourt", "st.do.mergeCourt"],
|
|
106
|
+
writeCount: 4,
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("reads one write per item as a chain, in a loop or a per-element callback, and a hook like a component", () => {
|
|
112
|
+
const loop = chainsIn(` const onRemoveAll = async (ids: string[]) => {
|
|
113
|
+
for (const id of ids) await st.do.removeCourt(id);
|
|
114
|
+
};
|
|
115
|
+
return <button type="button" onClick={() => void onRemoveAll([courtId])} />;`);
|
|
116
|
+
const hook = new ComponentWriteChainScanner(index).scan(
|
|
117
|
+
fileOf(
|
|
118
|
+
"apps/demo/webkit/useBulk.tsx",
|
|
119
|
+
'import { st } from "@apps/demo/client";\n\nexport const useBulk = () => ({\n run: async (ids: string[]) => {\n await Promise.all(ids.map((id) => st.do.removeCourt(id)));\n },\n});\n',
|
|
120
|
+
).sourceFile,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(loop).toEqual([{ component: "Install", line: 6, writes: ["st.do.removeCourt (per item)"], writeCount: 2 }]);
|
|
124
|
+
expect(hook).toEqual([{ component: "useBulk", line: 5, writes: ["st.do.removeCourt (per item)"], writeCount: 2 }]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("ignores a component file that does not import st", () => {
|
|
128
|
+
const chains = new ComponentWriteChainScanner(index).scan(
|
|
129
|
+
fileOf(
|
|
130
|
+
"apps/demo/ui/Plain.tsx",
|
|
131
|
+
"export const Plain = () => {\n const onClick = async () => {\n await st.do.createCourt({});\n await st.do.removeCourt('a');\n };\n return null;\n};\n",
|
|
132
|
+
).sourceFile,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(chains).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
3
|
+
import { StoreAccessScanner } from "./storeAccessScanner";
|
|
4
|
+
import { WritePath } from "./writePath";
|
|
5
|
+
import type { WritingActionIndex } from "./writingActionIndex";
|
|
6
|
+
|
|
7
|
+
export interface ComponentWriteChain {
|
|
8
|
+
component: string;
|
|
9
|
+
line: number;
|
|
10
|
+
writes: string[];
|
|
11
|
+
writeCount: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//? A callback an array method or a promise runs belongs to the handler that started it; any other function is one.
|
|
15
|
+
export class ComponentWriteChainScanner {
|
|
16
|
+
static readonly #inlineCallers = new Set([
|
|
17
|
+
"map",
|
|
18
|
+
"flatMap",
|
|
19
|
+
"forEach",
|
|
20
|
+
"filter",
|
|
21
|
+
"reduce",
|
|
22
|
+
"some",
|
|
23
|
+
"every",
|
|
24
|
+
"find",
|
|
25
|
+
"findIndex",
|
|
26
|
+
"then",
|
|
27
|
+
"catch",
|
|
28
|
+
"finally",
|
|
29
|
+
]);
|
|
30
|
+
readonly #index: WritingActionIndex;
|
|
31
|
+
|
|
32
|
+
constructor(index: WritingActionIndex) {
|
|
33
|
+
this.#index = index;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
scan(sourceFile: ts.SourceFile): ComponentWriteChain[] {
|
|
37
|
+
const stNames = StoreAccessScanner.stNamesOf(sourceFile);
|
|
38
|
+
if (!stNames.size) return [];
|
|
39
|
+
const path = new WritePath(
|
|
40
|
+
(call) => {
|
|
41
|
+
const action = ComponentWriteChainScanner.#actionOf(call.expression, stNames);
|
|
42
|
+
return !!action && this.#index.isWritingAction(action);
|
|
43
|
+
},
|
|
44
|
+
{ enters: ComponentWriteChainScanner.#isInlineCallback },
|
|
45
|
+
);
|
|
46
|
+
return ComponentSizeScanner.componentsAndHooksOf(sourceFile).flatMap(({ name, node }) =>
|
|
47
|
+
ComponentWriteChainScanner.#handlersIn(node).flatMap((handler) => {
|
|
48
|
+
const writes = path.along(handler);
|
|
49
|
+
const writeCount = path.countOf(writes);
|
|
50
|
+
const second = writes[1] ?? writes[0];
|
|
51
|
+
if (writeCount < 2 || !second) return [];
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
component: name,
|
|
55
|
+
line: sourceFile.getLineAndCharacterOfPosition(second.getStart(sourceFile)).line + 1,
|
|
56
|
+
writes: writes.map((call) => path.labelOf(call, sourceFile)),
|
|
57
|
+
writeCount,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static #handlersIn(component: ts.Node) {
|
|
65
|
+
const bodies: ts.ConciseBody[] = [];
|
|
66
|
+
const visit = (node: ts.Node) => {
|
|
67
|
+
if (
|
|
68
|
+
ComponentWriteChainScanner.#isFunction(node) &&
|
|
69
|
+
node.body &&
|
|
70
|
+
!ComponentWriteChainScanner.#isInlineCallback(node)
|
|
71
|
+
)
|
|
72
|
+
bodies.push(node.body);
|
|
73
|
+
ts.forEachChild(node, visit);
|
|
74
|
+
};
|
|
75
|
+
visit(component);
|
|
76
|
+
return bodies;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static #isFunction(node: ts.Node): node is ts.FunctionLikeDeclaration {
|
|
80
|
+
return (
|
|
81
|
+
ts.isArrowFunction(node) ||
|
|
82
|
+
ts.isFunctionExpression(node) ||
|
|
83
|
+
ts.isFunctionDeclaration(node) ||
|
|
84
|
+
ts.isMethodDeclaration(node)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static #isInlineCallback(fn: ts.Node) {
|
|
89
|
+
const call = fn.parent;
|
|
90
|
+
return (
|
|
91
|
+
!!call &&
|
|
92
|
+
ts.isCallExpression(call) &&
|
|
93
|
+
call.arguments.some((arg) => arg === fn) &&
|
|
94
|
+
ts.isPropertyAccessExpression(call.expression) &&
|
|
95
|
+
ComponentWriteChainScanner.#inlineCallers.has(call.expression.name.text)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static #actionOf(callee: ts.Expression, stNames: Set<string>) {
|
|
100
|
+
if (!ts.isPropertyAccessExpression(callee) || !ts.isPropertyAccessExpression(callee.expression)) return null;
|
|
101
|
+
const { expression: st, name } = callee.expression;
|
|
102
|
+
return ts.isIdentifier(st) && stNames.has(st.text) && name.text === "do" ? callee.name.text : null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { DeprecatedCommentScanner } from "./deprecatedCommentScanner";
|
|
4
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
5
|
+
|
|
6
|
+
const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
7
|
+
file,
|
|
8
|
+
absolutePath: `/repo/${file}`,
|
|
9
|
+
content,
|
|
10
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const scan = (file: string, lines: string[]) =>
|
|
14
|
+
new DeprecatedCommentScanner().scan([fileOf(file, `${lines.join("\n")}\n`)]);
|
|
15
|
+
|
|
16
|
+
describe("DeprecatedCommentScanner", () => {
|
|
17
|
+
test("flags a trailing, a leading and a block deprecated comment", () => {
|
|
18
|
+
const warnings = scan("apps/demo/lib/court/court.constant.ts", [
|
|
19
|
+
"export class CourtInput extends via((field) => ({",
|
|
20
|
+
" legacyVenue: field(String).optional(), // deprecated",
|
|
21
|
+
" // Deprecated: use courtId",
|
|
22
|
+
" legacyCourt: field(String).optional(),",
|
|
23
|
+
" /* DEPRECATED */ oldName: field(String).optional(),",
|
|
24
|
+
" /** deprecated, read venueId */ oldVenue: field(String).optional(),",
|
|
25
|
+
"})) {}",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
expect(warnings.map((warning) => warning.line)).toEqual([2, 3, 5, 6]);
|
|
29
|
+
expect(warnings[0]).toMatchObject({ rule: "akan.comment.deprecated-marker", scope: "comment" });
|
|
30
|
+
expect(warnings[0]?.message).toContain('"// deprecated"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("leaves a JSDoc @deprecated tag and prose that only mentions deprecation", () => {
|
|
34
|
+
expect(
|
|
35
|
+
scan("libs/demo/ui/field.type.ts", [
|
|
36
|
+
"export interface FieldProps {",
|
|
37
|
+
" /** @deprecated Ignored: the field renders `value`. */",
|
|
38
|
+
" defaultValue?: string;",
|
|
39
|
+
" /**",
|
|
40
|
+
" * Old name.",
|
|
41
|
+
" * @deprecated use `value`",
|
|
42
|
+
" */",
|
|
43
|
+
" initial?: string;",
|
|
44
|
+
"}",
|
|
45
|
+
"// Replaces the deprecated upstream plugin.",
|
|
46
|
+
"export const notice = '// deprecated';",
|
|
47
|
+
]),
|
|
48
|
+
).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("stays out of tests", () => {
|
|
52
|
+
expect(scan("apps/demo/lib/court/court.signal.test.ts", ["const venue = 1; // deprecated"])).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|