@akanjs/devkit 3.1.1-rc.9 → 3.1.2-rc.0

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 (188) 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 +105 -0
  8. package/akanConfig/akanConfig.ts +21 -1
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/akanMcpContract.test.ts +28 -1
  13. package/akanMcpContract.ts +51 -3
  14. package/applicationBuildRunner.test.ts +53 -0
  15. package/applicationBuildRunner.ts +7 -4
  16. package/artifact/implicitRootLayout.ts +4 -2
  17. package/biome.base.json +4 -1
  18. package/cloud/globalConfig.test.ts +34 -0
  19. package/cloud/globalConfig.ts +2 -2
  20. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  21. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  22. package/codeAgent/agent/CodeAgent.ts +11 -0
  23. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  24. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  25. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  26. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  27. package/codeAgent/agent/SubagentPool.ts +5 -0
  28. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  29. package/codeAgent/agent/akanCodePaths.ts +8 -1
  30. package/codeAgent/agent/akanSystemPrompt.ts +5 -1
  31. package/codeAgent/agent/codeAgent.test.ts +41 -2
  32. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  33. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  34. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  35. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  36. package/codeAgent/agent/stringArg.ts +1 -1
  37. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  38. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  39. package/codeAgent/index.ts +2 -2
  40. package/codeAgent/skills/akan-agent/SKILL.md +43 -0
  41. package/codeAgent/skills/akan-code/SKILL.md +38 -11
  42. package/codeAgent/skills/akan-debug/SKILL.md +41 -6
  43. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  44. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  45. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  46. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  47. package/codeAgent/skills/akan-query/SKILL.md +39 -0
  48. package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
  49. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  50. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  51. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  52. package/codeAgent/tools/AkanCli.ts +2 -1
  53. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  54. package/codeAgent/tools/AkanEditScope.ts +8 -3
  55. package/codeAgent/tools/AkanToolPack.ts +4 -10
  56. package/codeAgent/tools/AkanVerifier.ts +13 -31
  57. package/codeAgent/tools/McpServerConfig.ts +6 -4
  58. package/codeAgent/tools/McpSignIn.ts +7 -7
  59. package/codeAgent/tools/McpTokenStore.ts +63 -16
  60. package/codeAgent/tools/McpToolPack.ts +8 -1
  61. package/codeAgent/tools/WebToolPack.ts +50 -6
  62. package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
  63. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  64. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  65. package/commandDecorators/command.ts +4 -1
  66. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  67. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  68. package/commentDensityScanner.test.ts +121 -0
  69. package/commentDensityScanner.ts +101 -0
  70. package/componentSizeScanner.test.ts +36 -0
  71. package/componentSizeScanner.ts +52 -0
  72. package/componentWriteChainScanner.test.ts +137 -0
  73. package/componentWriteChainScanner.ts +104 -0
  74. package/conventionSet.test.ts +73 -0
  75. package/conventionSet.ts +67 -0
  76. package/createTunnel.test.ts +200 -2
  77. package/createTunnel.ts +80 -6
  78. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  79. package/deprecatedCommentScanner.test.ts +54 -0
  80. package/deprecatedCommentScanner.ts +30 -0
  81. package/endpointReach.test.ts +58 -0
  82. package/endpointReach.ts +80 -0
  83. package/envSeed.test.ts +90 -0
  84. package/envSeed.ts +68 -0
  85. package/executors.test.ts +19 -5
  86. package/executors.ts +59 -4
  87. package/fileSys.test.ts +30 -2
  88. package/fileSys.ts +14 -0
  89. package/formSetterScanner.test.ts +44 -1
  90. package/formSetterScanner.ts +53 -8
  91. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  92. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  93. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  94. package/frontendBuild/optionalPeers.test.ts +132 -0
  95. package/index.ts +1 -0
  96. package/integration/devStability.integration.test.ts +9 -5
  97. package/integration/ssrMemoryProbe.ts +6 -0
  98. package/kindBranchScanner.test.ts +89 -0
  99. package/kindBranchScanner.ts +158 -0
  100. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  101. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  102. package/lint/no-oversized-list-limit.grit +73 -0
  103. package/lint/no-unguarded-endpoint.grit +5 -5
  104. package/linter.test.ts +12 -0
  105. package/linter.ts +35 -12
  106. package/maintainabilityBaseline.test.ts +280 -0
  107. package/maintainabilityBaseline.ts +220 -0
  108. package/maintainabilityContract.test.ts +846 -0
  109. package/maintainabilityContract.ts +404 -0
  110. package/maintainabilityExceptionScanner.test.ts +90 -0
  111. package/maintainabilityExceptionScanner.ts +85 -0
  112. package/maintainabilityLimits.ts +60 -0
  113. package/maintainabilityReport.test.ts +130 -0
  114. package/maintainabilityReport.ts +153 -0
  115. package/mobile/desktopRelease.test.ts +1 -1
  116. package/mobile/desktopRelease.ts +3 -1
  117. package/mobile/desktopServerStage.test.ts +1 -0
  118. package/mobile/desktopServerStage.ts +3 -1
  119. package/mobile/nativeApp.test.ts +20 -5
  120. package/mobile/nativeApp.ts +4 -1
  121. package/mobile/nativeConfig.test.ts +209 -5
  122. package/mobile/nativeConfig.ts +127 -6
  123. package/mobile/nativePublicEnv.test.ts +2 -0
  124. package/mobile/nativePublicEnv.ts +13 -0
  125. package/package.json +4 -2
  126. package/platformTest/LinuxDockerTarget.ts +3 -0
  127. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  128. package/preview/PreviewView.ts +219 -0
  129. package/preview/index.ts +2 -0
  130. package/preview/previewView.test.ts +76 -0
  131. package/prompter.ts +7 -0
  132. package/qualityScanner.test.ts +75 -0
  133. package/qualityScanner.ts +17 -1
  134. package/routeSourceValidator.test.ts +11 -2
  135. package/routeSourceValidator.ts +14 -2
  136. package/serviceLoopCallScanner.test.ts +56 -0
  137. package/serviceLoopCallScanner.ts +122 -0
  138. package/shellOnlyEnv.test.ts +65 -0
  139. package/shellOnlyEnv.ts +54 -0
  140. package/signalMutationIndex.test.ts +15 -0
  141. package/signalMutationIndex.ts +23 -82
  142. package/sourceComments.test.ts +45 -0
  143. package/sourceComments.ts +70 -0
  144. package/sshHostKeys.ts +59 -0
  145. package/storeAccessScanner.test.ts +84 -0
  146. package/storeAccessScanner.ts +71 -0
  147. package/storeSource.ts +160 -0
  148. package/storeWriteScanner.test.ts +292 -0
  149. package/storeWriteScanner.ts +50 -0
  150. package/subspace.test.ts +10 -0
  151. package/subspaceClone.ts +3 -1
  152. package/subspaceConfig.ts +6 -0
  153. package/testOnlyExportScanner.test.ts +83 -0
  154. package/testOnlyExportScanner.ts +124 -0
  155. package/transforms/barrelImportsPlugin.ts +10 -4
  156. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  157. package/transforms/mcpAppPageTrimmer.ts +5 -20
  158. package/transforms/pureLazyMarker.ts +1 -1
  159. package/typeChecker.ts +32 -0
  160. package/typecheck/projectTypecheck.test.ts +18 -0
  161. package/typecheck/projectTypecheck.ts +29 -5
  162. package/typecheck/typecheck.proc.ts +6 -0
  163. package/typecheck/typecheckFailure.ts +13 -0
  164. package/types.ts +1 -0
  165. package/workflow/artifacts.ts +16 -2
  166. package/workflow/executor.ts +22 -7
  167. package/workflow/guardCatalog.test.ts +59 -0
  168. package/workflow/guardCatalog.ts +108 -0
  169. package/workflow/index.ts +4 -0
  170. package/workflow/interactionScaffold.ts +122 -0
  171. package/workflow/mutationScaffold.ts +533 -0
  172. package/workflow/mutationSource.test.ts +108 -0
  173. package/workflow/mutationSource.ts +219 -0
  174. package/workflow/plan.ts +82 -0
  175. package/workflow/render.ts +19 -4
  176. package/workflow/source.test.ts +185 -0
  177. package/workflow/source.ts +151 -24
  178. package/workflow/sourceAst.ts +10 -48
  179. package/workflow/types.ts +18 -1
  180. package/workspaceLayout.ts +2 -0
  181. package/writePath.test.ts +109 -0
  182. package/writePath.ts +219 -0
  183. package/writePlacementScanner.test.ts +77 -0
  184. package/writePlacementScanner.ts +50 -0
  185. package/writingActionIndex.test.ts +99 -0
  186. package/writingActionIndex.ts +63 -0
  187. package/codeAgent/feedback/PreviewView.ts +0 -95
  188. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { CommentDensityScanner } from "./commentDensityScanner";
