@akanjs/devkit 3.1.1-rc.14 → 3.1.1-rc.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/applicationBuildRunner.test.ts +17 -0
  2. package/applicationBuildRunner.ts +2 -1
  3. package/biome.base.json +4 -1
  4. package/commentDensityScanner.test.ts +121 -0
  5. package/commentDensityScanner.ts +101 -0
  6. package/componentSizeScanner.test.ts +36 -0
  7. package/componentSizeScanner.ts +52 -0
  8. package/componentWriteChainScanner.test.ts +137 -0
  9. package/componentWriteChainScanner.ts +104 -0
  10. package/deprecatedCommentScanner.test.ts +54 -0
  11. package/deprecatedCommentScanner.ts +30 -0
  12. package/endpointReach.test.ts +58 -0
  13. package/endpointReach.ts +80 -0
  14. package/index.ts +1 -0
  15. package/kindBranchScanner.test.ts +89 -0
  16. package/kindBranchScanner.ts +158 -0
  17. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  18. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  19. package/lint/no-oversized-list-limit.grit +73 -0
  20. package/maintainabilityBaseline.test.ts +280 -0
  21. package/maintainabilityBaseline.ts +220 -0
  22. package/maintainabilityContract.test.ts +846 -0
  23. package/maintainabilityContract.ts +404 -0
  24. package/maintainabilityExceptionScanner.test.ts +90 -0
  25. package/maintainabilityExceptionScanner.ts +85 -0
  26. package/maintainabilityLimits.ts +60 -0
  27. package/maintainabilityReport.test.ts +130 -0
  28. package/maintainabilityReport.ts +142 -0
  29. package/package.json +2 -2
  30. package/qualityScanner.test.ts +75 -0
  31. package/qualityScanner.ts +17 -1
  32. package/serviceLoopCallScanner.test.ts +56 -0
  33. package/serviceLoopCallScanner.ts +122 -0
  34. package/sourceComments.test.ts +45 -0
  35. package/sourceComments.ts +70 -0
  36. package/storeAccessScanner.test.ts +84 -0
  37. package/storeAccessScanner.ts +71 -0
  38. package/storeSource.ts +159 -0
  39. package/storeWriteScanner.test.ts +292 -0
  40. package/storeWriteScanner.ts +50 -0
  41. package/testOnlyExportScanner.test.ts +83 -0
  42. package/testOnlyExportScanner.ts +124 -0
  43. package/workflow/artifacts.ts +6 -1
  44. package/workflow/executor.ts +22 -7
  45. package/workflow/guardCatalog.test.ts +59 -0
  46. package/workflow/guardCatalog.ts +108 -0
  47. package/workflow/index.ts +4 -0
  48. package/workflow/interactionScaffold.ts +122 -0
  49. package/workflow/mutationScaffold.ts +519 -0
  50. package/workflow/mutationSource.test.ts +108 -0
  51. package/workflow/mutationSource.ts +219 -0
  52. package/workflow/plan.ts +54 -0
  53. package/workflow/render.ts +15 -1
  54. package/workflow/source.test.ts +130 -0
  55. package/workflow/source.ts +122 -14
  56. package/workflow/types.ts +17 -0
  57. package/workspaceLayout.ts +2 -0
  58. package/writePath.test.ts +109 -0
  59. package/writePath.ts +219 -0
  60. package/writePlacementScanner.test.ts +77 -0
  61. package/writePlacementScanner.ts +50 -0
  62. package/writingActionIndex.test.ts +99 -0
  63. package/writingActionIndex.ts +63 -0
