@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +30 -0
  8. package/akanConfig/akanConfig.ts +10 -0
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/applicationBuildRunner.test.ts +17 -0
  13. package/applicationBuildRunner.ts +3 -1
  14. package/biome.base.json +4 -1
  15. package/cloud/cloudUpdates.ts +3 -2
  16. package/codeAgent/agent/CodeAgent.ts +4 -0
  17. package/codeAgent/agent/SubagentPool.ts +5 -0
  18. package/codeAgent/agent/akanSystemPrompt.ts +1 -1
  19. package/codeAgent/agent/codeAgent.test.ts +41 -2
  20. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  21. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  22. package/codeAgent/index.ts +2 -2
  23. package/codeAgent/skills/akan-agent/SKILL.md +36 -0
  24. package/codeAgent/skills/akan-code/SKILL.md +1 -0
  25. package/codeAgent/skills/akan-debug/SKILL.md +40 -6
  26. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  27. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  28. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  29. package/codeAgent/skills/akan-query/SKILL.md +38 -0
  30. package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
  31. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  32. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  33. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  34. package/codeAgent/tools/AkanCli.ts +2 -1
  35. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  36. package/codeAgent/tools/AkanEditScope.ts +8 -3
  37. package/codeAgent/tools/AkanToolPack.ts +4 -10
  38. package/codeAgent/tools/AkanVerifier.ts +13 -31
  39. package/codeAgent/tools/akanCodePlugins.test.ts +60 -58
  40. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  41. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  42. package/commentDensityScanner.test.ts +121 -0
  43. package/commentDensityScanner.ts +101 -0
  44. package/componentSizeScanner.test.ts +36 -0
  45. package/componentSizeScanner.ts +52 -0
  46. package/componentWriteChainScanner.test.ts +137 -0
  47. package/componentWriteChainScanner.ts +104 -0
  48. package/conventionSet.test.ts +73 -0
  49. package/conventionSet.ts +67 -0
  50. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  51. package/deprecatedCommentScanner.test.ts +54 -0
  52. package/deprecatedCommentScanner.ts +30 -0
  53. package/endpointReach.test.ts +58 -0
  54. package/endpointReach.ts +80 -0
  55. package/executors.test.ts +0 -4
  56. package/executors.ts +18 -2
  57. package/index.ts +1 -0
  58. package/integration/devStability.integration.test.ts +9 -5
  59. package/kindBranchScanner.test.ts +89 -0
  60. package/kindBranchScanner.ts +158 -0
  61. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  62. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  63. package/lint/no-oversized-list-limit.grit +73 -0
  64. package/linter.test.ts +12 -0
  65. package/linter.ts +35 -12
  66. package/maintainabilityBaseline.test.ts +280 -0
  67. package/maintainabilityBaseline.ts +220 -0
  68. package/maintainabilityContract.test.ts +846 -0
  69. package/maintainabilityContract.ts +404 -0
  70. package/maintainabilityExceptionScanner.test.ts +90 -0
  71. package/maintainabilityExceptionScanner.ts +85 -0
  72. package/maintainabilityLimits.ts +60 -0
  73. package/maintainabilityReport.test.ts +130 -0
  74. package/maintainabilityReport.ts +153 -0
  75. package/mobile/desktopRelease.test.ts +94 -23
  76. package/mobile/desktopRelease.ts +83 -45
  77. package/mobile/nativeApi.test.ts +4 -1
  78. package/mobile/nativeApi.ts +3 -3
  79. package/mobile/nativeApp.test.ts +2 -2
  80. package/mobile/nativeApp.ts +18 -11
  81. package/mobile/nativeConfig.test.ts +16 -8
  82. package/mobile/nativeConfig.ts +1 -1
  83. package/mobile/updateUploader.test.ts +34 -0
  84. package/mobile/updateUploader.ts +9 -6
  85. package/package.json +3 -2
  86. package/platformTest/LinuxDockerTarget.ts +3 -0
  87. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  88. package/preview/PreviewView.ts +219 -0
  89. package/preview/index.ts +2 -0
  90. package/preview/previewView.test.ts +76 -0
  91. package/prompter.ts +7 -0
  92. package/qualityScanner.test.ts +75 -0
  93. package/qualityScanner.ts +17 -1
  94. package/routeSourceValidator.test.ts +11 -2
  95. package/routeSourceValidator.ts +14 -2
  96. package/serviceLoopCallScanner.test.ts +56 -0
  97. package/serviceLoopCallScanner.ts +122 -0
  98. package/signalMutationIndex.test.ts +15 -0
  99. package/signalMutationIndex.ts +1 -1
  100. package/sourceComments.test.ts +45 -0
  101. package/sourceComments.ts +70 -0
  102. package/storeAccessScanner.test.ts +84 -0
  103. package/storeAccessScanner.ts +71 -0
  104. package/storeSource.ts +159 -0
  105. package/storeWriteScanner.test.ts +292 -0
  106. package/storeWriteScanner.ts +50 -0
  107. package/testOnlyExportScanner.test.ts +83 -0
  108. package/testOnlyExportScanner.ts +124 -0
  109. package/typeChecker.ts +32 -0
  110. package/typecheck/projectTypecheck.test.ts +18 -0
  111. package/typecheck/projectTypecheck.ts +29 -5
  112. package/typecheck/typecheck.proc.ts +6 -0
  113. package/typecheck/typecheckFailure.ts +13 -0
  114. package/types.ts +1 -0
  115. package/workflow/artifacts.ts +16 -2
  116. package/workflow/executor.ts +22 -7
  117. package/workflow/guardCatalog.test.ts +59 -0
  118. package/workflow/guardCatalog.ts +108 -0
  119. package/workflow/index.ts +4 -0
  120. package/workflow/interactionScaffold.ts +122 -0
  121. package/workflow/mutationScaffold.ts +519 -0
  122. package/workflow/mutationSource.test.ts +108 -0
  123. package/workflow/mutationSource.ts +219 -0
  124. package/workflow/plan.ts +54 -0
  125. package/workflow/render.ts +19 -4
  126. package/workflow/source.test.ts +185 -0
  127. package/workflow/source.ts +151 -24
  128. package/workflow/types.ts +18 -1
  129. package/workspaceLayout.ts +2 -0
  130. package/writePath.test.ts +109 -0
  131. package/writePath.ts +219 -0
  132. package/writePlacementScanner.test.ts +77 -0
  133. package/writePlacementScanner.ts +50 -0
  134. package/writingActionIndex.test.ts +99 -0
  135. package/writingActionIndex.ts +63 -0
  136. package/codeAgent/feedback/PreviewView.ts +0 -95
  137. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -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
