@akanjs/devkit 3.1.1-rc.15 → 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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SizeViolation } from "./maintainabilityContract";
|
|
3
|
+
import { MaintainabilityReport } from "./maintainabilityReport";
|
|
4
|
+
|
|
5
|
+
const fileOf = (file: string, lineCount = 450): SizeViolation => ({
|
|
6
|
+
kind: "size",
|
|
7
|
+
rule: "akan.size.file",
|
|
8
|
+
key: file,
|
|
9
|
+
file,
|
|
10
|
+
line: 1,
|
|
11
|
+
lineCount,
|
|
12
|
+
maxLines: 400,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const fixLinesOf = (message: string) => message.split("How to fix:\n")[1]?.split("\n") ?? [];
|
|
16
|
+
|
|
17
|
+
describe("MaintainabilityReport", () => {
|
|
18
|
+
test("gives a client file, a ui/ or webkit/ file, a long component and a busy one the same ordered paths", () => {
|
|
19
|
+
const message = MaintainabilityReport.describe("apps/demo", [
|
|
20
|
+
fileOf("lib/court/Court.Zone.tsx"),
|
|
21
|
+
fileOf("ui/Board.tsx"),
|
|
22
|
+
fileOf("webkit/useBoard.tsx"),
|
|
23
|
+
{
|
|
24
|
+
...fileOf("lib/court/Court.Zone.tsx", 300),
|
|
25
|
+
rule: "akan.size.component",
|
|
26
|
+
key: "lib/court/Court.Zone.tsx#Canvas",
|
|
27
|
+
line: 4,
|
|
28
|
+
maxLines: 250,
|
|
29
|
+
component: "Canvas",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
kind: "storeAccess",
|
|
33
|
+
rule: "akan.component.store-access",
|
|
34
|
+
key: "lib/court/Court.Zone.tsx#Canvas",
|
|
35
|
+
file: "lib/court/Court.Zone.tsx",
|
|
36
|
+
line: 4,
|
|
37
|
+
component: "Canvas",
|
|
38
|
+
accessCount: 20,
|
|
39
|
+
maxAccesses: 16,
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
const [fix = "", ...rest] = fixLinesOf(message);
|
|
43
|
+
const order = ["mode/kind table", "webkit/use<X>.tsx", "`common/`", "add-mutation", "`lib/<sub>`"].map((path) =>
|
|
44
|
+
fix.indexOf(path),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(rest).toEqual([]);
|
|
48
|
+
expect(fix).toStartWith(
|
|
49
|
+
" *.Zone.tsx, ui/**, webkit/**, component or hook, component or hook reaching the store often: ",
|
|
50
|
+
);
|
|
51
|
+
expect(order.every((idx) => idx > 0)).toBe(true);
|
|
52
|
+
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
|
53
|
+
expect(fix).toContain('`{ [kind in cnst.X["value"]]: … }`');
|
|
54
|
+
expect(fix).toContain("akan workflow plan create-interaction");
|
|
55
|
+
expect(fix).toContain("never model-bound code moved into `ui/`");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("names a write sent from the wrong place, a call per item and a raised entry with their own fixes", () => {
|
|
59
|
+
const message = MaintainabilityReport.describe("apps/demo", [
|
|
60
|
+
{
|
|
61
|
+
kind: "baselineGrowth",
|
|
62
|
+
rule: "akan.baseline.growth",
|
|
63
|
+
key: "lib/court/court.store.ts#assign",
|
|
64
|
+
file: "akan.baseline.json",
|
|
65
|
+
line: 5,
|
|
66
|
+
debtRule: "akan.store.multi-write",
|
|
67
|
+
count: 3,
|
|
68
|
+
committed: 2,
|
|
69
|
+
reference: "3f2a9c1d0b7e",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
kind: "writePlacement",
|
|
73
|
+
rule: "akan.write.placement",
|
|
74
|
+
key: "webkit/useBoard.tsx",
|
|
75
|
+
file: "webkit/useBoard.tsx",
|
|
76
|
+
line: 7,
|
|
77
|
+
writes: ["st.do.removeBoard", "fetch.placeBoard"],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
kind: "loopCall",
|
|
81
|
+
rule: "akan.service.call-in-loop",
|
|
82
|
+
key: "lib/court/court.service.ts#syncAll",
|
|
83
|
+
file: "lib/court/court.service.ts",
|
|
84
|
+
line: 12,
|
|
85
|
+
owner: "syncAll",
|
|
86
|
+
calls: ["this.pcService.getPc"],
|
|
87
|
+
recorded: 0,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
kind: "multiWrite",
|
|
91
|
+
rule: "akan.component.write-chain",
|
|
92
|
+
key: "webkit/useBoard.tsx#useBoard",
|
|
93
|
+
file: "webkit/useBoard.tsx",
|
|
94
|
+
line: 9,
|
|
95
|
+
owner: "useBoard",
|
|
96
|
+
writes: ["st.do.removeBoard (per item)"],
|
|
97
|
+
writeCount: 2,
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(message).toContain(
|
|
102
|
+
'akan.baseline.growth entry "lib/court/court.store.ts#assign" under akan.store.multi-write allows 3 writes, above the 2 at 3f2a9c1d0b7e',
|
|
103
|
+
);
|
|
104
|
+
expect(message).toContain(
|
|
105
|
+
"akan.write.placement sends 2 server write(s) from outside a store action or a module component: st.do.removeBoard, fetch.placeBoard",
|
|
106
|
+
);
|
|
107
|
+
expect(message).toContain(
|
|
108
|
+
"akan.service.call-in-loop service method syncAll calls once per item: this.pcService.getPc, more than the 0 recorded",
|
|
109
|
+
);
|
|
110
|
+
expect(message).toContain("hook useBoard sends a write per item from one handler: st.do.removeBoard (per item)");
|
|
111
|
+
expect(fixLinesOf(message).map((line) => line.split(":")[0])).toEqual([
|
|
112
|
+
" baseline entry",
|
|
113
|
+
" write sent outside a store action or module component",
|
|
114
|
+
" service call per item",
|
|
115
|
+
" handler with several writes",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("keeps the server files' own fixes", () => {
|
|
120
|
+
const message = MaintainabilityReport.describe("apps/demo", [
|
|
121
|
+
fileOf("lib/court/Court.View.tsx"),
|
|
122
|
+
fileOf("lib/court/court.service.ts", 520),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
expect(fixLinesOf(message)).toEqual([
|
|
126
|
+
" *.View.tsx: Move model-independent pieces into ui/ components and display logic onto Light<Model>.",
|
|
127
|
+
" *.service.ts: Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { AbstractDoc } from "./abstractDoc";
|
|
2
|
+
import type {
|
|
3
|
+
DebtRuleId,
|
|
4
|
+
MaintainabilityCommands,
|
|
5
|
+
MaintainabilityFailure,
|
|
6
|
+
MaintainabilityRuleId,
|
|
7
|
+
} from "./maintainabilityContract";
|
|
8
|
+
import { type FileKind, MaintainabilityLimits } from "./maintainabilityLimits";
|
|
9
|
+
|
|
10
|
+
type FixKey = FileKind | Exclude<MaintainabilityRuleId, "akan.size.file">;
|
|
11
|
+
|
|
12
|
+
export class MaintainabilityReport {
|
|
13
|
+
static readonly #clientSplitFix =
|
|
14
|
+
'Split it, in this order: a mode/kind table `{ [kind in cnst.X["value"]]: … }` in place of mode flags, each entry a component of the module owning the data of that kind; an interaction hook in `webkit/use<X>.tsx` with its pure logic in `common/` (`akan workflow plan create-interaction`); the writes behind one endpoint (`akan workflow plan add-mutation`); last, a sub-concept in its own module (`lib/<sub>` or `lib/_<feature>`), never model-bound code moved into `ui/`.';
|
|
15
|
+
static readonly #serverMarkupFix =
|
|
16
|
+
"Move model-independent pieces into ui/ components and display logic onto Light<Model>.";
|
|
17
|
+
static readonly #oneEndpointFix =
|
|
18
|
+
"Make it one service method that runs the writes inside `this.transaction(fn)`, with a side effect such as a push command in `this.afterCommit(fn)`, behind one endpoint the action calls once; a write per item becomes one endpoint taking the list. An MCP agent or another screen calling one endpoint alone skips the rest.";
|
|
19
|
+
static readonly #fixes: { [key in FixKey]: string } = {
|
|
20
|
+
".Template.tsx": MaintainabilityReport.#clientSplitFix,
|
|
21
|
+
".Unit.tsx": MaintainabilityReport.#serverMarkupFix,
|
|
22
|
+
".Util.tsx": MaintainabilityReport.#clientSplitFix,
|
|
23
|
+
".View.tsx": MaintainabilityReport.#serverMarkupFix,
|
|
24
|
+
".Zone.tsx": MaintainabilityReport.#clientSplitFix,
|
|
25
|
+
"ui/": MaintainabilityReport.#clientSplitFix,
|
|
26
|
+
"webkit/": MaintainabilityReport.#clientSplitFix,
|
|
27
|
+
".store.ts":
|
|
28
|
+
"Move business logic into the service behind an endpoint and derived values onto Light<Model>; an action is fetch, set, toast.",
|
|
29
|
+
".document.ts":
|
|
30
|
+
"Move display and predicate logic onto Light<Model>, and split an unrelated concept into its own module.",
|
|
31
|
+
".service.ts":
|
|
32
|
+
"Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
|
|
33
|
+
[AbstractDoc.suffix]:
|
|
34
|
+
"Trim the abstract to a title, one sentence naming what the module owns, and a `## Rules` list of the invariants the code cannot show.",
|
|
35
|
+
"akan.size.component": MaintainabilityReport.#clientSplitFix,
|
|
36
|
+
"akan.store.multi-write": MaintainabilityReport.#oneEndpointFix,
|
|
37
|
+
"akan.store.root-write-chain": `A store may call another through \`RootStore\`, but that does not make the writes one. ${MaintainabilityReport.#oneEndpointFix}`,
|
|
38
|
+
"akan.component.write-chain":
|
|
39
|
+
"Move the writes into one service method behind one endpoint, called by one store action the handler calls once; a component chaining `st.do` writes, or writing once per item, leaves an agent or another screen half-done.",
|
|
40
|
+
"akan.component.store-access": MaintainabilityReport.#clientSplitFix,
|
|
41
|
+
"akan.write.placement":
|
|
42
|
+
"A server write leaves the browser through one store action, started by one module component. Move a `fetch.<mutation>` into a store action, and hand `ui/` and `webkit/` code the write as an `onX` prop or hook argument from the module's `Zone` or `Util`: they stay free of any one model, and an agent or another screen reaches the write through the store.",
|
|
43
|
+
"akan.service.call-in-loop":
|
|
44
|
+
"Load the set once (`load<Model>Many(ids)`, one filter query) or write it once (one batch method), then loop in memory: a call per item is a round trip per item, so the endpoint slows as its input grows.",
|
|
45
|
+
"akan.baseline.growth":
|
|
46
|
+
"Restore the entry from the default branch, then split the file or move the writes instead: `akan lint` lowers an entry as the code shrinks and never raises one.",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
static readonly #labels: { [key in FixKey]?: string } = {
|
|
50
|
+
"ui/": "ui/**",
|
|
51
|
+
"webkit/": "webkit/**",
|
|
52
|
+
"akan.size.component": "component or hook",
|
|
53
|
+
"akan.store.multi-write": "store action with several writes",
|
|
54
|
+
"akan.store.root-write-chain": "store method chaining other stores' writes",
|
|
55
|
+
"akan.component.write-chain": "handler with several writes",
|
|
56
|
+
"akan.component.store-access": "component or hook reaching the store often",
|
|
57
|
+
"akan.write.placement": "write sent outside a store action or module component",
|
|
58
|
+
"akan.service.call-in-loop": "service call per item",
|
|
59
|
+
"akan.baseline.growth": "baseline entry",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
static readonly #units: { [rule in DebtRuleId]: string } = {
|
|
63
|
+
"akan.size.file": "lines",
|
|
64
|
+
"akan.size.component": "lines",
|
|
65
|
+
"akan.component.store-access": "store accesses",
|
|
66
|
+
"akan.store.multi-write": "writes",
|
|
67
|
+
"akan.store.root-write-chain": "writes",
|
|
68
|
+
"akan.component.write-chain": "writes",
|
|
69
|
+
"akan.write.placement": "writes",
|
|
70
|
+
"akan.service.call-in-loop": "calls per item",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
static describe(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
|
|
74
|
+
const groups = new Map<string, { labels: string[]; rules: Set<MaintainabilityRuleId> }>();
|
|
75
|
+
for (const failure of failures) {
|
|
76
|
+
const fixKey = MaintainabilityReport.#fixKeyOf(failure);
|
|
77
|
+
const fix = MaintainabilityReport.#fixes[fixKey];
|
|
78
|
+
const group = groups.get(fix) ?? { labels: [], rules: new Set() };
|
|
79
|
+
const label = MaintainabilityReport.#labelOf(fixKey);
|
|
80
|
+
if (!group.labels.includes(label)) group.labels.push(label);
|
|
81
|
+
group.rules.add(failure.rule);
|
|
82
|
+
groups.set(fix, group);
|
|
83
|
+
}
|
|
84
|
+
return [
|
|
85
|
+
`[maintainability] ${failures.length} violation(s) in ${sysPath}:`,
|
|
86
|
+
...failures.map(
|
|
87
|
+
(failure) =>
|
|
88
|
+
` ${sysPath}/${failure.file}:${failure.line} ${failure.rule} ${MaintainabilityReport.#problemOf(failure)}`,
|
|
89
|
+
),
|
|
90
|
+
"How to fix:",
|
|
91
|
+
...[...groups].flatMap(([fix, { labels, rules }]) => [
|
|
92
|
+
` ${labels.join(", ")}: ${fix}`,
|
|
93
|
+
...[...new Set([...rules].flatMap((rule) => commands[rule] ?? []))].map((command) => ` Path: ${command}`),
|
|
94
|
+
]),
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static #problemOf(failure: MaintainabilityFailure) {
|
|
99
|
+
if (failure.kind === "baselineGrowth") {
|
|
100
|
+
const entry = `entry "${failure.key}" under ${failure.debtRule}`;
|
|
101
|
+
return failure.committed === undefined
|
|
102
|
+
? `${entry} is not in the baseline at ${failure.reference}, which may only shrink`
|
|
103
|
+
: `${entry} allows ${failure.count} ${MaintainabilityReport.#units[failure.debtRule]}, above the ${failure.committed} at ${failure.reference}; the baseline may only shrink`;
|
|
104
|
+
}
|
|
105
|
+
const grown = failure.recorded === undefined ? "" : `, more than the ${failure.recorded} recorded`;
|
|
106
|
+
if (failure.kind === "multiWrite") {
|
|
107
|
+
const writes = failure.writes.some((write) => write.endsWith("(per item)"))
|
|
108
|
+
? "a write per item"
|
|
109
|
+
: `${failure.writes.length} writes`;
|
|
110
|
+
const listed = `${failure.writes.join(", ")}${grown}`;
|
|
111
|
+
if (failure.rule === "akan.component.write-chain")
|
|
112
|
+
return `${MaintainabilityReport.#unitOf(failure.owner)} ${failure.owner} sends ${writes} from one handler: ${listed}`;
|
|
113
|
+
if (failure.rule === "akan.store.root-write-chain")
|
|
114
|
+
return `store method ${failure.owner} chains ${writes} through other stores from the browser: ${listed}`;
|
|
115
|
+
return `store action ${failure.owner} sends ${writes} from the browser: ${listed}`;
|
|
116
|
+
}
|
|
117
|
+
if (failure.kind === "writePlacement")
|
|
118
|
+
return `sends ${failure.writes.length} server write(s) from outside a store action or a module component: ${failure.writes.join(", ")}${grown}`;
|
|
119
|
+
if (failure.kind === "loopCall")
|
|
120
|
+
return `service method ${failure.owner} calls once per item: ${failure.calls.join(", ")}${grown}`;
|
|
121
|
+
if (failure.kind === "storeAccess")
|
|
122
|
+
return `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} reaches the store ${failure.accessCount} times, over the ${failure.maxAccesses}-access limit${failure.recorded === undefined ? "" : `, up from ${failure.recorded}`}`;
|
|
123
|
+
const subject = failure.component
|
|
124
|
+
? `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} has`
|
|
125
|
+
: "file has";
|
|
126
|
+
const up = failure.recorded === undefined ? "" : `, up from ${failure.recorded}`;
|
|
127
|
+
return `${subject} ${failure.lineCount} lines, over the ${failure.maxLines}-line limit${up}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static #unitOf(name: string) {
|
|
131
|
+
return /^use[A-Z]/.test(name) ? "hook" : "component";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
static #fixKeyOf(failure: MaintainabilityFailure): FixKey {
|
|
135
|
+
if (failure.rule !== "akan.size.file") return failure.rule;
|
|
136
|
+
return MaintainabilityLimits.fileKindOf(failure.file) ?? "akan.size.component";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static #labelOf(fixKey: FixKey) {
|
|
140
|
+
return MaintainabilityReport.#labels[fixKey] ?? `*${fixKey}`;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.1-rc.
|
|
3
|
+
"version": "3.1.1-rc.16",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
49
|
"@inquirer/prompts": "^8.7.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.1-rc.
|
|
51
|
+
"akanjs": "3.1.1-rc.16",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|
package/qualityScanner.test.ts
CHANGED
|
@@ -355,3 +355,78 @@ describe("bang comments in browser-reachable files", () => {
|
|
|
355
355
|
);
|
|
356
356
|
});
|
|
357
357
|
});
|
|
358
|
+
|
|
359
|
+
describe("AkanQualityScanner comment and dead-export rules", () => {
|
|
360
|
+
test("reports comment density, a loose deprecated marker and a test-only export, each with its fix", async () => {
|
|
361
|
+
const root = await makeWorkspace({
|
|
362
|
+
"apps/demo/lib/court/court.constant.ts": [
|
|
363
|
+
...Array.from({ length: 5 }, (_, idx) => `// restates line ${idx}`),
|
|
364
|
+
"export class CourtInput {",
|
|
365
|
+
" legacyVenue?: string; // deprecated",
|
|
366
|
+
"}",
|
|
367
|
+
"",
|
|
368
|
+
].join("\n"),
|
|
369
|
+
"apps/demo/webkit/boardLayout.ts": "export const computeGroupBoxes = () => [];\n",
|
|
370
|
+
"apps/demo/webkit/boardLayout.test.ts":
|
|
371
|
+
'import { computeGroupBoxes } from "./boardLayout";\ncomputeGroupBoxes();\n',
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const result = await new AkanQualityScanner().scan(root);
|
|
375
|
+
const density = rulesOf(result, "akan.comment.density");
|
|
376
|
+
const deprecated = rulesOf(result, "akan.comment.deprecated-marker");
|
|
377
|
+
const testOnly = rulesOf(result, "akan.global.test-only-export");
|
|
378
|
+
|
|
379
|
+
expect(density.map((warning) => warning.file)).toEqual(["apps/demo/lib/court/court.constant.ts"]);
|
|
380
|
+
expect(density[0]?.fix).toContain('AGENTS.md "Comments"');
|
|
381
|
+
expect(deprecated.map((warning) => warning.line)).toEqual([7]);
|
|
382
|
+
expect(deprecated[0]?.fix).toContain("/** @deprecated");
|
|
383
|
+
expect(testOnly.map((warning) => warning.file)).toEqual(["apps/demo/webkit/boardLayout.ts"]);
|
|
384
|
+
expect(testOnly[0]?.fix).toContain("unexported");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("AkanQualityScanner kind-branch rule", () => {
|
|
389
|
+
test("warns on a kind compared across three files and points at a kind table", async () => {
|
|
390
|
+
const compare = 'export const isScene = (pin: { kind: string }) => pin.kind === "videoScene";\n';
|
|
391
|
+
const root = await makeWorkspace({
|
|
392
|
+
"apps/demo/lib/pin/pin.constant.ts":
|
|
393
|
+
'export class PinKind extends enumOf("pinKind", ["videoScene", "memo"] as const) {}\n',
|
|
394
|
+
"apps/demo/lib/board/board.constant.ts": compare,
|
|
395
|
+
"apps/demo/lib/take/take.store.ts": compare,
|
|
396
|
+
"apps/demo/webkit/pinLayout.ts": compare,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.global.kind-branch");
|
|
400
|
+
|
|
401
|
+
expect(warnings.map(({ file, severity }) => ({ file, severity }))).toEqual([
|
|
402
|
+
{ file: "apps/demo/lib/pin/pin.constant.ts", severity: "warning" },
|
|
403
|
+
]);
|
|
404
|
+
expect(warnings[0]?.fix).toContain('cnst.PinKind["value"]');
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("AkanQualityScanner baseline exceptions", () => {
|
|
409
|
+
test("reports each app's recorded baseline entries per rule", async () => {
|
|
410
|
+
const root = await makeWorkspace({
|
|
411
|
+
"apps/demo/akan.baseline.json": `${JSON.stringify(
|
|
412
|
+
{
|
|
413
|
+
"akan.size.file": { "ui/Board.tsx": 420 },
|
|
414
|
+
reasons: { "ui/Board.tsx": "Generated SVG path table for the court." },
|
|
415
|
+
},
|
|
416
|
+
null,
|
|
417
|
+
2,
|
|
418
|
+
)}\n`,
|
|
419
|
+
"apps/demo/ui/Board.tsx": "export const Board = () => null;\n",
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.baseline.exceptions");
|
|
423
|
+
|
|
424
|
+
expect(warnings.map(({ file, message }) => ({ file, message }))).toEqual([
|
|
425
|
+
{
|
|
426
|
+
file: "apps/demo/akan.baseline.json",
|
|
427
|
+
message:
|
|
428
|
+
'apps/demo carries 1 reasoned exception and 0 adopted-debt entries for akan.size.file: ui/Board.tsx ("Generated SVG path table for the court.").',
|
|
429
|
+
},
|
|
430
|
+
]);
|
|
431
|
+
});
|
|
432
|
+
});
|
package/qualityScanner.ts
CHANGED
|
@@ -5,8 +5,13 @@ import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
|
|
|
5
5
|
import ignore from "ignore";
|
|
6
6
|
import ts from "typescript";
|
|
7
7
|
import { AbstractDoc } from "./abstractDoc";
|
|
8
|
+
import { CommentDensityScanner } from "./commentDensityScanner";
|
|
9
|
+
import { DeprecatedCommentScanner } from "./deprecatedCommentScanner";
|
|
8
10
|
import { FormSetterScanner } from "./formSetterScanner";
|
|
11
|
+
import { KindBranchScanner } from "./kindBranchScanner";
|
|
12
|
+
import { MaintainabilityExceptionScanner } from "./maintainabilityExceptionScanner";
|
|
9
13
|
import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
|
|
14
|
+
import { TestOnlyExportScanner } from "./testOnlyExportScanner";
|
|
10
15
|
import {
|
|
11
16
|
facetIndexShadowOf,
|
|
12
17
|
isAllowedLibFacetRootFile,
|
|
@@ -17,7 +22,7 @@ import {
|
|
|
17
22
|
} from "./workspaceLayout";
|
|
18
23
|
|
|
19
24
|
type QualitySeverity = "warning";
|
|
20
|
-
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
|
|
25
|
+
type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent" | "comment";
|
|
21
26
|
|
|
22
27
|
export interface QualityWarning {
|
|
23
28
|
rule: string;
|
|
@@ -115,6 +120,8 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
115
120
|
"Rename one of the exports, or if they are the same thing, extract it into one shared module and import it in both places.",
|
|
116
121
|
"akan.global.duplicate-exported-function-body":
|
|
117
122
|
"Extract the shared implementation into a single exported helper and import it, instead of copying the body.",
|
|
123
|
+
"akan.global.test-only-export":
|
|
124
|
+
"Delete the export together with its test. If a live caller in the same file needs it, keep it unexported and test it through that caller.",
|
|
118
125
|
"akan.file.recommended-max-lines":
|
|
119
126
|
"Split the file by responsibility — move Zones, Utils, or subcomponents into sibling files.",
|
|
120
127
|
"akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
|
|
@@ -163,6 +170,10 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
163
170
|
"Add a <Model>.Unit.tsx for list/card rendering and a <Model>.View.tsx for the detail surface, then have the Zone delegate to them.",
|
|
164
171
|
"akan.ssr.template-client-state":
|
|
165
172
|
"Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
|
|
173
|
+
"akan.comment.density":
|
|
174
|
+
'Delete comments that restate the code, label a section, or narrate a step; keep only those carrying a fact the code cannot show, such as a vendor quirk or security reasoning (AGENTS.md "Comments").',
|
|
175
|
+
"akan.comment.deprecated-marker":
|
|
176
|
+
"Replace the comment with `/** @deprecated <what to use instead> */` on the declaration, so every use site shows it struck through.",
|
|
166
177
|
};
|
|
167
178
|
|
|
168
179
|
function getRuleFix(rule: string): string | undefined {
|
|
@@ -193,6 +204,9 @@ export class AkanQualityScanner {
|
|
|
193
204
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
194
205
|
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
195
206
|
...new FormSetterScanner().scan(sourceFiles),
|
|
207
|
+
...new CommentDensityScanner().scan(sourceFiles),
|
|
208
|
+
...new DeprecatedCommentScanner().scan(sourceFiles),
|
|
209
|
+
...(await new MaintainabilityExceptionScanner().scan(workspaceRoot)),
|
|
196
210
|
...ssr.warnings,
|
|
197
211
|
];
|
|
198
212
|
|
|
@@ -297,6 +311,8 @@ export class AkanQualityScanner {
|
|
|
297
311
|
});
|
|
298
312
|
}
|
|
299
313
|
|
|
314
|
+
warnings.push(...new TestOnlyExportScanner().scan(sourceFiles));
|
|
315
|
+
warnings.push(...new KindBranchScanner().scan(sourceFiles));
|
|
300
316
|
return warnings;
|
|
301
317
|
}
|
|
302
318
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { ServiceLoopCallScanner } from "./serviceLoopCallScanner";
|
|
4
|
+
|
|
5
|
+
const scan = (body: string, answersCaller: (method: string) => boolean = () => true) =>
|
|
6
|
+
new ServiceLoopCallScanner().scan(
|
|
7
|
+
ts.createSourceFile(
|
|
8
|
+
"court.service.ts",
|
|
9
|
+
`export class CourtService extends serve(db.court, () => ({})) {\n${body}\n}\n`,
|
|
10
|
+
ts.ScriptTarget.Latest,
|
|
11
|
+
true,
|
|
12
|
+
),
|
|
13
|
+
answersCaller,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
describe("ServiceLoopCallScanner", () => {
|
|
17
|
+
test("names a service, model or generated query called per item, directly or through a method of its own", () => {
|
|
18
|
+
const calls = scan(` async syncAll(ids: string[]) {
|
|
19
|
+
for (const id of await this.listIds()) await this.pcService.ping(id);
|
|
20
|
+
await Promise.all(ids.map((id) => this.#touch(id)));
|
|
21
|
+
ids.forEach((id) => this.logger.info(id));
|
|
22
|
+
}
|
|
23
|
+
async #touch(id: string) {
|
|
24
|
+
await this.getCourt(id);
|
|
25
|
+
}`);
|
|
26
|
+
|
|
27
|
+
expect(calls).toEqual([{ method: "syncAll", line: 3, calls: ["this.pcService.ping", "this.#touch"] }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("spares a counted loop, a call made once, and a method no endpoint reaches", () => {
|
|
31
|
+
const body = ` async retry(id: string) {
|
|
32
|
+
for (let attempt = 0; attempt < 3; attempt += 1) await this.courtModel.touch(id);
|
|
33
|
+
while (await this.courtModel.isLocked(id)) await Bun.sleep(10);
|
|
34
|
+
}
|
|
35
|
+
async sweep(ids: string[]) {
|
|
36
|
+
const courts = await this.loadCourtMany(ids);
|
|
37
|
+
for (const court of courts) court.touch();
|
|
38
|
+
await Promise.all(ids.map((id) => this.pcService.ping(id)));
|
|
39
|
+
}`;
|
|
40
|
+
|
|
41
|
+
expect(scan(body, (method) => method === "retry")).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("spares a get or load a per-element callback issues in one tick, which the DataLoader batches", () => {
|
|
45
|
+
const calls = scan(` async courtsOf(ids: string[], pcIds: string[]) {
|
|
46
|
+
await Promise.all(ids.map((id) => this.getCourt(id)));
|
|
47
|
+
await Promise.all(pcIds.map((id) => this.pcModel.loadPc(id)));
|
|
48
|
+
await Promise.all(ids.map((id) => this.courtModel.countCourt({ id })));
|
|
49
|
+
for (const id of ids) await this.courtModel.getCourt(id);
|
|
50
|
+
}`);
|
|
51
|
+
|
|
52
|
+
expect(calls).toEqual([
|
|
53
|
+
{ method: "courtsOf", line: 5, calls: ["this.courtModel.countCourt", "this.courtModel.getCourt"] },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { WritePath } from "./writePath";
|
|
3
|
+
|
|
4
|
+
export interface ServiceLoopCall {
|
|
5
|
+
method: string;
|
|
6
|
+
line: number;
|
|
7
|
+
calls: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type NamedMethod = ts.MethodDeclaration & { name: ts.Identifier | ts.PrivateIdentifier };
|
|
11
|
+
|
|
12
|
+
type PerItem = false | "loop" | "callback";
|
|
13
|
+
|
|
14
|
+
//? A counted loop (`for (;;)`, `while`) retries or pages through a query, so only a loop over items is per item.
|
|
15
|
+
export class ServiceLoopCallScanner {
|
|
16
|
+
static readonly #reachingField = /(?:Service|Model)$/;
|
|
17
|
+
static readonly #generatedQuery =
|
|
18
|
+
/^_*(?:get|load|list|find|pick|exists|count|insight|create|update|remove|search)[A-Z]/;
|
|
19
|
+
|
|
20
|
+
scan(sourceFile: ts.SourceFile, answersCaller: (method: string) => boolean): ServiceLoopCall[] {
|
|
21
|
+
return sourceFile.statements.filter(ts.isClassDeclaration).flatMap((cls) => {
|
|
22
|
+
const methods = cls.members.filter(
|
|
23
|
+
(member): member is NamedMethod =>
|
|
24
|
+
ts.isMethodDeclaration(member) &&
|
|
25
|
+
!!member.body &&
|
|
26
|
+
(ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)),
|
|
27
|
+
);
|
|
28
|
+
const declared = new Set(methods.map((method) => method.name.text));
|
|
29
|
+
const reaching = ServiceLoopCallScanner.#reachingMethodsOf(methods, declared);
|
|
30
|
+
const reaches = (call: ts.CallExpression) => ServiceLoopCallScanner.#reaches(call, declared, reaching);
|
|
31
|
+
const ownModel = cls.name?.text.replace(/Service$/, "") ?? "";
|
|
32
|
+
return methods.flatMap((method) => {
|
|
33
|
+
if (!answersCaller(method.name.text)) return [];
|
|
34
|
+
const calls: ts.CallExpression[] = [];
|
|
35
|
+
const visit = (node: ts.Node, perItem: PerItem) => {
|
|
36
|
+
if (
|
|
37
|
+
perItem &&
|
|
38
|
+
ts.isCallExpression(node) &&
|
|
39
|
+
reaches(node) &&
|
|
40
|
+
!(perItem === "callback" && ServiceLoopCallScanner.#isBatchedLoad(node, declared, ownModel))
|
|
41
|
+
)
|
|
42
|
+
calls.push(node);
|
|
43
|
+
if (ts.isForOfStatement(node) || ts.isForInStatement(node)) {
|
|
44
|
+
visit(node.expression, perItem);
|
|
45
|
+
visit(node.statement, "loop");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
ts.forEachChild(node, (child) =>
|
|
49
|
+
visit(child, perItem === "loop" || !WritePath.isPerElementCallback(child) ? perItem : "callback"),
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
visit(method, false);
|
|
53
|
+
const first = calls[0];
|
|
54
|
+
if (!first) return [];
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
method: method.name.text,
|
|
58
|
+
line: sourceFile.getLineAndCharacterOfPosition(first.getStart(sourceFile)).line + 1,
|
|
59
|
+
calls: calls.map((call) => call.expression.getText(sourceFile)),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static #reachingMethodsOf(methods: NamedMethod[], declared: Set<string>) {
|
|
67
|
+
const reaching = new Set<string>();
|
|
68
|
+
let grew = true;
|
|
69
|
+
while (grew) {
|
|
70
|
+
grew = false;
|
|
71
|
+
for (const method of methods) {
|
|
72
|
+
const name = method.name.text;
|
|
73
|
+
if (reaching.has(name)) continue;
|
|
74
|
+
let found = false;
|
|
75
|
+
const visit = (node: ts.Node) => {
|
|
76
|
+
if (found) return;
|
|
77
|
+
if (ts.isCallExpression(node) && ServiceLoopCallScanner.#reaches(node, declared, reaching)) found = true;
|
|
78
|
+
else ts.forEachChild(node, visit);
|
|
79
|
+
};
|
|
80
|
+
visit(method);
|
|
81
|
+
if (found) {
|
|
82
|
+
reaching.add(name);
|
|
83
|
+
grew = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return reaching;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//? `get<Model>` / `load<Model>` / `load<Model>Many` go through the model's DataLoader, so the calls a per-element
|
|
91
|
+
//? callback issues in one tick are one query; a `for…of` awaiting each item issues them a tick apart, one by one.
|
|
92
|
+
static #isBatchedLoad(call: ts.CallExpression, declared: Set<string>, ownModel: string) {
|
|
93
|
+
const callee = call.expression;
|
|
94
|
+
if (!ts.isPropertyAccessExpression(callee)) return false;
|
|
95
|
+
const model = ServiceLoopCallScanner.#loadedModelOf(callee, declared, ownModel);
|
|
96
|
+
if (!model) return false;
|
|
97
|
+
const className = `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
|
|
98
|
+
return [`get${className}`, `load${className}`, `load${className}Many`].includes(callee.name.text);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static #loadedModelOf(callee: ts.PropertyAccessExpression, declared: Set<string>, ownModel: string) {
|
|
102
|
+
const owner = callee.expression;
|
|
103
|
+
if (owner.kind === ts.SyntaxKind.ThisKeyword) return declared.has(callee.name.text) ? null : ownModel;
|
|
104
|
+
if (!ts.isPropertyAccessExpression(owner) || owner.expression.kind !== ts.SyntaxKind.ThisKeyword) return null;
|
|
105
|
+
return owner.name.text.match(/^(\w+)Model$/)?.[1] ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static #reaches(call: ts.CallExpression, declared: Set<string>, reaching: Set<string>) {
|
|
109
|
+
const callee = call.expression;
|
|
110
|
+
if (!ts.isPropertyAccessExpression(callee)) return false;
|
|
111
|
+
const owner = callee.expression;
|
|
112
|
+
if (owner.kind === ts.SyntaxKind.ThisKeyword) {
|
|
113
|
+
const name = callee.name.text;
|
|
114
|
+
return declared.has(name) ? reaching.has(name) : ServiceLoopCallScanner.#generatedQuery.test(name);
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
ts.isPropertyAccessExpression(owner) &&
|
|
118
|
+
owner.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
119
|
+
ServiceLoopCallScanner.#reachingField.test(owner.name.text)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { SourceComments } from "./sourceComments";
|
|
4
|
+
|
|
5
|
+
const commentsOf = (content: string) =>
|
|
6
|
+
SourceComments.of(ts.createSourceFile("demo.tsx", content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)).map(
|
|
7
|
+
({ line, body, isJsDoc }) => ({ line, body, isJsDoc }),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
describe("SourceComments", () => {
|
|
11
|
+
test("finds leading, trailing, empty-block and end-of-file comments", () => {
|
|
12
|
+
expect(
|
|
13
|
+
commentsOf(
|
|
14
|
+
["// leading", "const a = 1; // trailing", "const f = () => {", " /* inside */", "};", "// last", ""].join(
|
|
15
|
+
"\n",
|
|
16
|
+
),
|
|
17
|
+
),
|
|
18
|
+
).toEqual([
|
|
19
|
+
{ line: 1, body: ["leading"], isJsDoc: false },
|
|
20
|
+
{ line: 2, body: ["trailing"], isJsDoc: false },
|
|
21
|
+
{ line: 4, body: ["inside"], isJsDoc: false },
|
|
22
|
+
{ line: 6, body: ["last"], isJsDoc: false },
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("ignores comment syntax inside strings, templates, regexes and JSX text", () => {
|
|
27
|
+
expect(
|
|
28
|
+
commentsOf(
|
|
29
|
+
[
|
|
30
|
+
'const url = "https://host//path";',
|
|
31
|
+
`const tpl = \`a // b \${url} // c\`;`,
|
|
32
|
+
"const re = /\\/\\/x/;",
|
|
33
|
+
'export const C = () => <div title="//x">// text {/* real */}</div>;',
|
|
34
|
+
"",
|
|
35
|
+
].join("\n"),
|
|
36
|
+
),
|
|
37
|
+
).toEqual([{ line: 4, body: ["real"], isJsDoc: false }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("strips block delimiters and marks a JSDoc", () => {
|
|
41
|
+
expect(
|
|
42
|
+
commentsOf(["/**", " * Summary", " * @deprecated use b", " */", "export const a = 1;", ""].join("\n")),
|
|
43
|
+
).toEqual([{ line: 1, body: ["", "Summary", "@deprecated use b", ""], isJsDoc: true }]);
|
|
44
|
+
});
|
|
45
|
+
});
|