@@ -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
+ }
@@ -0,0 +1,292 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import type { SourceFileInfo } from "./qualityScanner";
4
+ import { SignalMutationIndex } from "./signalMutationIndex";
5
+ import { StoreWriteScanner } from "./storeWriteScanner";
6
+ import { WritingActionIndex } from "./writingActionIndex";
7
+
8
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
9
+ file,
10
+ absolutePath: `/repo/${file}`,
11
+ content,
12
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
13
+ });
14
+
15
+ const signal = fileOf(
16
+ "apps/demo/lib/court/court.signal.ts",
17
+ `
18
+ export class CourtSlice extends slice(srv.court, { guards: { root: Admin, get: Public, cru: User } }, () => ({})) {}
19
+ export class CourtEndpoint extends endpoint(srv.court, ({ query, mutation }) => ({
20
+ assignCourtPc: mutation(cnst.Court, { guards: [User] }).param("courtId", ID).exec(async function (courtId) {
21
+ return await this.courtService.assignPc(courtId);
22
+ }),
23
+ pushPcCommand: mutation(Boolean, { guards: [User] }).param("courtId", ID).exec(async function (courtId) {
24
+ return await this.courtService.pushPcCommand(courtId);
25
+ }),
26
+ courtCount: query(Int, { guards: [User] }).exec(async function () {
27
+ return 0;
28
+ }),
29
+ })) {}
30
+ `,
31
+ );
32
+
33
+ const storeOf = (body: string) =>
34
+ fileOf(
35
+ "apps/demo/lib/court/court.store.ts",
36
+ `import { store } from "akanjs/store";\nimport * as cnst from "../cnst";\nimport { fetch, sig } from "../useClient";\n\nexport class CourtStore extends store(sig.court, () => ({ count: 0 })) {\n${body}\n}\n`,
37
+ );
38
+
39
+ const scan = (store: SourceFileInfo, otherStores: SourceFileInfo[] = []) =>
40
+ new StoreWriteScanner(new WritingActionIndex(new SignalMutationIndex([signal]), [store, ...otherStores])).scan(store);
41
+
42
+ const pushStore = fileOf(
43
+ "apps/demo/lib/push/push.store.ts",
44
+ `import { fetch, sig } from "../useClient";
45
+ export class PushStore extends store(sig.push, () => ({})) {
46
+ async pushCommandOnPc(courtId: string) {
47
+ await fetch.pushPcCommand(courtId);
48
+ }
49
+ async countPushes() {
50
+ return await fetch.courtCount();
51
+ }
52
+ }
53
+ `,
54
+ );
55
+
56
+ describe("StoreWriteScanner", () => {
57
+ test("reports an action sending two mutations at the second one", () => {
58
+ const writes = scan(
59
+ storeOf(` async assignCourtPcOnCourt(courtId: string) {
60
+ const court = await fetch.assignCourtPc(courtId);
61
+ await fetch.pushPcCommand(court.id);
62
+ this.setCourt(court);
63
+ }`),
64
+ );
65
+ expect(writes).toEqual([
66
+ {
67
+ action: "assignCourtPcOnCourt",
68
+ line: 8,
69
+ writes: ["fetch.assignCourtPc", "fetch.pushPcCommand"],
70
+ writeCount: 2,
71
+ isRootChain: false,
72
+ },
73
+ ]);
74
+ });
75
+
76
+ test("reads a mutation and a query, or a model read, as one write", () => {
77
+ const writes = scan(
78
+ storeOf(` async refreshCourt(courtId: string) {
79
+ const court = await fetch.assignCourtPc(courtId);
80
+ this.set({ count: await fetch.courtCount() });
81
+ this.setCourt(await fetch.court(court.id));
82
+ }`),
83
+ );
84
+ expect(writes).toEqual([]);
85
+ });
86
+
87
+ test("reads writes on exclusive branches as one", () => {
88
+ const writes = scan(
89
+ storeOf(` async pushOnCourt(courtId: string, many: boolean) {
90
+ if (many) await fetch.assignCourtPc(courtId);
91
+ else await fetch.pushPcCommand(courtId);
92
+ }`),
93
+ );
94
+ expect(writes).toEqual([]);
95
+ });
96
+
97
+ test("counts the generated CRUD and a write inside a nested callback", () => {
98
+ const writes = scan(
99
+ storeOf(` async mergeCourts(courtIds: string[]) {
100
+ await Promise.all(courtIds.map(async (courtId) => fetch.mergeCourt(courtId, {})));
101
+ setTimeout(() => void fetch["pushPcCommand"](courtIds[0]), 0);
102
+ }`),
103
+ );
104
+ expect(writes.map(({ action, writes }) => ({ action, writes }))).toEqual([
105
+ { action: "mergeCourts", writes: ["fetch.mergeCourt (per item)", 'fetch["pushPcCommand"]'] },
106
+ ]);
107
+ });
108
+
109
+ test("reads one write per item as two, in a loop or a per-element callback, but not the list it loops over", () => {
110
+ const writes = scan(
111
+ storeOf(` async removeCourts(courtIds: string[]) {
112
+ for (const courtId of courtIds) await fetch.mergeCourt(courtId, {});
113
+ }
114
+ async pushToEach(courtIds: string[]) {
115
+ await Promise.all(courtIds.map((courtId) => fetch.pushPcCommand(courtId)));
116
+ }
117
+ async assignOnce(courtId: string) {
118
+ for (const pc of [await fetch.assignCourtPc(courtId)]) this.setCourt(pc);
119
+ }`),
120
+ );
121
+ expect(writes.map(({ action, writes, writeCount }) => ({ action, writes, writeCount }))).toEqual([
122
+ { action: "removeCourts", writes: ["fetch.mergeCourt (per item)"], writeCount: 2 },
123
+ { action: "pushToEach", writes: ["fetch.pushPcCommand (per item)"], writeCount: 2 },
124
+ ]);
125
+ });
126
+
127
+ test("counts a call to another writing action of the same store", () => {
128
+ const writes = scan(
129
+ storeOf(` async pushCommandOnCourt(courtId: string) {
130
+ await fetch.pushPcCommand(courtId);
131
+ }
132
+ async readCourt(courtId: string) {
133
+ return fetch.court(courtId);
134
+ }
135
+ async assignCourtPcOnCourt(courtId: string) {
136
+ await this.readCourt(courtId);
137
+ await fetch.assignCourtPc(courtId);
138
+ await this.pushCommandOnCourt(courtId);
139
+ }`),
140
+ );
141
+ expect(writes).toEqual([
142
+ {
143
+ action: "assignCourtPcOnCourt",
144
+ line: 15,
145
+ writes: ["fetch.assignCourtPc", "this.pushCommandOnCourt"],
146
+ writeCount: 2,
147
+ isRootChain: false,
148
+ },
149
+ ]);
150
+ });
151
+
152
+ test("counts a generated CRUD action of the store's own model as its own write", () => {
153
+ const writes = scan(
154
+ storeOf(` async createAndPushCourt(courtId: string) {
155
+ await this.createCourt({ name: courtId });
156
+ await fetch.pushPcCommand(courtId);
157
+ }`),
158
+ );
159
+ expect(writes).toMatchObject([
160
+ { action: "createAndPushCourt", writes: ["this.createCourt", "fetch.pushPcCommand"] },
161
+ ]);
162
+ expect(writes[0]?.isRootChain).toBe(false);
163
+ });
164
+
165
+ test("reads another store's writing action through the RootStore cast, an alias of it or a RootStore parameter", () => {
166
+ const writes = scan(
167
+ storeOf(` async assignAndPush(courtId: string) {
168
+ await fetch.assignCourtPc(courtId);
169
+ await (this as unknown as RootStore).pushCommandOnPc(courtId);
170
+ }
171
+ async assignThroughAlias(courtId: string) {
172
+ const root = this as unknown as RootStore;
173
+ await root.countPushes();
174
+ await fetch.assignCourtPc(courtId);
175
+ await root.pushCommandOnPc(courtId);
176
+ }
177
+ static async assignFrom(root: RootStore, courtId: string) {
178
+ await root.assignCourtPcOnCourt(courtId);
179
+ await root.pushCommandOnPc(courtId);
180
+ }
181
+ async assignCourtPcOnCourt(courtId: string) {
182
+ await fetch.assignCourtPc(courtId);
183
+ }`),
184
+ [pushStore],
185
+ );
186
+ expect(writes.map(({ action, writes, isRootChain }) => ({ action, writes, isRootChain }))).toEqual([
187
+ {
188
+ action: "assignAndPush",
189
+ writes: ["fetch.assignCourtPc", "(this as unknown as RootStore).pushCommandOnPc"],
190
+ isRootChain: true,
191
+ },
192
+ { action: "assignThroughAlias", writes: ["fetch.assignCourtPc", "root.pushCommandOnPc"], isRootChain: true },
193
+ { action: "assignFrom", writes: ["root.assignCourtPcOnCourt", "root.pushCommandOnPc"], isRootChain: true },
194
+ ]);
195
+ });
196
+
197
+ test("counts a call to a writing static helper, and the writes a callback hands it", () => {
198
+ const writes = scan(
199
+ storeOf(` static async guarded(root: RootStore, write: () => Promise<void>) {
200
+ await write();
201
+ void root.countPushes();
202
+ }
203
+ static async assignOnce(root: RootStore, courtId: string) {
204
+ await fetch.assignCourtPc(courtId);
205
+ }
206
+ async assignAndPush(courtId: string) {
207
+ const root = this as unknown as RootStore;
208
+ await CourtStore.assignOnce(root, courtId);
209
+ await CourtStore.guarded(root, () => root.pushCommandOnPc(courtId));
210
+ }`),
211
+ [pushStore],
212
+ );
213
+ expect(writes).toMatchObject([
214
+ {
215
+ action: "assignAndPush",
216
+ writes: ["CourtStore.assignOnce", "root.pushCommandOnPc"],
217
+ isRootChain: true,
218
+ },
219
+ ]);
220
+ });
221
+
222
+ test("reads the store's own writing static as its own write, not a chain through another store", () => {
223
+ const writes = scan(
224
+ storeOf(` static async assignOnce(courtId: string) {
225
+ await fetch.assignCourtPc(courtId);
226
+ }
227
+ async assignAndPush(courtId: string) {
228
+ await CourtStore.assignOnce(courtId);
229
+ await fetch.pushPcCommand(courtId);
230
+ }`),
231
+ );
232
+ expect(writes).toMatchObject([
233
+ { action: "assignAndPush", writes: ["CourtStore.assignOnce", "fetch.pushPcCommand"], isRootChain: false },
234
+ ]);
235
+ });
236
+
237
+ test("reads a recursive call as no write, and a static helper's own writes as a chain", () => {
238
+ const writes = scan(
239
+ storeOf(` async undoOnCourt(courtId: string) {
240
+ await fetch.assignCourtPc(courtId);
241
+ await this.undoOnCourt(courtId);
242
+ }
243
+ static async sync(courtId: string) {
244
+ await fetch.assignCourtPc(courtId);
245
+ await fetch.pushPcCommand(courtId);
246
+ }`),
247
+ );
248
+ expect(writes.map(({ action, isRootChain }) => ({ action, isRootChain }))).toEqual([
249
+ { action: "sync", isRootChain: false },
250
+ ]);
251
+ });
252
+
253
+ test("ignores getters, class-property arrows and classes that are not stores", () => {
254
+ const writes = scan(
255
+ fileOf(
256
+ "apps/demo/lib/court/court.store.ts",
257
+ `import { fetch, sig } from "../useClient";
258
+ export class CourtStore extends store(sig.court, () => ({})) {
259
+ get both() {
260
+ void fetch.assignCourtPc("a");
261
+ void fetch.pushPcCommand("a");
262
+ return true;
263
+ }
264
+ assignBoth = async (courtId: string) => {
265
+ await fetch.assignCourtPc(courtId);
266
+ await fetch.pushPcCommand(courtId);
267
+ };
268
+ }
269
+ export class Helper extends Base {
270
+ async run(courtId: string) {
271
+ await fetch.assignCourtPc(courtId);
272
+ await fetch.pushPcCommand(courtId);
273
+ }
274
+ }`,
275
+ ),
276
+ );
277
+ expect(writes).toEqual([]);
278
+ });
279
+
280
+ test("reads nothing from a store that does not import fetch", () => {
281
+ const store = fileOf(
282
+ "apps/demo/lib/court/court.store.ts",
283
+ `export class CourtStore extends store(sig.court, () => ({})) {
284
+ async run(courtId: string) {
285
+ await fetch.assignCourtPc(courtId);
286
+ await fetch.pushPcCommand(courtId);
287
+ }
288
+ }`,
289
+ );
290
+ expect(scan(store)).toEqual([]);
291
+ });
292
+ });