4
+ import type { SourceFileInfo } from "./qualityScanner";
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 codeOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => `const value${idx} = ${idx};`);
20
+ const commentsOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => `// note ${idx}`);
21
+ const scan = (files: Record<string, string[]>) =>
22
+ new CommentDensityScanner().scan(Object.entries(files).map(([file, lines]) => fileOf(file, `${lines.join("\n")}\n`)));
23
+
24
+ describe("CommentDensityScanner", () => {
25
+ test("flags a module over 1% with at least five comment lines, summed across its files", () => {
26
+ const warnings = scan({
27
+ "apps/demo/lib/post/post.service.ts": [...commentsOf(3), ...codeOf(100)],
28
+ "apps/demo/lib/post/post.document.ts": [...codeOf(50), ...commentsOf(2)],
29
+ });
30
+
31
+ expect(warnings).toHaveLength(1);
32
+ expect(warnings[0]).toMatchObject({
33
+ rule: "akan.comment.density",
34
+ scope: "comment",
35
+ file: "apps/demo/lib/post/post.service.ts",
36
+ line: 1,
37
+ });
38
+ expect(warnings[0]?.message).toContain("apps/demo/lib/post is 3.23% comments (5 of 155 non-blank lines)");
39
+ expect(warnings[0]?.locations).toEqual([
40
+ { file: "apps/demo/lib/post/post.service.ts", line: 1 },
41
+ { file: "apps/demo/lib/post/post.document.ts", line: 51 },
42
+ ]);
43
+ });
44
+
45
+ test("passes a module under 1%, and a small module with fewer than five comment lines", () => {
46
+ expect(
47
+ scan({
48
+ "apps/demo/lib/post/post.service.ts": [...commentsOf(5), ...codeOf(600)],
49
+ "apps/demo/lib/tag/tag.service.ts": [...commentsOf(4), ...codeOf(10)],
50
+ }),
51
+ ).toEqual([]);
52
+ });
53
+
54
+ test("does not count lint and TS directives or the store scaffold markers", () => {
55
+ expect(
56
+ scan({
57
+ "apps/demo/lib/post/post.store.ts": [
58
+ "export class PostStore extends store(sig.post, () => ({",
59
+ " // state",
60
+ "})) {",
61
+ " // action",
62
+ "}",
63
+ "// biome-ignore lint/style/noNonNullAssertion: vendor typing",
64
+ "/* biome-ignore lint/plugin: fixed brand color */",
65
+ "// @ts-expect-error vendor typing",
66
+ "// @ts-ignore vendor typing",
67
+ ...codeOf(5),
68
+ ],
69
+ }),
70
+ ).toEqual([]);
71
+ });
72
+
73
+ test("does not count comment syntax inside strings or JSX text", () => {
74
+ expect(
75
+ scan({
76
+ "apps/demo/ui/Links.tsx": [
77
+ 'export const home = "https://host//home";',
78
+ 'export const docs = "https://host//docs";',
79
+ `export const api = \`https://host//\${home}\`;`,
80
+ "export const Links = () => (",
81
+ " <p>",
82
+ " // not a comment",
83
+ " // still text",
84
+ " </p>",
85
+ ");",
86
+ ],
87
+ }),
88
+ ).toEqual([]);
89
+ });
90
+
91
+ test("measures neither srvkit nor guards.ts nor tests", () => {
92
+ expect(
93
+ scan({
94
+ "apps/demo/srvkit/vendorApi.ts": [...commentsOf(20), ...codeOf(10)],
95
+ "apps/demo/lib/post/guards.ts": [...commentsOf(20), ...codeOf(10)],
96
+ "apps/demo/lib/post/post.signal.test.ts": [...commentsOf(20), ...codeOf(10)],
97
+ "apps/demo/ui/Card.test.tsx": [...commentsOf(20), ...codeOf(10)],
98
+ }),
99
+ ).toEqual([]);
100
+ });
101
+
102
+ test("aggregates a ui folder as one entry and a ui file as its own", () => {
103
+ const warnings = scan({
104
+ "apps/demo/ui/Chart/Bar.tsx": [...commentsOf(3), ...codeOf(20)],
105
+ "apps/demo/ui/Chart/Pie.tsx": [...commentsOf(3), ...codeOf(20)],
106
+ "apps/demo/ui/Badge.tsx": [...commentsOf(4), ...codeOf(20)],
107
+ });
108
+
109
+ expect(warnings.map((warning) => warning.message.split(" is ")[0])).toEqual(["apps/demo/ui/Chart"]);
110
+ });
111
+
112
+ test("leaves a recipe's one-line JSDoc description uncounted", () => {
113
+ const recipeOf = (name: string) => [`/** ${name} look. */`, `export const ${name}Recipe = recipe(tv({}));`];
114
+ expect(
115
+ scan({
116
+ "apps/demo/ui/Recipe/panel.ts": [...recipeOf("panel"), ...recipeOf("tile"), ...recipeOf("card")],
117
+ "apps/demo/ui/Recipe/chip.ts": [...recipeOf("chip"), ...recipeOf("pill"), ...recipeOf("tag")],
118
+ }),
119
+ ).toEqual([]);
120
+ });
121
+ });
@@ -0,0 +1,101 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
4
+ import { type SourceComment, SourceComments } from "./sourceComments";
5
+
6
+ interface CommentedFile {
7
+ file: string;
8
+ line: number;
9
+ commentLineNum: number;
10
+ }
11
+
12
+ interface CommentTally {
13
+ lineNum: number;
14
+ commentLineNum: number;
15
+ files: CommentedFile[];
16
+ }
17
+
18
+ export class CommentDensityScanner {
19
+ static readonly rule = "akan.comment.density";
20
+ static readonly maxShare = 0.01;
21
+ static readonly minCommentLineNum = 5;
22
+ static #directive =
23
+ /^(?:biome-ignore|@ts-(?:expect-error|ignore|nocheck|check)\b|eslint-(?:disable|enable)|<reference\b)/;
24
+ static #scaffoldMarkers = new Set(["state", "action"]);
25
+
26
+ scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
27
+ const tallies = new Map<string, CommentTally>();
28
+ for (const sourceFile of sourceFiles) {
29
+ const unit = CommentDensityScanner.#unitOf(sourceFile.file);
30
+ if (!unit || !CommentDensityScanner.#isMeasured(sourceFile)) continue;
31
+ const tally = tallies.get(unit) ?? { lineNum: 0, commentLineNum: 0, files: [] };
32
+ const commentLines = CommentDensityScanner.#commentLinesOf(sourceFile);
33
+ tally.lineNum += sourceFile.content.split(/\r?\n/).filter((line) => line.trim() !== "").length;
34
+ tally.commentLineNum += commentLines.length;
35
+ if (commentLines[0] !== undefined)
36
+ tally.files.push({ file: sourceFile.file, line: commentLines[0], commentLineNum: commentLines.length });
37
+ tallies.set(unit, tally);
38
+ }
39
+ return [...tallies]
40
+ .filter(([, tally]) => CommentDensityScanner.#isDense(tally))
41
+ .map(([unit, tally]) => CommentDensityScanner.#warningOf(unit, tally));
42
+ }
43
+
44
+ static #commentLinesOf({ file, sourceFile }: SourceFileInfo) {
45
+ const isRecipe = file.includes("/ui/Recipe/");
46
+ const lines = new Set<number>();
47
+ for (const comment of SourceComments.of(sourceFile)) {
48
+ if (CommentDensityScanner.#isExempt(comment)) continue;
49
+ const contentIdxs = comment.body.flatMap((text, idx) => (text ? [idx] : []));
50
+ //? A recipe's first JSDoc line is its description in the generated AGENTS.md recipe index.
51
+ const countedIdxs = isRecipe && comment.isJsDoc ? contentIdxs.slice(1) : contentIdxs;
52
+ for (const idx of countedIdxs) lines.add(comment.line + idx);
53
+ }
54
+ return [...lines].sort((a, b) => a - b);
55
+ }
56
+
57
+ static #isExempt({ raw, body }: SourceComment) {
58
+ const head = body.find((text) => text !== "") ?? "";
59
+ if (CommentDensityScanner.#directive.test(head)) return true;
60
+ return raw.startsWith("//") && CommentDensityScanner.#scaffoldMarkers.has(head.toLowerCase());
61
+ }
62
+
63
+ static #isDense({ lineNum, commentLineNum }: CommentTally) {
64
+ return (
65
+ commentLineNum >= CommentDensityScanner.minCommentLineNum &&
66
+ commentLineNum > lineNum * CommentDensityScanner.maxShare
67
+ );
68
+ }
69
+
70
+ static #warningOf(unit: string, { lineNum, commentLineNum, files }: CommentTally): QualityWarning {
71
+ const [primary] = [...files].sort((a, b) => b.commentLineNum - a.commentLineNum);
72
+ const share = ((commentLineNum / lineNum) * 100).toFixed(2);
73
+ return {
74
+ rule: CommentDensityScanner.rule,
75
+ scope: "comment",
76
+ severity: "warning",
77
+ file: primary?.file ?? unit,
78
+ line: primary?.line,
79
+ message: `${unit} is ${share}% comments (${commentLineNum} of ${lineNum} non-blank lines); product lib/ and ui/ code stays under ${CommentDensityScanner.maxShare * 100}%.`,
80
+ ...(files.length > 1 ? { locations: files.map(({ file, line }) => ({ file, line })) } : {}),
81
+ };
82
+ }
83
+
84
+ static #unitOf(file: string) {
85
+ const segments = file.split("/");
86
+ if ((segments[0] !== "apps" && segments[0] !== "libs") || segments.length < 4) return null;
87
+ if (segments[2] === "ui") return segments.slice(0, 4).join("/");
88
+ if (segments[2] !== "lib") return null;
89
+ const depth = segments[3] === "__scalar" ? 5 : 4;
90
+ return segments.length > depth ? segments.slice(0, depth).join("/") : null;
91
+ }
92
+
93
+ static #isMeasured({ file, sourceFile }: SourceFileInfo) {
94
+ if (/\.(test|spec)\.tsx?$/.test(file) || path.basename(file) === "guards.ts") return false;
95
+ return !sourceFile.statements.every(
96
+ (statement) =>
97
+ ts.isExportDeclaration(statement) ||
98
+ (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)),
99
+ );
100
+ }
101
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { ComponentSizeScanner } from "./componentSizeScanner";
4
+
5
+ const measure = (content: string) =>
6
+ new ComponentSizeScanner().measure(
7
+ ts.createSourceFile("Board.tsx", content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
8
+ );
9
+
10
+ const bodyOf = (lineNum: number) => Array.from({ length: lineNum }, (_, idx) => ` const v${idx} = ${idx};`).join("\n");
11
+
12
+ describe("ComponentSizeScanner", () => {
13
+ test("measures a function declaration and a const arrow from their first to their last line", () => {
14
+ const sizes = measure(
15
+ `import { cn } from "akanjs/client";\n\nexport function Board() {\n${bodyOf(3)}\n return null;\n}\n\nexport const Cell = () => {\n${bodyOf(10)}\n return null;\n};\n`,
16
+ );
17
+ expect(sizes).toEqual([
18
+ { name: "Board", line: 3, lineCount: 6 },
19
+ { name: "Cell", line: 10, lineCount: 13 },
20
+ ]);
21
+ });
22
+
23
+ test("measures a wrapped component with its wrapper", () => {
24
+ const sizes = measure(
25
+ `export const Canvas = memo(\n forwardRef((props, ref) => {\n${bodyOf(4)}\n return null;\n }),\n);\n`,
26
+ );
27
+ expect(sizes).toEqual([{ name: "Canvas", line: 1, lineCount: 9 }]);
28
+ });
29
+
30
+ test("skips a helper, a value, a bodiless overload and a nested component", () => {
31
+ const sizes = measure(
32
+ `const toLabel = () => "x";\nexport const Columns = 3;\nexport const Theme = { dark: true };\nexport function Overloaded(): null;\nexport const Outer = () => {\n const Inner = () => null;\n return <Inner />;\n};\n`,
33
+ );
34
+ expect(sizes.map(({ name }) => name)).toEqual(["Outer"]);
35
+ });
36
+ });
@@ -0,0 +1,52 @@
1
+ import ts from "typescript";
2
+
3
+ export interface ComponentSize {
4
+ name: string;
5
+ line: number;
6
+ lineCount: number;
7
+ }
8
+
9
+ export interface ComponentNode {
10
+ name: string;
11
+ node: ts.FunctionDeclaration | ts.VariableDeclaration;
12
+ }
13
+
14
+ //? A hook is a component without markup: the same function-sized unit of screen code, so the same limits hold.
15
+ export class ComponentSizeScanner {
16
+ static readonly #componentName = /^(?:[A-Z]|use[A-Z])[A-Za-z0-9]*$/;
17
+
18
+ static componentsAndHooksOf(sourceFile: ts.SourceFile): ComponentNode[] {
19
+ return sourceFile.statements.flatMap((statement): ComponentNode[] => {
20
+ if (ts.isFunctionDeclaration(statement))
21
+ return statement.name && statement.body && ComponentSizeScanner.#componentName.test(statement.name.text)
22
+ ? [{ name: statement.name.text, node: statement }]
23
+ : [];
24
+ if (!ts.isVariableStatement(statement)) return [];
25
+ return statement.declarationList.declarations.flatMap((declaration) =>
26
+ ts.isIdentifier(declaration.name) &&
27
+ ComponentSizeScanner.#componentName.test(declaration.name.text) &&
28
+ declaration.initializer &&
29
+ ComponentSizeScanner.#holdsFunction(declaration.initializer)
30
+ ? [{ name: declaration.name.text, node: declaration }]
31
+ : [],
32
+ );
33
+ });
34
+ }
35
+
36
+ measure(sourceFile: ts.SourceFile): ComponentSize[] {
37
+ return ComponentSizeScanner.componentsAndHooksOf(sourceFile).map(({ name, node }) => {
38
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
39
+ const endLine = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
40
+ return { name, line, lineCount: endLine - line + 1 };
41
+ });
42
+ }
43
+
44
+ //? A wrapped component (`memo(() => …)`, `forwardRef(…)`) is still one component, measured with its wrapper.
45
+ static #holdsFunction(expression: ts.Expression): boolean {
46
+ let current = expression;
47
+ while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current))
48
+ current = current.expression;
49
+ if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) return true;
50
+ return ts.isCallExpression(current) && current.arguments.some((arg) => ComponentSizeScanner.#holdsFunction(arg));
51
+ }
52
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { ComponentWriteChainScanner } from "./componentWriteChainScanner";
4
+ import type { SourceFileInfo } from "./qualityScanner";
5
+ import { SignalMutationIndex } from "./signalMutationIndex";
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 CourtSlice extends slice(srv.court, { guards: { root: Admin, cru: User } }, () => ({})) {}
26
+ export class CourtEndpoint extends endpoint(srv.court, ({ query, mutation }) => ({
27
+ skipNvrOnCourt: mutation(cnst.Court, { guards: [User] }).param("courtId", ID).exec(() => null),
28
+ courtCount: query(Int, { guards: [User] }).exec(() => 0),
29
+ })) {}
30
+ `,
31
+ ),
32
+ ]),
33
+ [
34
+ fileOf(
35
+ "apps/demo/lib/court/court.store.ts",
36
+ `import { fetch, sig } from "../useClient";
37
+ export class CourtStore extends store(sig.court, () => ({})) {
38
+ async skipNvrOnCourt(courtId: string) {
39
+ this.setCourt(await fetch.skipNvrOnCourt(courtId));
40
+ }
41
+ async loadCourtCount() {
42
+ this.set({ count: await fetch.courtCount() });
43
+ }
44
+ }
45
+ `,
46
+ ),
47
+ ],
48
+ );
49
+
50
+ const chainsIn = (body: string) =>
51
+ new ComponentWriteChainScanner(index).scan(
52
+ fileOf(
53
+ "apps/demo/lib/court/Court.Zone.tsx",
54
+ `"use client";\nimport { st } from "@apps/demo/client";\n\nexport const Install = ({ courtId }: { courtId: string }) => {\n${body}\n};\n`,
55
+ ).sourceFile,
56
+ );
57
+
58
+ describe("ComponentWriteChainScanner", () => {
59
+ test("reports one handler sending two writing actions at the second", () => {
60
+ const chains = chainsIn(` const register = st
61
+ .tool("registerNvr")
62
+ .exec(async () => {
63
+ await st.do.updateCourt(courtId, {});
64
+ await st.do.loadCourtCount();
65
+ await st.do.skipNvrOnCourt(courtId);
66
+ });
67
+ return <button type="button" onClick={() => void register()} />;`);
68
+
69
+ expect(chains).toEqual([
70
+ { component: "Install", line: 10, writes: ["st.do.updateCourt", "st.do.skipNvrOnCourt"], writeCount: 2 },
71
+ ]);
72
+ });
73
+
74
+ test("passes two writes in two separate handlers, a render callback included", () => {
75
+ const chains = chainsIn(` const courtIds = [courtId];
76
+ return (
77
+ <div>
78
+ {courtIds.map((id) => (
79
+ <div key={id}>
80
+ <button type="button" onClick={() => void st.do.removeCourt(id)} />
81
+ <button type="button" onClick={() => void st.do.skipNvrOnCourt(id)} />
82
+ </div>
83
+ ))}
84
+ </div>
85
+ );`);
86
+
87
+ expect(chains).toEqual([]);
88
+ });
89
+
90
+ test("reads exclusive branches as one and an array or promise callback as part of its handler", () => {
91
+ const chains = chainsIn(` const onSave = async (isNew: boolean) => {
92
+ if (isNew) await st.do.createCourt({});
93
+ else await st.do.updateCourt(courtId, {});
94
+ };
95
+ const onRemoveAll = async (ids: string[]) => {
96
+ await Promise.all(ids.map((id) => st.do.removeCourt(id)));
97
+ await st.do.submitCourt().then(() => st.do.mergeCourt(courtId, {}));
98
+ };
99
+ return <button type="button" onClick={() => void onSave(true)} />;`);
100
+
101
+ expect(chains).toEqual([
102
+ {
103
+ component: "Install",
104
+ line: 11,
105
+ writes: ["st.do.removeCourt (per item)", "st.do.submitCourt", "st.do.mergeCourt"],
106
+ writeCount: 4,
107
+ },
108
+ ]);
109
+ });
110
+
111
+ test("reads one write per item as a chain, in a loop or a per-element callback, and a hook like a component", () => {
112
+ const loop = chainsIn(` const onRemoveAll = async (ids: string[]) => {
113
+ for (const id of ids) await st.do.removeCourt(id);
114
+ };
115
+ return <button type="button" onClick={() => void onRemoveAll([courtId])} />;`);
116
+ const hook = new ComponentWriteChainScanner(index).scan(
117
+ fileOf(
118
+ "apps/demo/webkit/useBulk.tsx",
119
+ 'import { st } from "@apps/demo/client";\n\nexport const useBulk = () => ({\n run: async (ids: string[]) => {\n await Promise.all(ids.map((id) => st.do.removeCourt(id)));\n },\n});\n',
120
+ ).sourceFile,
121
+ );
122
+
123
+ expect(loop).toEqual([{ component: "Install", line: 6, writes: ["st.do.removeCourt (per item)"], writeCount: 2 }]);
124
+ expect(hook).toEqual([{ component: "useBulk", line: 5, writes: ["st.do.removeCourt (per item)"], writeCount: 2 }]);
125
+ });
126
+
127
+ test("ignores a component file that does not import st", () => {
128
+ const chains = new ComponentWriteChainScanner(index).scan(
129
+ fileOf(
130
+ "apps/demo/ui/Plain.tsx",
131
+ "export const Plain = () => {\n const onClick = async () => {\n await st.do.createCourt({});\n await st.do.removeCourt('a');\n };\n return null;\n};\n",
132
+ ).sourceFile,
133
+ );
134
+
135
+ expect(chains).toEqual([]);
136
+ });
137
+ });
@@ -0,0 +1,104 @@
1
+ import ts from "typescript";
2
+ import { ComponentSizeScanner } from "./componentSizeScanner";
3
+ import { StoreAccessScanner } from "./storeAccessScanner";
4
+ import { WritePath } from "./writePath";
5
+ import type { WritingActionIndex } from "./writingActionIndex";
6
+
7
+ export interface ComponentWriteChain {
8
+ component: string;
9
+ line: number;
10
+ writes: string[];
11
+ writeCount: number;
12
+ }
13
+
14
+ //? A callback an array method or a promise runs belongs to the handler that started it; any other function is one.
15
+ export class ComponentWriteChainScanner {
16
+ static readonly #inlineCallers = new Set([
17
+ "map",
18
+ "flatMap",
19
+ "forEach",
20
+ "filter",
21
+ "reduce",
22
+ "some",
23
+ "every",
24
+ "find",
25
+ "findIndex",
26
+ "then",
27
+ "catch",
28
+ "finally",
29
+ ]);
30
+ readonly #index: WritingActionIndex;
31
+
32
+ constructor(index: WritingActionIndex) {
33
+ this.#index = index;
34
+ }
35
+
36
+ scan(sourceFile: ts.SourceFile): ComponentWriteChain[] {
37
+ const stNames = StoreAccessScanner.stNamesOf(sourceFile);
38
+ if (!stNames.size) return [];
39
+ const path = new WritePath(
40
+ (call) => {
41
+ const action = ComponentWriteChainScanner.#actionOf(call.expression, stNames);
42
+ return !!action && this.#index.isWritingAction(action);
43
+ },
44
+ { enters: ComponentWriteChainScanner.#isInlineCallback },
45
+ );
46
+ return ComponentSizeScanner.componentsAndHooksOf(sourceFile).flatMap(({ name, node }) =>
47
+ ComponentWriteChainScanner.#handlersIn(node).flatMap((handler) => {
48
+ const writes = path.along(handler);
49
+ const writeCount = path.countOf(writes);
50
+ const second = writes[1] ?? writes[0];
51
+ if (writeCount < 2 || !second) return [];
52
+ return [
53
+ {
54
+ component: name,
55
+ line: sourceFile.getLineAndCharacterOfPosition(second.getStart(sourceFile)).line + 1,
56
+ writes: writes.map((call) => path.labelOf(call, sourceFile)),
57
+ writeCount,
58
+ },
59
+ ];
60
+ }),
61
+ );
62
+ }
63
+
64
+ static #handlersIn(component: ts.Node) {
65
+ const bodies: ts.ConciseBody[] = [];
66
+ const visit = (node: ts.Node) => {
67
+ if (
68
+ ComponentWriteChainScanner.#isFunction(node) &&
69
+ node.body &&
70
+ !ComponentWriteChainScanner.#isInlineCallback(node)
71
+ )
72
+ bodies.push(node.body);
73
+ ts.forEachChild(node, visit);
74
+ };
75
+ visit(component);
76
+ return bodies;
77
+ }
78
+
79
+ static #isFunction(node: ts.Node): node is ts.FunctionLikeDeclaration {
80
+ return (
81
+ ts.isArrowFunction(node) ||
82
+ ts.isFunctionExpression(node) ||
83
+ ts.isFunctionDeclaration(node) ||
84
+ ts.isMethodDeclaration(node)
85
+ );
86
+ }
87
+
88
+ static #isInlineCallback(fn: ts.Node) {
89
+ const call = fn.parent;
90
+ return (
91
+ !!call &&
92
+ ts.isCallExpression(call) &&
93
+ call.arguments.some((arg) => arg === fn) &&
94
+ ts.isPropertyAccessExpression(call.expression) &&
95
+ ComponentWriteChainScanner.#inlineCallers.has(call.expression.name.text)
96
+ );
97
+ }
98
+
99
+ static #actionOf(callee: ts.Expression, stNames: Set<string>) {
100
+ if (!ts.isPropertyAccessExpression(callee) || !ts.isPropertyAccessExpression(callee.expression)) return null;
101
+ const { expression: st, name } = callee.expression;
102
+ return ts.isIdentifier(st) && stNames.has(st.text) && name.text === "do" ? callee.name.text : null;
103
+ }
104
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fsPromise from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ConventionSet } from "./conventionSet";
5
+ import { MaintainabilityContract } from "./maintainabilityContract";
6
+ import { Prompter } from "./prompter";
7
+
8
+ describe("ConventionSet", () => {
9
+ test("expands every brace list, so a comma-separated glob field can carry it", () => {
10
+ expect(ConventionSet.expand("{apps,libs}/**/*.{ts,tsx}")).toEqual([
11
+ "apps/**/*.ts",
12
+ "apps/**/*.tsx",
13
+ "libs/**/*.ts",
14
+ "libs/**/*.tsx",
15
+ ]);
16
+ expect(ConventionSet.expand("**/.env*")).toEqual(["**/.env*"]);
17
+ });
18
+
19
+ test("reads a rule's description and paths from its frontmatter, and refuses one without", () => {
20
+ const rule = ConventionSet.parseRule(
21
+ "page",
22
+ '---\ndescription: Route files.\npaths:\n - "{apps,libs}/**/page/**"\n---\n\n# Pages\n',
23
+ );
24
+ expect(rule).toEqual({
25
+ name: "page",
26
+ description: "Route files.",
27
+ paths: ["{apps,libs}/**/page/**"],
28
+ body: "# Pages",
29
+ });
30
+ expect(() => ConventionSet.parseRule("bare", "# Pages\n")).toThrow("no frontmatter");
31
+ expect(() => ConventionSet.parseRule("empty", "---\ndescription: x\n---\n")).toThrow("description and paths");
32
+ });
33
+
34
+ test("nests each rule under the core when composed, leaving fenced code alone", () => {
35
+ const set = new ConventionSet("## Core", [
36
+ { name: "a", description: "d", paths: ["x"], body: "# A\n\n## Part\n\n```sh\n# a comment\n```" },
37
+ ]);
38
+ expect(set.instruction).toBe("## Core\n\n## A\n\n### Part\n\n```sh\n# a comment\n```\n");
39
+ });
40
+
41
+ test("the shipped conventions guideline is the core followed by every rule", async () => {
42
+ const set = await Prompter.getConventionSet();
43
+ const instruction = await Prompter.getInstruction("conventions");
44
+ expect(set.rules.length).toBeGreaterThanOrEqual(10);
45
+ expect(instruction.startsWith(set.core)).toBe(true);
46
+ for (const rule of set.rules) {
47
+ expect(rule.body).toStartWith("# ");
48
+ expect(instruction).toContain(`\n#${rule.body.split("\n")[0]}\n`);
49
+ }
50
+ });
51
+
52
+ test("names only checks that exist, and names every lint plugin and maintainability gate somewhere", async () => {
53
+ const set = await Prompter.getConventionSet();
54
+ const named = new Set(
55
+ [set.core, ...set.rules.map((rule) => rule.body)].flatMap((text) =>
56
+ [...text.matchAll(/`([^`\s]+)`/g)].map((match) => match[1] ?? ""),
57
+ ),
58
+ );
59
+ const plugins = (await fsPromise.readdir(path.join(import.meta.dir, "lint")))
60
+ .filter((file) => file.endsWith(".grit"))
61
+ .map((file) => file.slice(0, -".grit".length));
62
+ const gates: readonly string[] = MaintainabilityContract.ruleIds;
63
+ const gateAreas = new Set(gates.map((id) => id.split(".").slice(0, 2).join(".")));
64
+ const isPluginName = (name: string) => /^non?-[a-z]+(-[a-z]+)+$/.test(name);
65
+ const isGateName = (name: string) =>
66
+ gateAreas.has(name.split(".").slice(0, 2).join(".")) && !name.endsWith(".json");
67
+
68
+ expect([...named].filter((name) => isPluginName(name) && !plugins.includes(name))).toEqual([]);
69
+ expect([...named].filter((name) => isGateName(name) && !gates.includes(name))).toEqual([]);
70
+ expect(plugins.filter((plugin) => !named.has(plugin))).toEqual([]);
71
+ expect(gates.filter((gate) => !named.has(gate))).toEqual([]);
72
+ });
73
+ });