@akanjs/devkit 3.1.1-rc.8 → 3.1.1
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/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +30 -0
- package/akanConfig/akanConfig.ts +10 -0
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +3 -1
- package/biome.base.json +4 -1
- package/cloud/cloudUpdates.ts +3 -2
- package/codeAgent/agent/CodeAgent.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +36 -0
- package/codeAgent/skills/akan-code/SKILL.md +1 -0
- package/codeAgent/skills/akan-debug/SKILL.md +40 -6
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +38 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +5 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/akanCodePlugins.test.ts +60 -58
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +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/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/executors.test.ts +0 -4
- package/executors.ts +18 -2
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- 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/linter.test.ts +12 -0
- package/linter.ts +35 -12
- 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 +153 -0
- package/mobile/desktopRelease.test.ts +94 -23
- package/mobile/desktopRelease.ts +83 -45
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +18 -11
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -6
- package/package.json +3 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +1 -1
- 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/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/types.ts +1 -0
- package/workflow/artifacts.ts +16 -2
- 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 +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/types.ts +18 -1
- 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
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -110,6 +110,21 @@ export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
|
|
|
110
110
|
expect(index.isMutation("apps/third/page/x.tsx", "approveTicket")).toBe(false);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("reads a lib page's call by its own lib's endpoint before another lib's of that name", () => {
|
|
114
|
+
const shared = fileOf(
|
|
115
|
+
"libs/shared/lib/_shared/shared.signal.ts",
|
|
116
|
+
`export class SharedEndpoint extends endpoint(srv.shared, ({ mutation }) => ({ syncFeed: mutation(Boolean, {}).exec(() => true) })) {}`,
|
|
117
|
+
);
|
|
118
|
+
const social = fileOf(
|
|
119
|
+
"libs/social/lib/_social/social.signal.ts",
|
|
120
|
+
`export class SocialEndpoint extends endpoint(srv.social, ({ query }) => ({ syncFeed: query(Boolean, {}).exec(() => true) })) {}`,
|
|
121
|
+
);
|
|
122
|
+
const index = new SignalMutationIndex([shared, social]);
|
|
123
|
+
expect(index.isMutation("libs/social/page/x.tsx", "syncFeed")).toBe(false);
|
|
124
|
+
expect(index.policyIndexOf("libs/social/page/x.tsx", "syncFeed")).toBeNull();
|
|
125
|
+
expect(index.isMutation("libs/shared/page/x.tsx", "syncFeed")).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
113
128
|
test("ignores signal tests and specs", () => {
|
|
114
129
|
const spec = fileOf(
|
|
115
130
|
"apps/demo/lib/ticket/ticket.signal.spec.ts",
|
package/signalMutationIndex.ts
CHANGED
|
@@ -34,7 +34,7 @@ export class SignalMutationIndex {
|
|
|
34
34
|
|
|
35
35
|
#shapeOf(consumerFile: string, name: string) {
|
|
36
36
|
const [root, owner] = consumerFile.split("/");
|
|
37
|
-
const own =
|
|
37
|
+
const own = this.#shapesByOwner.get(`${root}/${owner}`)?.get(name);
|
|
38
38
|
if (own) return own;
|
|
39
39
|
for (const [key, shapes] of this.#shapesByOwner) {
|
|
40
40
|
const shape = shapes.get(name);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
export interface SourceComment {
|
|
4
|
+
raw: string;
|
|
5
|
+
line: number;
|
|
6
|
+
isJsDoc: boolean;
|
|
7
|
+
body: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SourceComments {
|
|
11
|
+
static #scanner = ts.createScanner(ts.ScriptTarget.Latest, false);
|
|
12
|
+
|
|
13
|
+
static of(sourceFile: ts.SourceFile): SourceComment[] {
|
|
14
|
+
const comments: SourceComment[] = [];
|
|
15
|
+
const scanGap = (start: number, end: number) => {
|
|
16
|
+
if (end <= start) return;
|
|
17
|
+
const scanner = SourceComments.#scanner;
|
|
18
|
+
scanner.setText(sourceFile.text, start, end - start);
|
|
19
|
+
for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) {
|
|
20
|
+
if (kind !== ts.SyntaxKind.SingleLineCommentTrivia && kind !== ts.SyntaxKind.MultiLineCommentTrivia) continue;
|
|
21
|
+
comments.push(SourceComments.#commentOf(sourceFile, scanner.getTokenStart(), scanner.getTokenText()));
|
|
22
|
+
}
|
|
23
|
+
scanner.setText(undefined);
|
|
24
|
+
};
|
|
25
|
+
//? Only the trivia between AST children is scanned: string, template, regex and JSX text tokens can hold `//`.
|
|
26
|
+
const visit = (node: ts.Node) => {
|
|
27
|
+
if (node.kind === ts.SyntaxKind.JsxText) return;
|
|
28
|
+
if (ts.isToken(node)) {
|
|
29
|
+
scanGap(node.pos, node.getStart(sourceFile));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const children: ts.Node[] = [];
|
|
33
|
+
ts.forEachChild(
|
|
34
|
+
node,
|
|
35
|
+
(child) => {
|
|
36
|
+
children.push(child);
|
|
37
|
+
},
|
|
38
|
+
(nodes) => {
|
|
39
|
+
children.push(...nodes);
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
let cursor = node.pos;
|
|
43
|
+
for (const child of children) {
|
|
44
|
+
scanGap(cursor, child.pos);
|
|
45
|
+
visit(child);
|
|
46
|
+
cursor = Math.max(cursor, child.end);
|
|
47
|
+
}
|
|
48
|
+
scanGap(cursor, node.end);
|
|
49
|
+
};
|
|
50
|
+
visit(sourceFile);
|
|
51
|
+
return comments;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static #commentOf(sourceFile: ts.SourceFile, pos: number, raw: string): SourceComment {
|
|
55
|
+
return {
|
|
56
|
+
raw,
|
|
57
|
+
line: sourceFile.getLineAndCharacterOfPosition(pos).line + 1,
|
|
58
|
+
isJsDoc: raw.startsWith("/**") && !raw.startsWith("/**/"),
|
|
59
|
+
body: SourceComments.#bodyOf(raw),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static #bodyOf(raw: string) {
|
|
64
|
+
if (raw.startsWith("//")) return [raw.replace(/^\/\/+/, "").trim()];
|
|
65
|
+
return raw
|
|
66
|
+
.slice(2, raw.endsWith("*/") ? -2 : undefined)
|
|
67
|
+
.split(/\r?\n/)
|
|
68
|
+
.map((line) => line.replace(/^\s*\*+/, "").trim());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
4
|
+
import { StoreAccessScanner } from "./storeAccessScanner";
|
|
5
|
+
import { WritingActionIndex } from "./writingActionIndex";
|
|
6
|
+
|
|
7
|
+
const sourceOf = (file: string, content: string) =>
|
|
8
|
+
ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
9
|
+
|
|
10
|
+
const storeFile = "apps/demo/lib/board/board.store.ts";
|
|
11
|
+
const signalFile = "apps/demo/lib/boardPin/boardPin.signal.ts";
|
|
12
|
+
const signal = sourceOf(
|
|
13
|
+
signalFile,
|
|
14
|
+
"export class BoardPinSlice extends slice(srv.boardPin, { guards: { root: Admin, cru: User } }, () => ({})) {}\n",
|
|
15
|
+
);
|
|
16
|
+
const index = new WritingActionIndex(
|
|
17
|
+
new SignalMutationIndex([{ file: signalFile, absolutePath: "", content: "", sourceFile: signal }]),
|
|
18
|
+
[
|
|
19
|
+
{
|
|
20
|
+
file: storeFile,
|
|
21
|
+
sourceFile: sourceOf(
|
|
22
|
+
storeFile,
|
|
23
|
+
`import { fetch, sig } from "../useClient";
|
|
24
|
+
export class BoardStore extends store(sig.board, () => ({})) {
|
|
25
|
+
async addPinOnCanvas(boardId: string) {
|
|
26
|
+
await fetch.createBoardPin({ board: boardId });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
`,
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const measure = (content: string) =>
|
|
36
|
+
new StoreAccessScanner(index).measure(sourceOf("apps/demo/lib/board/Board.Zone.tsx", content));
|
|
37
|
+
|
|
38
|
+
describe("StoreAccessScanner", () => {
|
|
39
|
+
test("counts every read, action, selector, slice and agent hook a component reaches the store through", () => {
|
|
40
|
+
const counts = measure(`"use client";
|
|
41
|
+
import { st as store } from "@apps/demo/client";
|
|
42
|
+
|
|
43
|
+
export const Canvas = () => {
|
|
44
|
+
const board = store.use.board();
|
|
45
|
+
const pins = store.slice.boardInSelf.use.boardList();
|
|
46
|
+
const zoom = store.sel((state) => state.zoom);
|
|
47
|
+
const latest = store.ref((state) => state.board);
|
|
48
|
+
const move = store.tool("movePin").exec(() => store.do.setZoom(1));
|
|
49
|
+
return <div onClick={() => void store.do.addPinOnCanvas(board.id)}>{pins.length}{zoom}{latest.current?.id}{String(move)}</div>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const Empty = () => null;
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
expect(counts).toEqual([
|
|
56
|
+
{ component: "Canvas", line: 4, accessCount: 7 },
|
|
57
|
+
{ component: "Empty", line: 13, accessCount: 0 },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("leaves out a generated form setter bound to its control, but not a writing action named like one", () => {
|
|
62
|
+
const counts = measure(`"use client";
|
|
63
|
+
import { st } from "@apps/demo/client";
|
|
64
|
+
|
|
65
|
+
export const General = () => {
|
|
66
|
+
const boardForm = st.use.boardForm();
|
|
67
|
+
return (
|
|
68
|
+
<form>
|
|
69
|
+
<input value={boardForm.name} onChange={(e) => st.do.setNameOnBoard(e.target.value)} />
|
|
70
|
+
<input onChange={st.do.addTagOnBoard} />
|
|
71
|
+
<input onChange={(e) => st.do.writeOnBoard("meta.title", e.target.value)} />
|
|
72
|
+
<button type="button" onClick={() => void st.do.addPinOnCanvas(boardForm.id)} />
|
|
73
|
+
</form>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
`);
|
|
77
|
+
|
|
78
|
+
expect(counts).toEqual([{ component: "General", line: 4, accessCount: 2 }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("measures nothing in a file that does not import st", () => {
|
|
82
|
+
expect(measure("export const Plain = () => st.use.board();\n")).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
3
|
+
import type { WritingActionIndex } from "./writingActionIndex";
|
|
4
|
+
|
|
5
|
+
export interface StoreAccessCount {
|
|
6
|
+
component: string;
|
|
7
|
+
line: number;
|
|
8
|
+
accessCount: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
//? A generated form setter only binds a field to its control: a long form is a size breach, not a busy component.
|
|
12
|
+
export class StoreAccessScanner {
|
|
13
|
+
static readonly #formSetter = /^(?:(?:set|add|sub|addOrSub|upload)[A-Z]\w*On|writeOn)[A-Z]\w*$/;
|
|
14
|
+
readonly #index: WritingActionIndex;
|
|
15
|
+
|
|
16
|
+
constructor(index: WritingActionIndex) {
|
|
17
|
+
this.#index = index;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static stNamesOf(sourceFile: ts.SourceFile) {
|
|
21
|
+
return StoreAccessScanner.localNamesOf(sourceFile, "st");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static localNamesOf(sourceFile: ts.SourceFile, imported: string) {
|
|
25
|
+
const names = new Set<string>();
|
|
26
|
+
for (const statement of sourceFile.statements) {
|
|
27
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
|
|
28
|
+
const bindings = statement.importClause?.namedBindings;
|
|
29
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
30
|
+
for (const element of bindings.elements)
|
|
31
|
+
if (!element.isTypeOnly && (element.propertyName ?? element.name).text === imported)
|
|
32
|
+
names.add(element.name.text);
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
measure(sourceFile: ts.SourceFile): StoreAccessCount[] {
|
|
38
|
+
const stNames = StoreAccessScanner.stNamesOf(sourceFile);
|
|
39
|
+
if (!stNames.size) return [];
|
|
40
|
+
return ComponentSizeScanner.componentsAndHooksOf(sourceFile).map(({ name, node }) => {
|
|
41
|
+
let accessCount = 0;
|
|
42
|
+
const visit = (child: ts.Node) => {
|
|
43
|
+
if (
|
|
44
|
+
ts.isPropertyAccessExpression(child) &&
|
|
45
|
+
ts.isIdentifier(child.expression) &&
|
|
46
|
+
stNames.has(child.expression.text) &&
|
|
47
|
+
!this.#isFormSetter(child)
|
|
48
|
+
)
|
|
49
|
+
accessCount += 1;
|
|
50
|
+
ts.forEachChild(child, visit);
|
|
51
|
+
};
|
|
52
|
+
visit(node);
|
|
53
|
+
return {
|
|
54
|
+
component: name,
|
|
55
|
+
line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
|
|
56
|
+
accessCount,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#isFormSetter(access: ts.PropertyAccessExpression) {
|
|
62
|
+
const action = access.parent;
|
|
63
|
+
return (
|
|
64
|
+
access.name.text === "do" &&
|
|
65
|
+
ts.isPropertyAccessExpression(action) &&
|
|
66
|
+
action.expression === access &&
|
|
67
|
+
StoreAccessScanner.#formSetter.test(action.name.text) &&
|
|
68
|
+
!this.#index.isWritingAction(action.name.text)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/storeSource.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
3
|
+
|
|
4
|
+
export interface StoreClass {
|
|
5
|
+
name: string;
|
|
6
|
+
refName: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StoreMethod {
|
|
10
|
+
store: string;
|
|
11
|
+
name: string;
|
|
12
|
+
isStatic: boolean;
|
|
13
|
+
body: ts.Block;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type StoreCallTarget =
|
|
17
|
+
| { kind: "fetch"; name: string }
|
|
18
|
+
| { kind: "action"; name: string; isOwn: boolean }
|
|
19
|
+
| { kind: "static"; store: string; name: string };
|
|
20
|
+
|
|
21
|
+
export class StoreSource {
|
|
22
|
+
readonly file: string;
|
|
23
|
+
readonly stores: StoreClass[] = [];
|
|
24
|
+
readonly methods: StoreMethod[] = [];
|
|
25
|
+
readonly #fetchNames: Set<string>;
|
|
26
|
+
readonly #ownActionsByStore = new Map<string, Set<string>>();
|
|
27
|
+
readonly #rootAliasesByMethod = new Map<StoreMethod, Set<string>>();
|
|
28
|
+
|
|
29
|
+
//? The actions `store()` generates that send a mutation; `upload<Field>On<Model>` only stages a file the form saves.
|
|
30
|
+
static generatedWritesOf(refName: string) {
|
|
31
|
+
const model = `${refName.charAt(0).toUpperCase()}${refName.slice(1)}`;
|
|
32
|
+
return [
|
|
33
|
+
`create${model}`,
|
|
34
|
+
`update${model}`,
|
|
35
|
+
`remove${model}`,
|
|
36
|
+
`merge${model}`,
|
|
37
|
+
`create${model}InForm`,
|
|
38
|
+
`update${model}InForm`,
|
|
39
|
+
`submit${model}`,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
constructor({ file, sourceFile }: Pick<SourceFileInfo, "file" | "sourceFile">) {
|
|
44
|
+
this.file = file;
|
|
45
|
+
this.#fetchNames = StoreSource.#fetchBindingsOf(sourceFile);
|
|
46
|
+
for (const statement of sourceFile.statements) {
|
|
47
|
+
if (!ts.isClassDeclaration(statement) || !statement.name) continue;
|
|
48
|
+
const refName = StoreSource.#refNameOf(statement);
|
|
49
|
+
if (refName === undefined) continue;
|
|
50
|
+
const store = statement.name.text;
|
|
51
|
+
this.stores.push({ name: store, refName });
|
|
52
|
+
const ownActions = new Set(refName ? StoreSource.generatedWritesOf(refName) : []);
|
|
53
|
+
for (const member of statement.members) {
|
|
54
|
+
if (!ts.isMethodDeclaration(member) || !member.body) continue;
|
|
55
|
+
const isStatic = !!ts.getModifiers(member)?.some(({ kind }) => kind === ts.SyntaxKind.StaticKeyword);
|
|
56
|
+
const name = member.name.getText(sourceFile);
|
|
57
|
+
if (!isStatic) ownActions.add(name);
|
|
58
|
+
const method = { store, name, isStatic, body: member.body };
|
|
59
|
+
this.methods.push(method);
|
|
60
|
+
this.#rootAliasesByMethod.set(method, StoreSource.#rootAliasesIn(member));
|
|
61
|
+
}
|
|
62
|
+
this.#ownActionsByStore.set(store, ownActions);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
targetsIn(method: StoreMethod) {
|
|
67
|
+
const targets: StoreCallTarget[] = [];
|
|
68
|
+
const visit = (node: ts.Node) => {
|
|
69
|
+
const target = ts.isCallExpression(node) ? this.targetOf(method, node) : null;
|
|
70
|
+
if (target) targets.push(target);
|
|
71
|
+
ts.forEachChild(node, visit);
|
|
72
|
+
};
|
|
73
|
+
visit(method.body);
|
|
74
|
+
return targets;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
targetOf(method: StoreMethod, call: ts.CallExpression): StoreCallTarget | null {
|
|
78
|
+
const callee = call.expression;
|
|
79
|
+
if (!ts.isPropertyAccessExpression(callee) && !ts.isElementAccessExpression(callee)) return null;
|
|
80
|
+
const name = ts.isPropertyAccessExpression(callee)
|
|
81
|
+
? callee.name.text
|
|
82
|
+
: ts.isStringLiteralLike(callee.argumentExpression)
|
|
83
|
+
? callee.argumentExpression.text
|
|
84
|
+
: null;
|
|
85
|
+
if (name === null) return null;
|
|
86
|
+
const receiver = StoreSource.#unwrap(callee.expression);
|
|
87
|
+
const isThis = receiver.kind === ts.SyntaxKind.ThisKeyword;
|
|
88
|
+
if (isThis && method.isStatic) return { kind: "static", store: method.store, name };
|
|
89
|
+
if (!isThis && !ts.isIdentifier(receiver)) return null;
|
|
90
|
+
if (ts.isIdentifier(receiver) && this.#fetchNames.has(receiver.text)) return { kind: "fetch", name };
|
|
91
|
+
if (isThis || (ts.isIdentifier(receiver) && this.#rootAliasesByMethod.get(method)?.has(receiver.text)))
|
|
92
|
+
return { kind: "action", name, isOwn: !!this.#ownActionsByStore.get(method.store)?.has(name) };
|
|
93
|
+
return ts.isIdentifier(receiver) ? { kind: "static", store: receiver.text, name } : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//? `this`, cast to `RootStore` or not, an alias of it, or a `RootStore` parameter reaches every store's action.
|
|
97
|
+
static #rootAliasesIn(method: ts.MethodDeclaration) {
|
|
98
|
+
const aliases = new Set<string>();
|
|
99
|
+
const visit = (node: ts.Node) => {
|
|
100
|
+
if (ts.isParameter(node) && ts.isIdentifier(node.name) && StoreSource.#isRootStoreType(node.type))
|
|
101
|
+
aliases.add(node.name.text);
|
|
102
|
+
if (
|
|
103
|
+
ts.isVariableDeclaration(node) &&
|
|
104
|
+
ts.isIdentifier(node.name) &&
|
|
105
|
+
node.initializer &&
|
|
106
|
+
StoreSource.#unwrap(node.initializer).kind === ts.SyntaxKind.ThisKeyword
|
|
107
|
+
)
|
|
108
|
+
aliases.add(node.name.text);
|
|
109
|
+
ts.forEachChild(node, visit);
|
|
110
|
+
};
|
|
111
|
+
visit(method);
|
|
112
|
+
return aliases;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static #isRootStoreType(type: ts.TypeNode | undefined) {
|
|
116
|
+
return (
|
|
117
|
+
!!type && ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && type.typeName.text === "RootStore"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static #unwrap(expression: ts.Expression) {
|
|
122
|
+
let current = expression;
|
|
123
|
+
while (
|
|
124
|
+
ts.isParenthesizedExpression(current) ||
|
|
125
|
+
ts.isAsExpression(current) ||
|
|
126
|
+
ts.isTypeAssertionExpression(current) ||
|
|
127
|
+
ts.isSatisfiesExpression(current) ||
|
|
128
|
+
ts.isNonNullExpression(current)
|
|
129
|
+
)
|
|
130
|
+
current = current.expression;
|
|
131
|
+
return current;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
static #refNameOf(statement: ts.ClassDeclaration) {
|
|
135
|
+
for (const clause of statement.heritageClauses ?? []) {
|
|
136
|
+
if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue;
|
|
137
|
+
for (const { expression } of clause.types) {
|
|
138
|
+
if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression)) continue;
|
|
139
|
+
if (expression.expression.text !== "store") continue;
|
|
140
|
+
const sig = expression.arguments[0];
|
|
141
|
+
return sig && ts.isPropertyAccessExpression(sig) ? sig.name.text : null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
static #fetchBindingsOf(sourceFile: ts.SourceFile) {
|
|
148
|
+
const names = new Set<string>();
|
|
149
|
+
for (const statement of sourceFile.statements) {
|
|
150
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
|
|
151
|
+
const bindings = statement.importClause?.namedBindings;
|
|
152
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
153
|
+
for (const element of bindings.elements)
|
|
154
|
+
if (!element.isTypeOnly && (element.propertyName ?? element.name).text === "fetch")
|
|
155
|
+
names.add(element.name.text);
|
|
156
|
+
}
|
|
157
|
+
return names;
|
|
158
|
+
}
|
|
159
|
+
}
|