@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.
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
package/workflow/types.ts CHANGED
@@ -49,6 +49,14 @@ export interface AddEnumFieldInput extends PrimitiveTargetInput {
49
49
 
50
50
  export interface AddMutationInput extends PrimitiveTargetInput {
51
51
  mutation: string | null;
52
+ guards?: string[] | null;
53
+ withCalls?: string[] | null;
54
+ afterCalls?: string[] | null;
55
+ }
56
+
57
+ export interface CreateInteractionInput {
58
+ app: string | null;
59
+ interaction: string | null;
52
60
  }
53
61
 
54
62
  export interface AddSliceInput extends PrimitiveTargetInput {
@@ -94,6 +102,7 @@ export interface WorkflowSpec {
94
102
  predictedChanges: readonly WorkflowPredictedChange[];
95
103
  validation: readonly WorkflowValidation[];
96
104
  completionCriteria: readonly string[];
105
+ replacesRules?: readonly string[];
97
106
  }
98
107
 
99
108
  export interface WorkflowDiagnostic {
@@ -382,6 +391,13 @@ export interface PrimitiveWriteReport {
382
391
  nextActions: PrimitiveNextAction[];
383
392
  }
384
393
 
394
+ export interface ScaffoldFile {
395
+ path: string;
396
+ content: string;
397
+ action: "create" | "modify";
398
+ reason: string;
399
+ }
400
+
385
401
  export type PrimitiveFileMap = Record<string, { filename: string; content: string }>;
386
402
 
387
403
  export interface WorkflowApplyReport {
@@ -482,4 +498,5 @@ export interface WorkflowPrimitiveOperations {
482
498
  addEnumField: (input: AddEnumFieldInput) => Promise<PrimitiveWriteReport>;
483
499
  addMutation: (input: AddMutationInput) => Promise<PrimitiveWriteReport>;
484
500
  addSlice: (input: AddSliceInput) => Promise<PrimitiveWriteReport>;
501
+ createInteraction: (input: CreateInteractionInput) => Promise<PrimitiveWriteReport>;
485
502
  }
@@ -7,6 +7,7 @@ export const appRootAllowedFiles = new Set([
7
7
  "AGENTS.md",
8
8
  "CLAUDE.md",
9
9
  "akan.app.json",
10
+ "akan.baseline.json",
10
11
  "akan.config.ts",
11
12
  "client.ts",
12
13
  "main.ts",
@@ -38,6 +39,7 @@ export const libRootAllowedFiles = new Set([
38
39
  "AGENTS.md",
39
40
  "CLAUDE.md",
40
41
  "README.md",
42
+ "akan.baseline.json",
41
43
  "akan.config.ts",
42
44
  "akan.lib.json",
43
45
  "client.ts",
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { WritePath } from "./writePath";
4
+
5
+ const pathIn = (body: string, enters?: (fn: ts.SignatureDeclaration) => boolean) => {
6
+ const sourceFile = ts.createSourceFile("a.ts", `async function run() {\n${body}\n}`, ts.ScriptTarget.Latest, true);
7
+ const fn = sourceFile.statements[0];
8
+ if (!fn || !ts.isFunctionDeclaration(fn) || !fn.body) throw new Error("fixture has no function body");
9
+ const isWrite = (call: ts.CallExpression) => /^write/.test(call.expression.getText(sourceFile));
10
+ const path = new WritePath(isWrite, { enters });
11
+ const calls = path.along(fn.body);
12
+ return { labels: calls.map((call) => path.labelOf(call, sourceFile)), count: path.countOf(calls) };
13
+ };
14
+ const writesIn = (body: string, enters?: (fn: ts.SignatureDeclaration) => boolean) =>
15
+ pathIn(body, enters).labels.map((label) => label.replace(" (per item)", ""));
16
+
17
+ describe("WritePath", () => {
18
+ test("adds up writes in sequence, nested callbacks and logical operands included", () => {
19
+ expect(
20
+ writesIn("await writeA();\nawait Promise.all(ids.map((id) => writeB(id)));\nok && (await writeC());"),
21
+ ).toEqual(["writeA", "writeB", "writeC"]);
22
+ });
23
+
24
+ test("counts only the busiest of exclusive branches", () => {
25
+ expect(writesIn("if (x) await writeA();\nelse await writeB();")).toEqual(["writeA"]);
26
+ expect(writesIn("await (x ? writeA() : Promise.all([writeB(), writeC()]));")).toEqual(["writeB", "writeC"]);
27
+ expect(
28
+ writesIn(
29
+ "switch (x) {\n case 1:\n await writeA();\n break;\n default:\n await writeB();\n await writeC();\n}",
30
+ ),
31
+ ).toEqual(["writeB", "writeC"]);
32
+ });
33
+
34
+ test("reads an if that returns as the else of the statements after it", () => {
35
+ expect(
36
+ writesIn("await writeA();\nif (x) {\n await writeB();\n return;\n}\nawait writeC();\nawait writeD();"),
37
+ ).toEqual(["writeA", "writeC", "writeD"]);
38
+ expect(writesIn("if (x) await writeB();\nawait writeC();")).toEqual(["writeB", "writeC"]);
39
+ });
40
+
41
+ test("reads every branch that returns as exclusive of the statements after it, however it is nested", () => {
42
+ const countIn = (body: string) => pathIn(body).count;
43
+ expect(
44
+ countIn(
45
+ "if (x) {\n await writeA();\n return;\n} else if (y) {\n await writeB();\n return;\n}\nawait writeC();",
46
+ ),
47
+ ).toBe(1);
48
+ expect(countIn('switch (x) {\n case "a":\n await writeA();\n return;\n}\nawait writeC();')).toBe(1);
49
+ expect(countIn("if (x) {\n if (y) {\n await writeA();\n return;\n }\n}\nawait writeC();")).toBe(1);
50
+ expect(writesIn("try {\n await writeA();\n return;\n} catch {\n await writeB();\n}")).toEqual([
51
+ "writeA",
52
+ "writeB",
53
+ ]);
54
+ });
55
+
56
+ test("runs a switch clause without break on into the next one", () => {
57
+ expect(
58
+ writesIn("switch (x) {\n case 1:\n await writeA();\n case 2:\n await writeB();\n break;\n}"),
59
+ ).toEqual(["writeA", "writeB"]);
60
+ });
61
+
62
+ test("counts a write in a loop or a per-element callback as two, but not the list it loops over", () => {
63
+ expect(pathIn("for (const id of ids) await writeA(id);")).toEqual({ labels: ["writeA (per item)"], count: 2 });
64
+ expect(pathIn("for (const id of await writeA()) read(id);")).toEqual({ labels: ["writeA"], count: 1 });
65
+ expect(pathIn("while (await more()) await writeA();")).toEqual({ labels: ["writeA (per item)"], count: 2 });
66
+ expect(pathIn("await Promise.all(ids.map((id) => writeA(id)));")).toEqual({
67
+ labels: ["writeA (per item)"],
68
+ count: 2,
69
+ });
70
+ expect(pathIn("await read().then(() => writeA());")).toEqual({ labels: ["writeA"], count: 1 });
71
+ expect(pathIn("for (let i = 0; i < ids.length; i++) await writeA(ids[i]);")).toEqual({
72
+ labels: ["writeA (per item)"],
73
+ count: 2,
74
+ });
75
+ expect(pathIn("while (queue.length) await writeA(queue.shift());")).toEqual({
76
+ labels: ["writeA (per item)"],
77
+ count: 2,
78
+ });
79
+ });
80
+
81
+ test("counts a retry or a poll as one write, and a loop that returns on its write as one", () => {
82
+ expect(pathIn("for (let i = 0; i < 3; i++) {\n try {\n await writeA();\n return;\n } catch {}\n}")).toEqual(
83
+ {
84
+ labels: ["writeA"],
85
+ count: 1,
86
+ },
87
+ );
88
+ expect(pathIn("let ok = false;\nwhile (!ok) ok = await writeA();")).toEqual({ labels: ["writeA"], count: 1 });
89
+ expect(pathIn("for (const id of ids)\n if (id) {\n await writeA(id);\n return;\n }")).toEqual({
90
+ labels: ["writeA"],
91
+ count: 1,
92
+ });
93
+ });
94
+
95
+ test("weighs a branch writing per item above one writing once", () => {
96
+ expect(pathIn("if (x) await writeA();\nelse for (const id of ids) await writeB(id);")).toEqual({
97
+ labels: ["writeB (per item)"],
98
+ count: 2,
99
+ });
100
+ });
101
+
102
+ test("leaves out a nested function the caller does not enter", () => {
103
+ const body = "await writeA();\nids.map((id) => writeB(id));\nsetTimeout(() => writeC(), 0);";
104
+ const entersMapOnly = (fn: ts.SignatureDeclaration) =>
105
+ ts.isCallExpression(fn.parent) && fn.parent.expression.getText().endsWith(".map");
106
+
107
+ expect(writesIn(body, entersMapOnly)).toEqual(["writeA", "writeB"]);
108
+ });
109
+ });
package/writePath.ts ADDED
@@ -0,0 +1,219 @@
1
+ import ts from "typescript";
2
+
3
+ interface Flow {
4
+ through: ts.CallExpression[] | null;
5
+ exit: ts.CallExpression[] | null;
6
+ jump: ts.CallExpression[] | null;
7
+ }
8
+
9
+ //? Exclusive branches never run together, so only the busiest one counts, a branch that returns included; a write in
10
+ //? a loop over items or a per-element callback runs once per item, so it counts as two.
11
+ export class WritePath {
12
+ static readonly #perElement = new Set([
13
+ "map",
14
+ "flatMap",
15
+ "forEach",
16
+ "filter",
17
+ "reduce",
18
+ "some",
19
+ "every",
20
+ "find",
21
+ "findIndex",
22
+ ]);
23
+ readonly #matches: (call: ts.CallExpression) => boolean;
24
+ readonly #enters: (fn: ts.SignatureDeclaration) => boolean;
25
+ readonly #repeated = new WeakSet<ts.CallExpression>();
26
+
27
+ constructor(
28
+ matches: (call: ts.CallExpression) => boolean,
29
+ { enters = () => true }: { enters?: (fn: ts.SignatureDeclaration) => boolean } = {},
30
+ ) {
31
+ this.#matches = matches;
32
+ this.#enters = enters;
33
+ }
34
+
35
+ along(node: ts.Node): ts.CallExpression[] {
36
+ if (ts.isFunctionLike(node) && !this.#enters(node)) return [];
37
+ if (WritePath.isPerElementCallback(node)) return this.#repeat(this.#children(node));
38
+ if (WritePath.#isFlowStatement(node)) return this.#busiestOf(this.#flowOf(node));
39
+ if (ts.isConditionalExpression(node))
40
+ return [...this.along(node.condition), ...this.#busier(this.along(node.whenTrue), this.along(node.whenFalse))];
41
+ const own = ts.isCallExpression(node) && this.#matches(node) ? [node] : [];
42
+ return [...own, ...this.#children(node)];
43
+ }
44
+
45
+ isRepeated(call: ts.CallExpression) {
46
+ return this.#repeated.has(call);
47
+ }
48
+
49
+ countOf(calls: ts.CallExpression[]) {
50
+ return calls.reduce((count, call) => count + (this.isRepeated(call) ? 2 : 1), 0);
51
+ }
52
+
53
+ labelOf(call: ts.CallExpression, sourceFile: ts.SourceFile) {
54
+ const callee = call.expression.getText(sourceFile);
55
+ return this.isRepeated(call) ? `${callee} (per item)` : callee;
56
+ }
57
+
58
+ static isPerElementCallback(node: ts.Node) {
59
+ const call = node.parent;
60
+ return (
61
+ (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
62
+ !!call &&
63
+ ts.isCallExpression(call) &&
64
+ call.arguments.some((arg) => arg === node) &&
65
+ ts.isPropertyAccessExpression(call.expression) &&
66
+ WritePath.#perElement.has(call.expression.name.text)
67
+ );
68
+ }
69
+
70
+ #children(node: ts.Node) {
71
+ const children: ts.Node[] = [];
72
+ ts.forEachChild(node, (child) => {
73
+ children.push(child);
74
+ });
75
+ return children.flatMap((child) => this.along(child));
76
+ }
77
+
78
+ #repeat(calls: ts.CallExpression[]) {
79
+ for (const call of calls) this.#repeated.add(call);
80
+ return calls;
81
+ }
82
+
83
+ #flowOf(node: ts.Statement): Flow {
84
+ if (ts.isBlock(node)) return this.#sequenceOf(node.statements);
85
+ if (ts.isReturnStatement(node) || ts.isThrowStatement(node))
86
+ return { through: null, exit: node.expression ? this.along(node.expression) : [], jump: null };
87
+ if (ts.isBreakStatement(node) || ts.isContinueStatement(node)) return { through: null, exit: null, jump: [] };
88
+ if (ts.isLabeledStatement(node)) return this.#flowOf(node.statement);
89
+ if (ts.isIfStatement(node))
90
+ return WritePath.#prefixed(
91
+ this.along(node.expression),
92
+ this.#merged(
93
+ this.#flowOf(node.thenStatement),
94
+ node.elseStatement ? this.#flowOf(node.elseStatement) : WritePath.#onward([]),
95
+ ),
96
+ );
97
+ if (ts.isSwitchStatement(node)) return WritePath.#prefixed(this.along(node.expression), this.#switchOf(node));
98
+ if (ts.isTryStatement(node)) return this.#tryOf(node);
99
+ if (ts.isForOfStatement(node) || ts.isForInStatement(node))
100
+ return this.#loopOf(this.along(node.expression), [], node.statement, true);
101
+ if (ts.isForStatement(node))
102
+ return this.#loopOf(
103
+ node.initializer ? this.along(node.initializer) : [],
104
+ [node.condition, node.incrementor].flatMap((part) => (part ? this.along(part) : [])),
105
+ node.statement,
106
+ WritePath.#walksItems(node.condition),
107
+ );
108
+ if (ts.isWhileStatement(node) || ts.isDoStatement(node))
109
+ return this.#loopOf([], this.along(node.expression), node.statement, WritePath.#walksItems(node.expression));
110
+ return WritePath.#onward(this.along(node));
111
+ }
112
+
113
+ #sequenceOf(statements: readonly ts.Statement[]): Flow {
114
+ let through: ts.CallExpression[] | null = [];
115
+ let exit: ts.CallExpression[] | null = null;
116
+ let jump: ts.CallExpression[] | null = null;
117
+ for (const statement of statements) {
118
+ if (through === null) break;
119
+ const flow = this.#flowOf(statement);
120
+ if (flow.exit) exit = this.#busierOf(exit, [...through, ...flow.exit]);
121
+ if (flow.jump) jump = this.#busierOf(jump, [...through, ...flow.jump]);
122
+ through = flow.through && [...through, ...flow.through];
123
+ }
124
+ return { through, exit, jump };
125
+ }
126
+
127
+ //? A clause without `break` runs on into the next one; `break` leaves the switch and goes on after it.
128
+ #switchOf(node: ts.SwitchStatement): Flow {
129
+ const hasDefault = node.caseBlock.clauses.some(ts.isDefaultClause);
130
+ let flow: Flow = { through: hasDefault ? null : [], exit: null, jump: null };
131
+ let fallen: ts.CallExpression[] | null = null;
132
+ for (const clause of node.caseBlock.clauses) {
133
+ const entry = fallen && this.countOf(fallen) > 0 ? fallen : [];
134
+ const own = WritePath.#prefixed(entry, this.#sequenceOf(clause.statements));
135
+ flow = this.#merged(flow, { through: own.jump, exit: own.exit, jump: null });
136
+ fallen = own.through;
137
+ }
138
+ return this.#merged(flow, { through: fallen, exit: null, jump: null });
139
+ }
140
+
141
+ //? A catch runs after whatever the try sent before it threw, so both count; a finally follows every path.
142
+ #tryOf(node: ts.TryStatement): Flow {
143
+ const tried = this.#flowOf(node.tryBlock);
144
+ const sent = this.#busiestOf(tried);
145
+ const caught = node.catchClause
146
+ ? WritePath.#prefixed(sent, this.#flowOf(node.catchClause.block))
147
+ : { through: null, exit: sent, jump: null };
148
+ const flow = this.#merged(tried, caught);
149
+ if (!node.finallyBlock) return flow;
150
+ const after = this.#busiestOf(this.#flowOf(node.finallyBlock));
151
+ return {
152
+ through: flow.through && [...flow.through, ...after],
153
+ exit: flow.exit && [...flow.exit, ...after],
154
+ jump: flow.jump && [...flow.jump, ...after],
155
+ };
156
+ }
157
+
158
+ //? An iteration that leaves the function sends its writes once; one that goes on repeats them for a loop over items.
159
+ #loopOf(once: ts.CallExpression[], everyTurn: ts.CallExpression[], body: ts.Statement, isPerItem: boolean): Flow {
160
+ const flow = this.#flowOf(body);
161
+ const turn = [...everyTurn, ...(this.#busierOf(flow.through, flow.jump) ?? [])];
162
+ const repeated = isPerItem ? this.#repeat(turn) : turn;
163
+ return { through: [...once, ...repeated], exit: flow.exit && [...once, ...flow.exit], jump: null };
164
+ }
165
+
166
+ #merged(a: Flow, b: Flow): Flow {
167
+ return {
168
+ through: this.#busierOf(a.through, b.through),
169
+ exit: this.#busierOf(a.exit, b.exit),
170
+ jump: this.#busierOf(a.jump, b.jump),
171
+ };
172
+ }
173
+
174
+ #busiestOf({ through, exit, jump }: Flow) {
175
+ return this.#busierOf(this.#busierOf(through, exit), jump) ?? [];
176
+ }
177
+
178
+ #busierOf(a: ts.CallExpression[] | null, b: ts.CallExpression[] | null) {
179
+ return a && b ? this.#busier(a, b) : (a ?? b);
180
+ }
181
+
182
+ static #onward(calls: ts.CallExpression[]): Flow {
183
+ return { through: calls, exit: null, jump: null };
184
+ }
185
+
186
+ static #prefixed(calls: ts.CallExpression[], { through, exit, jump }: Flow): Flow {
187
+ return {
188
+ through: through && [...calls, ...through],
189
+ exit: exit && [...calls, ...exit],
190
+ jump: jump && [...calls, ...jump],
191
+ };
192
+ }
193
+
194
+ static #isFlowStatement(node: ts.Node): node is ts.Statement {
195
+ return (
196
+ ts.isBlock(node) ||
197
+ ts.isIfStatement(node) ||
198
+ ts.isSwitchStatement(node) ||
199
+ ts.isTryStatement(node) ||
200
+ ts.isLabeledStatement(node) ||
201
+ ts.isReturnStatement(node) ||
202
+ ts.isThrowStatement(node) ||
203
+ ts.isBreakStatement(node) ||
204
+ ts.isContinueStatement(node) ||
205
+ ts.isIterationStatement(node, false)
206
+ );
207
+ }
208
+
209
+ //? A loop bound by `xs.length` or asking a call for more (`while (await next())`) walks items; a bound it counts
210
+ //? itself (`i < 3`, `!ok`) is a retry or a poll, which sends one write however many times it tries.
211
+ static #walksItems(node: ts.Node | undefined): boolean {
212
+ if (!node) return false;
213
+ if (ts.isCallExpression(node)) return true;
214
+ if (ts.isPropertyAccessExpression(node) && ["length", "size"].includes(node.name.text)) return true;
215
+ return !!ts.forEachChild(node, (child) => WritePath.#walksItems(child) || undefined);
216
+ }
217
+
218
+ #busier = (a: ts.CallExpression[], b: ts.CallExpression[]) => (this.countOf(b) > this.countOf(a) ? b : a);
219
+ }
@@ -0,0 +1,77 @@
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 { WritePlacementScanner } from "./writePlacementScanner";
6
+ import { WritingActionIndex } from "./writingActionIndex";
7
+
8
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
9
+ file,
10
+ absolutePath: `/repo/${file}`,
11
+ content,
12
+ sourceFile: ts.createSourceFile(
13
+ file,
14
+ content,
15
+ ts.ScriptTarget.Latest,
16
+ true,
17
+ file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
18
+ ),
19
+ });
20
+
21
+ const index = new WritingActionIndex(
22
+ new SignalMutationIndex([
23
+ fileOf(
24
+ "apps/demo/lib/court/court.signal.ts",
25
+ `export class CourtEndpoint extends endpoint(srv.court, ({ query, mutation }) => ({
26
+ skipNvrOnCourt: mutation(cnst.Court, { guards: [User] }).param("courtId", ID).exec(() => null),
27
+ courtCount: query(Int, { guards: [User] }).exec(() => 0),
28
+ })) {}
29
+ `,
30
+ ),
31
+ ]),
32
+ [
33
+ fileOf(
34
+ "apps/demo/lib/court/court.store.ts",
35
+ `import { fetch, sig } from "../useClient";
36
+ export class CourtStore extends store(sig.court, () => ({})) {
37
+ async skipNvrOnCourt(courtId: string) {
38
+ this.setCourt(await fetch.skipNvrOnCourt(courtId));
39
+ }
40
+ setPicked(courtId: string) {
41
+ this.set({ picked: courtId });
42
+ }
43
+ }
44
+ `,
45
+ ),
46
+ ],
47
+ );
48
+
49
+ const scan = (file: string, body: string) => {
50
+ const content = `"use client";\nimport { fetch as api, st } from "@apps/demo/client";\n\n${body}\n`;
51
+ return new WritePlacementScanner(index).scan(
52
+ file,
53
+ `apps/demo/${file}`,
54
+ fileOf(`apps/demo/${file}`, content).sourceFile,
55
+ );
56
+ };
57
+
58
+ describe("WritePlacementScanner", () => {
59
+ test("names every server write a ui/ or webkit/ file sends, by call or by reference, under any import name", () => {
60
+ expect(
61
+ scan(
62
+ "webkit/useSkip.tsx",
63
+ "export const useSkip = (courtId: string) => ({\n skip: () => st.do.skipNvrOnCourt(courtId),\n bound: st.do.skipNvrOnCourt,\n count: () => api.courtCount(),\n direct: () => api.skipNvrOnCourt(courtId),\n});",
64
+ ),
65
+ ).toEqual({ line: 5, writes: ["st.do.skipNvrOnCourt", "st.do.skipNvrOnCourt", "api.skipNvrOnCourt"] });
66
+ });
67
+
68
+ test("lets a module component call a writing action, and a ui/ file set client state, but not a fetch mutation", () => {
69
+ expect(
70
+ scan("lib/court/Court.Zone.tsx", "export const Skip = ({ id }: { id: string }) => st.do.skipNvrOnCourt(id);"),
71
+ ).toBeNull();
72
+ expect(scan("ui/Pick.tsx", "export const Pick = ({ id }: { id: string }) => st.do.setPicked(id);")).toBeNull();
73
+ expect(
74
+ scan("lib/court/Court.Zone.tsx", "export const Skip = ({ id }: { id: string }) => api.skipNvrOnCourt(id);"),
75
+ ).toEqual({ line: 4, writes: ["api.skipNvrOnCourt"] });
76
+ });
77
+ });
@@ -0,0 +1,50 @@
1
+ import ts from "typescript";
2
+ import { StoreAccessScanner } from "./storeAccessScanner";
3
+ import type { WritingActionIndex } from "./writingActionIndex";
4
+
5
+ export interface WritePlacement {
6
+ line: number;
7
+ writes: string[];
8
+ }
9
+
10
+ //* A write leaves the browser through one store action, started by one module component: an agent, a test or another
11
+ //* screen reaches it through the store, and `ui/` / `webkit/` stay free of any one model.
12
+ export class WritePlacementScanner {
13
+ readonly #index: WritingActionIndex;
14
+
15
+ constructor(index: WritingActionIndex) {
16
+ this.#index = index;
17
+ }
18
+
19
+ scan(file: string, workspaceFile: string, sourceFile: ts.SourceFile): WritePlacement | null {
20
+ const fetchNames = StoreAccessScanner.localNamesOf(sourceFile, "fetch");
21
+ const stNames = file.startsWith("lib/") ? new Set<string>() : StoreAccessScanner.stNamesOf(sourceFile);
22
+ if (!fetchNames.size && !stNames.size) return null;
23
+ const sites: ts.PropertyAccessExpression[] = [];
24
+ const visit = (node: ts.Node) => {
25
+ if (ts.isPropertyAccessExpression(node) && this.#isWrite(node, workspaceFile, fetchNames, stNames))
26
+ sites.push(node);
27
+ ts.forEachChild(node, visit);
28
+ };
29
+ visit(sourceFile);
30
+ const first = sites[0];
31
+ if (!first) return null;
32
+ return {
33
+ line: sourceFile.getLineAndCharacterOfPosition(first.getStart(sourceFile)).line + 1,
34
+ writes: sites.map((site) => site.getText(sourceFile)),
35
+ };
36
+ }
37
+
38
+ #isWrite(access: ts.PropertyAccessExpression, workspaceFile: string, fetchNames: Set<string>, stNames: Set<string>) {
39
+ const owner = access.expression;
40
+ if (ts.isIdentifier(owner) && fetchNames.has(owner.text))
41
+ return this.#index.isMutation(workspaceFile, access.name.text);
42
+ return (
43
+ ts.isPropertyAccessExpression(owner) &&
44
+ owner.name.text === "do" &&
45
+ ts.isIdentifier(owner.expression) &&
46
+ stNames.has(owner.expression.text) &&
47
+ this.#index.isWritingAction(access.name.text)
48
+ );
49
+ }
50
+ }
@@ -0,0 +1,99 @@
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 { WritingActionIndex } from "./writingActionIndex";
6
+
7
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
8
+ file,
9
+ absolutePath: `/repo/${file}`,
10
+ content,
11
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
12
+ });
13
+
14
+ const signals = [
15
+ fileOf(
16
+ "apps/demo/lib/court/court.signal.ts",
17
+ `export class CourtSlice extends slice(srv.court, { guards: { root: Admin, cru: User } }, () => ({})) {}
18
+ export class CourtEndpoint extends endpoint(srv.court, ({ query, mutation }) => ({
19
+ courtCount: query(Int, { guards: [User] }).exec(() => 0),
20
+ })) {}
21
+ `,
22
+ ),
23
+ fileOf(
24
+ "libs/shared/lib/push/push.signal.ts",
25
+ `export class PushEndpoint extends endpoint(srv.push, ({ mutation }) => ({
26
+ pushPcCommand: mutation(Boolean, { guards: [User] }).param("courtId", ID).exec(() => true),
27
+ })) {}
28
+ `,
29
+ ),
30
+ ];
31
+
32
+ const courtStore = fileOf(
33
+ "apps/demo/lib/court/court.store.ts",
34
+ `import { fetch, sig } from "../useClient";
35
+ export class CourtStore extends store(sig.court, () => ({})) {
36
+ async countCourts() {
37
+ this.set({ count: await fetch.courtCount() });
38
+ }
39
+ async openCourt(courtId: string) {
40
+ const root = this as unknown as RootStore;
41
+ await root.restartPc(courtId);
42
+ }
43
+ async refreshAfterOpen(courtId: string) {
44
+ await this.openCourt(courtId);
45
+ await this.countCourts();
46
+ }
47
+ }
48
+ `,
49
+ );
50
+ const pcStore = fileOf(
51
+ "apps/demo/lib/_pc/pc.store.ts",
52
+ `import { fetch, sig } from "../useClient";
53
+ export class PcStore extends store(sig.pc, () => ({})) {
54
+ async restartPc(courtId: string) {
55
+ await PcStore.sendRestart(courtId);
56
+ }
57
+ static async sendRestart(courtId: string) {
58
+ await fetch.pushPcCommand(courtId);
59
+ }
60
+ }
61
+ `,
62
+ );
63
+
64
+ const indexOf = (storeFiles: SourceFileInfo[]) => new WritingActionIndex(new SignalMutationIndex(signals), storeFiles);
65
+
66
+ describe("WritingActionIndex", () => {
67
+ test("holds the generated actions that send a mutation for a model with a slice, not its form setters", () => {
68
+ const index = indexOf([courtStore, pcStore]);
69
+ for (const name of [
70
+ "createCourt",
71
+ "updateCourt",
72
+ "removeCourt",
73
+ "mergeCourt",
74
+ "createCourtInForm",
75
+ "updateCourtInForm",
76
+ "submitCourt",
77
+ ])
78
+ expect(index.isWritingAction(name)).toBe(true);
79
+ for (const name of ["setNameOnCourt", "writeOnCourt", "uploadImageOnCourt", "newCourt", "editCourt", "viewCourt"])
80
+ expect(index.isWritingAction(name)).toBe(false);
81
+ expect(index.isWritingAction("createPc")).toBe(false);
82
+ });
83
+
84
+ test("follows writes through a static helper, the RootStore alias and a sibling action to a fixed point", () => {
85
+ const index = indexOf([courtStore, pcStore]);
86
+
87
+ expect(index.isWritingAction("restartPc")).toBe(true);
88
+ expect(index.isWritingAction("openCourt")).toBe(true);
89
+ expect(index.isWritingAction("refreshAfterOpen")).toBe(true);
90
+ expect(index.isWritingAction("countCourts")).toBe(false);
91
+ });
92
+
93
+ test("knows no action of a store it was not given", () => {
94
+ const index = indexOf([courtStore]);
95
+
96
+ expect(index.isWritingAction("restartPc")).toBe(false);
97
+ expect(index.isWritingAction("openCourt")).toBe(false);
98
+ });
99
+ });
@@ -0,0 +1,63 @@
1
+ import type ts from "typescript";
2
+ import type { SourceFileInfo } from "./qualityScanner";
3
+ import type { SignalMutationIndex } from "./signalMutationIndex";
4
+ import { type StoreCallTarget, type StoreMethod, StoreSource } from "./storeSource";
5
+
6
+ //? Stores merge into one root, so an action name means one action wherever it is called from.
7
+ export class WritingActionIndex {
8
+ readonly #mutations: SignalMutationIndex;
9
+ readonly #actions = new Set<string>();
10
+ readonly #statics = new Set<string>();
11
+
12
+ constructor(mutations: SignalMutationIndex, storeFiles: Pick<SourceFileInfo, "file" | "sourceFile">[]) {
13
+ this.#mutations = mutations;
14
+ const sources = storeFiles.map((storeFile) => new StoreSource(storeFile));
15
+ for (const source of sources)
16
+ for (const { refName } of source.stores) {
17
+ const generated = refName ? StoreSource.generatedWritesOf(refName) : [];
18
+ if (generated[0] && mutations.isMutation(source.file, generated[0]))
19
+ for (const name of generated) this.#actions.add(name);
20
+ }
21
+ const methods = sources.flatMap((source) =>
22
+ source.methods.map((method) => ({ file: source.file, method, targets: source.targetsIn(method) })),
23
+ );
24
+ let grew = true;
25
+ while (grew) {
26
+ grew = false;
27
+ for (const { file, method, targets } of methods) {
28
+ if (this.#holds(method) || !targets.some((target) => this.#writes(file, target))) continue;
29
+ if (method.isStatic) this.#statics.add(`${method.store}.${method.name}`);
30
+ else this.#actions.add(method.name);
31
+ grew = true;
32
+ }
33
+ }
34
+ }
35
+
36
+ isWritingAction(name: string) {
37
+ return this.#actions.has(name);
38
+ }
39
+
40
+ isMutation(consumerFile: string, name: string) {
41
+ return this.#mutations.isMutation(consumerFile, name);
42
+ }
43
+
44
+ writeOf(source: StoreSource, method: StoreMethod, call: ts.CallExpression): StoreCallTarget | null {
45
+ const target = source.targetOf(method, call);
46
+ if (!target || !this.#writes(source.file, target)) return null;
47
+ const isSelf =
48
+ target.kind !== "fetch" &&
49
+ target.name === method.name &&
50
+ (target.kind === "static" ? method.isStatic && target.store === method.store : !method.isStatic);
51
+ return isSelf ? null : target;
52
+ }
53
+
54
+ #holds(method: StoreMethod) {
55
+ return method.isStatic ? this.#statics.has(`${method.store}.${method.name}`) : this.#actions.has(method.name);
56
+ }
57
+
58
+ #writes(file: string, target: StoreCallTarget) {
59
+ if (target.kind === "fetch") return this.#mutations.isMutation(file, target.name);
60
+ if (target.kind === "action") return this.#actions.has(target.name);
61
+ return this.#statics.has(`${target.store}.${target.name}`);
62
+ }
63
+ }