+ });
@@ -0,0 +1,50 @@
1
+ import type ts from "typescript";
2
+ import type { SourceFileInfo } from "./qualityScanner";
3
+ import { type StoreCallTarget, StoreSource } from "./storeSource";
4
+ import { WritePath } from "./writePath";
5
+ import type { WritingActionIndex } from "./writingActionIndex";
6
+
7
+ export interface StoreMultiWrite {
8
+ action: string;
9
+ line: number;
10
+ writes: string[];
11
+ writeCount: number;
12
+ isRootChain: boolean;
13
+ }
14
+
15
+ export class StoreWriteScanner {
16
+ readonly #index: WritingActionIndex;
17
+
18
+ constructor(index: WritingActionIndex) {
19
+ this.#index = index;
20
+ }
21
+
22
+ scan({ file, sourceFile }: Pick<SourceFileInfo, "file" | "sourceFile">): StoreMultiWrite[] {
23
+ const source = new StoreSource({ file, sourceFile });
24
+ return source.methods.flatMap((method) => {
25
+ const targets = new Map<ts.CallExpression, StoreCallTarget>();
26
+ const path = new WritePath((call) => {
27
+ const target = this.#index.writeOf(source, method, call);
28
+ if (target) targets.set(call, target);
29
+ return !!target;
30
+ });
31
+ const writes = path.along(method.body);
32
+ const writeCount = path.countOf(writes);
33
+ const second = writes[1] ?? writes[0];
34
+ if (writeCount < 2 || !second) return [];
35
+ return [
36
+ {
37
+ action: method.name,
38
+ line: sourceFile.getLineAndCharacterOfPosition(second.getStart(sourceFile)).line + 1,
39
+ writes: writes.map((call) => path.labelOf(call, sourceFile)),
40
+ writeCount,
41
+ isRootChain: writes.some((call) => StoreWriteScanner.#crossesStores(targets.get(call), method.store)),
42
+ },
43
+ ];
44
+ });
45
+ }
46
+
47
+ static #crossesStores(target: StoreCallTarget | undefined, ownStore: string) {
48
+ return (target?.kind === "static" && target.store !== ownStore) || (target?.kind === "action" && !target.isOwn);
49
+ }
50
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import type { SourceFileInfo } from "./qualityScanner";
4
+ import { TestOnlyExportScanner } from "./testOnlyExportScanner";
5
+
6
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
7
+ file,
8
+ absolutePath: `/repo/${file}`,
9
+ content,
10
+ sourceFile: ts.createSourceFile(
11
+ file,
12
+ content,
13
+ ts.ScriptTarget.Latest,
14
+ true,
15
+ file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
16
+ ),
17
+ });
18
+
19
+ const scan = (files: Record<string, string>) =>
20
+ new TestOnlyExportScanner().scan(Object.entries(files).map(([file, content]) => fileOf(file, content)));
21
+
22
+ const layout = [
23
+ "export const groupLabelHeight = 30;",
24
+ "export const computeGroupBoxes = () => [groupLabelHeight];",
25
+ "export const groupEdgeStrips = (pad: number): number[] => (pad > 0 ? groupEdgeStrips(pad - 1) : []);",
26
+ "export const layoutBoard = () => 1;",
27
+ "",
28
+ ].join("\n");
29
+
30
+ const layoutTest = [
31
+ 'import { computeGroupBoxes, groupEdgeStrips, groupLabelHeight, layoutBoard } from "./boardLayout";',
32
+ "test(() => [computeGroupBoxes(), groupEdgeStrips(1), groupLabelHeight, layoutBoard()]);",
33
+ "",
34
+ ].join("\n");
35
+
36
+ describe("TestOnlyExportScanner", () => {
37
+ test("flags an export only tests reference, even when it calls itself", () => {
38
+ const warnings = scan({
39
+ "apps/demo/webkit/boardLayout.ts": layout,
40
+ "apps/demo/webkit/boardLayout.test.ts": layoutTest,
41
+ "apps/demo/ui/Board.tsx":
42
+ 'import { layoutBoard } from "@apps/demo/webkit";\nexport const Board = () => layoutBoard();\n',
43
+ });
44
+
45
+ expect(warnings.map((warning) => warning.message)).toEqual([
46
+ 'Exported "computeGroupBoxes" is referenced only by tests: apps/demo/webkit/boardLayout.test.ts.',
47
+ 'Exported "groupEdgeStrips" is referenced only by tests: apps/demo/webkit/boardLayout.test.ts.',
48
+ ]);
49
+ expect(warnings[0]).toMatchObject({
50
+ rule: "akan.global.test-only-export",
51
+ scope: "global",
52
+ file: "apps/demo/webkit/boardLayout.ts",
53
+ line: 2,
54
+ locations: [{ file: "apps/demo/webkit/boardLayout.test.ts", line: 1 }],
55
+ });
56
+ });
57
+
58
+ test("passes an export another source file uses, or that a function in its own file uses", () => {
59
+ const warnings = scan({
60
+ "apps/demo/webkit/boardLayout.ts": layout,
61
+ "apps/demo/webkit/boardLayout.test.ts": layoutTest,
62
+ "apps/demo/ui/Board.tsx": [
63
+ 'import * as webkit from "@apps/demo/webkit";',
64
+ "export const Board = () => [webkit.computeGroupBoxes(), webkit.groupEdgeStrips(2), webkit.layoutBoard()];",
65
+ "",
66
+ ].join("\n"),
67
+ });
68
+
69
+ expect(warnings).toEqual([]);
70
+ });
71
+
72
+ test("passes an export no test references, and leaves lib facets and lib modules alone", () => {
73
+ expect(
74
+ scan({
75
+ "apps/demo/common/unused.ts": "export const unused = 1;\n",
76
+ "libs/demo/webkit/signedIn.ts": "export const signedIn = () => true;\n",
77
+ "libs/demo/webkit/signedIn.test.ts": 'import { signedIn } from "./signedIn";\ntest(() => signedIn());\n',
78
+ "apps/demo/lib/post/post.constant.ts": "export class PostInput {}\n",
79
+ "apps/demo/lib/post/post.signal.test.ts": "const input = new PostInput();\n",
80
+ }),
81
+ ).toEqual([]);
82
+ });
83
+ });
@@ -0,0 +1,124 @@
1
+ import ts from "typescript";
2
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
+
4
+ interface ExportedDeclaration {
5
+ name: string;
6
+ line: number;
7
+ statement: ts.Statement;
8
+ }
9
+
10
+ interface TestReference {
11
+ file: string;
12
+ line: number;
13
+ }
14
+
15
+ export class TestOnlyExportScanner {
16
+ static readonly rule = "akan.global.test-only-export";
17
+ static #facets = new Set(["common", "webkit", "srvkit", "ui"]);
18
+
19
+ scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
20
+ const liveFileNumByName = new Map<string, number>();
21
+ const testRefsByName = new Map<string, TestReference[]>();
22
+ for (const { file, sourceFile } of sourceFiles) {
23
+ const isTest = TestOnlyExportScanner.#isTest(file);
24
+ for (const [name, line] of TestOnlyExportScanner.#identifierLinesOf(sourceFile)) {
25
+ if (!isTest) liveFileNumByName.set(name, (liveFileNumByName.get(name) ?? 0) + 1);
26
+ else if (testRefsByName.has(name)) testRefsByName.get(name)?.push({ file, line });
27
+ else testRefsByName.set(name, [{ file, line }]);
28
+ }
29
+ }
30
+ return sourceFiles
31
+ .filter(({ file }) => TestOnlyExportScanner.#isFacetSource(file))
32
+ .flatMap(({ file, sourceFile }) => {
33
+ const declarations = TestOnlyExportScanner.#exportedDeclarationsOf(sourceFile);
34
+ return declarations
35
+ .filter(({ name }) => (liveFileNumByName.get(name) ?? 0) <= 1)
36
+ .filter(({ name }) => !TestOnlyExportScanner.#isUsedInFile(sourceFile, name, declarations))
37
+ .flatMap(({ name, line }) => {
38
+ const testRefs = testRefsByName.get(name);
39
+ if (!testRefs?.length) return [];
40
+ return [
41
+ {
42
+ rule: TestOnlyExportScanner.rule,
43
+ scope: "global" as const,
44
+ severity: "warning" as const,
45
+ file,
46
+ line,
47
+ message: `Exported "${name}" is referenced only by tests: ${testRefs.map((ref) => ref.file).join(", ")}.`,
48
+ locations: testRefs,
49
+ },
50
+ ];
51
+ });
52
+ });
53
+ }
54
+
55
+ static #isTest(file: string) {
56
+ return /\.(test|spec)\.tsx?$/.test(file);
57
+ }
58
+
59
+ //? A lib's facets are public API for every workspace that installs it, so an export no app here calls is not dead.
60
+ static #isFacetSource(file: string) {
61
+ const segments = file.split("/");
62
+ if (segments[0] !== "apps") return false;
63
+ return TestOnlyExportScanner.#facets.has(segments[2] ?? "") && !TestOnlyExportScanner.#isTest(file);
64
+ }
65
+
66
+ static #identifierLinesOf(sourceFile: ts.SourceFile) {
67
+ const lines = new Map<string, number>();
68
+ const visit = (node: ts.Node) => {
69
+ if (ts.isIdentifier(node) && !lines.has(node.text))
70
+ lines.set(node.text, sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1);
71
+ ts.forEachChild(node, visit);
72
+ };
73
+ visit(sourceFile);
74
+ return lines;
75
+ }
76
+
77
+ static #exportedDeclarationsOf(sourceFile: ts.SourceFile): ExportedDeclaration[] {
78
+ return sourceFile.statements.flatMap((statement) => {
79
+ if (!TestOnlyExportScanner.#isNamedExport(statement)) return [];
80
+ return TestOnlyExportScanner.#declaredNamesOf(statement).map((identifier) => ({
81
+ name: identifier.text,
82
+ line: sourceFile.getLineAndCharacterOfPosition(identifier.getStart(sourceFile)).line + 1,
83
+ statement,
84
+ }));
85
+ });
86
+ }
87
+
88
+ static #isNamedExport(statement: ts.Statement) {
89
+ if (!ts.canHaveModifiers(statement)) return false;
90
+ const modifiers = ts.getModifiers(statement) ?? [];
91
+ return (
92
+ modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) &&
93
+ !modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
94
+ );
95
+ }
96
+
97
+ static #declaredNamesOf(statement: ts.Statement): ts.Identifier[] {
98
+ if (ts.isVariableStatement(statement))
99
+ return statement.declarationList.declarations
100
+ .map((declaration) => declaration.name)
101
+ .filter((name): name is ts.Identifier => ts.isIdentifier(name));
102
+ if (
103
+ ts.isFunctionDeclaration(statement) ||
104
+ ts.isClassDeclaration(statement) ||
105
+ ts.isInterfaceDeclaration(statement) ||
106
+ ts.isTypeAliasDeclaration(statement) ||
107
+ ts.isEnumDeclaration(statement)
108
+ )
109
+ return statement.name ? [statement.name] : [];
110
+ return [];
111
+ }
112
+
113
+ static #isUsedInFile(sourceFile: ts.SourceFile, name: string, declarations: ExportedDeclaration[]) {
114
+ const ownStatements = new Set(declarations.filter((entry) => entry.name === name).map((entry) => entry.statement));
115
+ let used = false;
116
+ const visit = (node: ts.Node) => {
117
+ if (used || ownStatements.has(node as ts.Statement)) return;
118
+ if (ts.isIdentifier(node) && node.text === name) used = true;
119
+ else ts.forEachChild(node, visit);
120
+ };
121
+ visit(sourceFile);
122
+ return used;
123
+ }
124
+ }
package/typeChecker.ts CHANGED
@@ -13,6 +13,16 @@ export interface ProjectTypecheckResult {
13
13
  message: string;
14
14
  }
