@akanjs/devkit 3.1.1-rc.15 → 3.1.1-rc.17
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/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanContext.ts +44 -2
- package/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +3 -1
- package/biome.base.json +4 -1
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/skills/akan-agent/SKILL.md +36 -0
- package/codeAgent/skills/akan-debug/SKILL.md +3 -3
- 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/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/akanCodePlugins.test.ts +12 -30
- 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/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/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 +153 -0
- package/package.json +2 -2
- package/prompter.ts +7 -0
- 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/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/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
|
@@ -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
|
+
}
|