15
15
 
16
+ export interface TypecheckRecord {
17
+ /** Relative to the workspace root, `/`-separated; `null` for a diagnostic tied to no file. */
18
+ file: string | null;
19
+ line: number;
20
+ col: number;
21
+ code: string;
22
+ severity: "error" | "warning";
23
+ message: string;
24
+ }
25
+
16
26
  export class TypeChecker {
17
27
  readonly configPath: string;
18
28
  readonly configFile: { config?: any; error?: ts.Diagnostic };
@@ -198,6 +208,28 @@ export class TypeChecker {
198
208
  return parsedConfig;
199
209
  }
200
210
 
211
+ static toRecords(diagnostics: ts.Diagnostic[], root: string): TypecheckRecord[] {
212
+ return diagnostics
213
+ .filter(
214
+ (diagnostic) =>
215
+ diagnostic.category === ts.DiagnosticCategory.Error || diagnostic.category === ts.DiagnosticCategory.Warning,
216
+ )
217
+ .map((diagnostic) => {
218
+ const position =
219
+ diagnostic.file && diagnostic.start !== undefined
220
+ ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
221
+ : null;
222
+ return {
223
+ file: diagnostic.file ? path.relative(root, diagnostic.file.fileName).split(path.sep).join("/") : null,
224
+ line: position ? position.line + 1 : 0,
225
+ col: position ? position.character + 1 : 0,
226
+ code: `TS${diagnostic.code}`,
227
+ severity: diagnostic.category === ts.DiagnosticCategory.Error ? "error" : "warning",
228
+ message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
229
+ };
230
+ });
231
+ }
232
+
201
233
  static formatDiagnosticMessages(diagnostics: ts.Diagnostic[]): string {
202
234
  return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
203
235
  getCanonicalFileName: (fileName) => fileName,
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import { tempDirs, writeText as write } from "../testHelpers";
4
4
  import { ProjectTypecheck } from "./projectTypecheck";
5
+ import { TypecheckFailure } from "./typecheckFailure";
5
6
 
6
7
  const makeTempRoot = tempDirs("akan-devkit-project-typecheck-");
7
8
 
@@ -29,4 +30,21 @@ describe("ProjectTypecheck", () => {
29
30
  expect((error as Error).message).toContain("lib/user/user.signal.test.ts");
30
31
  expect((error as Error).message).not.toContain("old.test.ts");
31
32
  }, 60_000);
33
+
34
+ test("hands back one record per diagnostic when asked for records", async () => {
35
+ const { root, dir } = await project();
36
+ const error = await new ProjectTypecheck(dir, root, { ...process.env })
37
+ .run(["../../server.ts"], { tests: true, report: "records" })
38
+ .catch((e: unknown) => e);
39
+ expect(error).toBeInstanceOf(TypecheckFailure);
40
+ const [record, ...rest] = (error as TypecheckFailure).records;
41
+ expect(rest).toEqual([]);
42
+ expect(record).toMatchObject({
43
+ file: "libs/demo/lib/user/user.signal.test.ts",
44
+ line: 1,
45
+ col: 14,
46
+ code: "TS2322",
47
+ severity: "error",
48
+ });
49
+ }, 60_000);
32
50
  });