@akanjs/devkit 3.1.1-rc.9 → 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 (128) 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/codeAgent/agent/CodeAgent.ts +4 -0
  16. package/codeAgent/agent/SubagentPool.ts +5 -0
  17. package/codeAgent/agent/akanSystemPrompt.ts +1 -1
  18. package/codeAgent/agent/codeAgent.test.ts +41 -2
  19. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  20. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  21. package/codeAgent/index.ts +2 -2
  22. package/codeAgent/skills/akan-agent/SKILL.md +36 -0
  23. package/codeAgent/skills/akan-code/SKILL.md +1 -0
  24. package/codeAgent/skills/akan-debug/SKILL.md +40 -6
  25. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  26. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  27. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  28. package/codeAgent/skills/akan-query/SKILL.md +38 -0
  29. package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
  30. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  31. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  32. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  33. package/codeAgent/tools/AkanCli.ts +2 -1
  34. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  35. package/codeAgent/tools/AkanEditScope.ts +8 -3
  36. package/codeAgent/tools/AkanToolPack.ts +4 -10
  37. package/codeAgent/tools/AkanVerifier.ts +13 -31
  38. package/codeAgent/tools/akanCodePlugins.test.ts +60 -58
  39. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  40. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  41. package/commentDensityScanner.test.ts +121 -0
  42. package/commentDensityScanner.ts +101 -0
  43. package/componentSizeScanner.test.ts +36 -0
  44. package/componentSizeScanner.ts +52 -0
  45. package/componentWriteChainScanner.test.ts +137 -0
  46. package/componentWriteChainScanner.ts +104 -0
  47. package/conventionSet.test.ts +73 -0
  48. package/conventionSet.ts +67 -0
  49. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  50. package/deprecatedCommentScanner.test.ts +54 -0
  51. package/deprecatedCommentScanner.ts +30 -0
  52. package/endpointReach.test.ts +58 -0
  53. package/endpointReach.ts +80 -0
  54. package/executors.test.ts +0 -4
  55. package/executors.ts +18 -2
  56. package/index.ts +1 -0
  57. package/integration/devStability.integration.test.ts +9 -5
  58. package/kindBranchScanner.test.ts +89 -0
  59. package/kindBranchScanner.ts +158 -0
  60. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  61. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  62. package/lint/no-oversized-list-limit.grit +73 -0
  63. package/linter.test.ts +12 -0
  64. package/linter.ts +35 -12
  65. package/maintainabilityBaseline.test.ts +280 -0
  66. package/maintainabilityBaseline.ts +220 -0
  67. package/maintainabilityContract.test.ts +846 -0
  68. package/maintainabilityContract.ts +404 -0
  69. package/maintainabilityExceptionScanner.test.ts +90 -0
  70. package/maintainabilityExceptionScanner.ts +85 -0
  71. package/maintainabilityLimits.ts +60 -0
  72. package/maintainabilityReport.test.ts +130 -0
  73. package/maintainabilityReport.ts +153 -0
  74. package/mobile/desktopRelease.test.ts +1 -1
  75. package/mobile/desktopRelease.ts +3 -1
  76. package/package.json +3 -2
  77. package/platformTest/LinuxDockerTarget.ts +3 -0
  78. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  79. package/preview/PreviewView.ts +219 -0
  80. package/preview/index.ts +2 -0
  81. package/preview/previewView.test.ts +76 -0
  82. package/prompter.ts +7 -0
  83. package/qualityScanner.test.ts +75 -0
  84. package/qualityScanner.ts +17 -1
  85. package/routeSourceValidator.test.ts +11 -2
  86. package/routeSourceValidator.ts +14 -2
  87. package/serviceLoopCallScanner.test.ts +56 -0
  88. package/serviceLoopCallScanner.ts +122 -0
  89. package/signalMutationIndex.test.ts +15 -0
  90. package/signalMutationIndex.ts +1 -1
  91. package/sourceComments.test.ts +45 -0
  92. package/sourceComments.ts +70 -0
  93. package/storeAccessScanner.test.ts +84 -0
  94. package/storeAccessScanner.ts +71 -0
  95. package/storeSource.ts +159 -0
  96. package/storeWriteScanner.test.ts +292 -0
  97. package/storeWriteScanner.ts +50 -0
  98. package/testOnlyExportScanner.test.ts +83 -0
  99. package/testOnlyExportScanner.ts +124 -0
  100. package/typeChecker.ts +32 -0
  101. package/typecheck/projectTypecheck.test.ts +18 -0
  102. package/typecheck/projectTypecheck.ts +29 -5
  103. package/typecheck/typecheck.proc.ts +6 -0
  104. package/typecheck/typecheckFailure.ts +13 -0
  105. package/types.ts +1 -0
  106. package/workflow/artifacts.ts +16 -2
  107. package/workflow/executor.ts +22 -7
  108. package/workflow/guardCatalog.test.ts +59 -0
  109. package/workflow/guardCatalog.ts +108 -0
  110. package/workflow/index.ts +4 -0
  111. package/workflow/interactionScaffold.ts +122 -0
  112. package/workflow/mutationScaffold.ts +519 -0
  113. package/workflow/mutationSource.test.ts +108 -0
  114. package/workflow/mutationSource.ts +219 -0
  115. package/workflow/plan.ts +54 -0
  116. package/workflow/render.ts +19 -4
  117. package/workflow/source.test.ts +185 -0
  118. package/workflow/source.ts +151 -24
  119. package/workflow/types.ts +18 -1
  120. package/workspaceLayout.ts +2 -0
  121. package/writePath.test.ts +109 -0
  122. package/writePath.ts +219 -0
  123. package/writePlacementScanner.test.ts +77 -0
  124. package/writePlacementScanner.ts +50 -0
  125. package/writingActionIndex.test.ts +99 -0
  126. package/writingActionIndex.ts +63 -0
  127. package/codeAgent/feedback/PreviewView.ts +0 -95
  128. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { PreviewView } from "./PreviewView";
3
+
4
+ const serve = (html: string, status = 200) =>
5
+ Bun.serve({ port: 0, fetch: () => new Response(html, { status, headers: { "content-type": "text/html" } }) });
6
+
7
+ describe("PreviewView", () => {
8
+ test("reports whether this Bun build can drive a headless page at all", () => {
9
+ expect(typeof PreviewView.available).toBe("boolean");
10
+ });
11
+
12
+ test("formats a console call the way the console prints it", () => {
13
+ expect(PreviewView.formatConsole(["%cstyled%c %s of %d", "color:red", "", "two", 3, "extra"])).toBe(
14
+ "styled two of 3 extra",
15
+ );
16
+ expect(PreviewView.formatConsole(["a\n\n\n \nb"])).toBe("a\nb");
17
+ expect(PreviewView.formatConsole(["x".repeat(2_000)])).toHaveLength(1_500);
18
+ });
19
+
20
+ test.skipIf(!PreviewView.available)(
21
+ "finds an empty body and a console error on a real page",
22
+ async () => {
23
+ const server = serve('<!doctype html><html><body></body><script>console.error("boom")</script></html>');
24
+ try {
25
+ const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
26
+ expect(probe.pageErrors.join(" ")).toContain("rendered empty");
27
+ expect(probe.consoleErrors.join(" ")).toContain("boom");
28
+ } finally {
29
+ server.stop(true);
30
+ }
31
+ },
32
+ 30_000,
33
+ );
34
+
35
+ test.skipIf(!PreviewView.available)(
36
+ "reads the status, the live title, a failed dev build and a page wider than the viewport",
37
+ async () => {
38
+ const server = serve(
39
+ [
40
+ "<!doctype html><html><head><title>Before</title></head><body>",
41
+ '<div style="width:900px">wide</div>',
42
+ '<div class="__akan_hmr_overlay" data-status="error">Build failed: page.tsx</div>',
43
+ '<script>document.title = "After";</script>',
44
+ "</body></html>",
45
+ ].join(""),
46
+ 404,
47
+ );
48
+ try {
49
+ const probe = await PreviewView.probe(`http://localhost:${server.port}/missing`, { width: 390, height: 844 });
50
+ expect(probe.status).toBe(404);
51
+ expect(probe.title).toBe("After");
52
+ expect(probe.text).toBe("wide Build failed: page.tsx");
53
+ expect(probe.pageErrors).toEqual(["The dev build failed: Build failed: page.tsx"]);
54
+ expect(probe.domFindings.join(" ")).toContain("first wider element: div");
55
+ } finally {
56
+ server.stop(true);
57
+ }
58
+ },
59
+ 30_000,
60
+ );
61
+
62
+ test.skipIf(!PreviewView.available)(
63
+ "gives up on a server that takes the connection and never answers",
64
+ async () => {
65
+ const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => undefined) });
66
+ try {
67
+ await expect(PreviewView.probe(`http://localhost:${server.port}/`, { timeoutMs: 1_500 })).rejects.toThrow(
68
+ "Timed out loading",
69
+ );
70
+ } finally {
71
+ server.stop(true);
72
+ }
73
+ },
74
+ 30_000,
75
+ );
76
+ });
package/prompter.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import fsPromise from "node:fs/promises";
2
2
  import { input, select } from "@inquirer/prompts";
3
3
 
4
+ import { ConventionSet } from "./conventionSet";
4
5
  import { getDirname } from "./fileSys";
5
6
 
6
7
  interface FileUpdateRequestProps {
@@ -40,8 +41,14 @@ export class Prompter {
40
41
  if (!guideNames.includes(guideName))
41
42
  throw new Error(`Unknown guideline "${guideName}". Available: ${guideNames.join(", ")}`);
42
43
  const guidelineRoot = await Prompter.#getGuidelineRoot();
44
+ const composed = `${guidelineRoot}/${guideName}/core.md`;
45
+ if (await Bun.file(composed).exists())
46
+ return (await ConventionSet.load(`${guidelineRoot}/${guideName}`)).instruction;
43
47
  return await fsPromise.readFile(`${guidelineRoot}/${guideName}/${guideName}.instruction.md`, "utf-8");
44
48
  }
49
+ static async getConventionSet() {
50
+ return await ConventionSet.load(`${await Prompter.#getGuidelineRoot()}/conventions`);
51
+ }
45
52
  static async getUpdateRequest(guideName: string) {
46
53
  return await input({
47
54
  message: `What do you want to update in ${guideName}?`,
@@ -355,3 +355,78 @@ describe("bang comments in browser-reachable files", () => {
355
355
  );
356
356
  });
357
357
  });
358
+
359
+ describe("AkanQualityScanner comment and dead-export rules", () => {
360
+ test("reports comment density, a loose deprecated marker and a test-only export, each with its fix", async () => {
361
+ const root = await makeWorkspace({
362
+ "apps/demo/lib/court/court.constant.ts": [
363
+ ...Array.from({ length: 5 }, (_, idx) => `// restates line ${idx}`),
364
+ "export class CourtInput {",
365
+ " legacyVenue?: string; // deprecated",
366
+ "}",
367
+ "",
368
+ ].join("\n"),
369
+ "apps/demo/webkit/boardLayout.ts": "export const computeGroupBoxes = () => [];\n",
370
+ "apps/demo/webkit/boardLayout.test.ts":
371
+ 'import { computeGroupBoxes } from "./boardLayout";\ncomputeGroupBoxes();\n',
372
+ });
373
+
374
+ const result = await new AkanQualityScanner().scan(root);
375
+ const density = rulesOf(result, "akan.comment.density");
376
+ const deprecated = rulesOf(result, "akan.comment.deprecated-marker");
377
+ const testOnly = rulesOf(result, "akan.global.test-only-export");
378
+
379
+ expect(density.map((warning) => warning.file)).toEqual(["apps/demo/lib/court/court.constant.ts"]);
380
+ expect(density[0]?.fix).toContain('AGENTS.md "Comments"');
381
+ expect(deprecated.map((warning) => warning.line)).toEqual([7]);
382
+ expect(deprecated[0]?.fix).toContain("/** @deprecated");
383
+ expect(testOnly.map((warning) => warning.file)).toEqual(["apps/demo/webkit/boardLayout.ts"]);
384
+ expect(testOnly[0]?.fix).toContain("unexported");
385
+ });
386
+ });
387
+
388
+ describe("AkanQualityScanner kind-branch rule", () => {
389
+ test("warns on a kind compared across three files and points at a kind table", async () => {
390
+ const compare = 'export const isScene = (pin: { kind: string }) => pin.kind === "videoScene";\n';
391
+ const root = await makeWorkspace({
392
+ "apps/demo/lib/pin/pin.constant.ts":
393
+ 'export class PinKind extends enumOf("pinKind", ["videoScene", "memo"] as const) {}\n',
394
+ "apps/demo/lib/board/board.constant.ts": compare,
395
+ "apps/demo/lib/take/take.store.ts": compare,
396
+ "apps/demo/webkit/pinLayout.ts": compare,
397
+ });
398
+
399
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.global.kind-branch");
400
+
401
+ expect(warnings.map(({ file, severity }) => ({ file, severity }))).toEqual([
402
+ { file: "apps/demo/lib/pin/pin.constant.ts", severity: "warning" },
403
+ ]);
404
+ expect(warnings[0]?.fix).toContain('cnst.PinKind["value"]');
405
+ });
406
+ });
407
+
408
+ describe("AkanQualityScanner baseline exceptions", () => {
409
+ test("reports each app's recorded baseline entries per rule", async () => {
410
+ const root = await makeWorkspace({
411
+ "apps/demo/akan.baseline.json": `${JSON.stringify(
412
+ {
413
+ "akan.size.file": { "ui/Board.tsx": 420 },
414
+ reasons: { "ui/Board.tsx": "Generated SVG path table for the court." },
415
+ },
416
+ null,
417
+ 2,
418
+ )}\n`,
419
+ "apps/demo/ui/Board.tsx": "export const Board = () => null;\n",
420
+ });
421
+
422
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.baseline.exceptions");
423
+
424
+ expect(warnings.map(({ file, message }) => ({ file, message }))).toEqual([
425
+ {
426
+ file: "apps/demo/akan.baseline.json",
427
+ message:
428
+ 'apps/demo carries 1 reasoned exception and 0 adopted-debt entries for akan.size.file: ui/Board.tsx ("Generated SVG path table for the court.").',
429
+ },
430
+ ]);
431
+ });
432
+ });
package/qualityScanner.ts CHANGED
@@ -5,8 +5,13 @@ import { RESERVED_ROUTE_CONFIG_EXPORTS } from "akanjs/common";
5
5
  import ignore from "ignore";
6
6
  import ts from "typescript";
7
7
  import { AbstractDoc } from "./abstractDoc";
8
+ import { CommentDensityScanner } from "./commentDensityScanner";
9
+ import { DeprecatedCommentScanner } from "./deprecatedCommentScanner";
8
10
  import { FormSetterScanner } from "./formSetterScanner";
11
+ import { KindBranchScanner } from "./kindBranchScanner";
12
+ import { MaintainabilityExceptionScanner } from "./maintainabilityExceptionScanner";
9
13
  import { fileWarning, formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
14
+ import { TestOnlyExportScanner } from "./testOnlyExportScanner";
10
15
  import {
11
16
  facetIndexShadowOf,
12
17
  isAllowedLibFacetRootFile,
@@ -17,7 +22,7 @@ import {
17
22
  } from "./workspaceLayout";
18
23
 
19
24
  type QualitySeverity = "warning";
20
- type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent";
25
+ type QualityScope = "global" | "file" | "convention" | "layout" | "ssr" | "agent" | "comment";
21
26
 
22
27
  export interface QualityWarning {
23
28
  rule: string;
@@ -115,6 +120,8 @@ const RULE_FIXES: Record<string, string> = {
115
120
  "Rename one of the exports, or if they are the same thing, extract it into one shared module and import it in both places.",
116
121
  "akan.global.duplicate-exported-function-body":
117
122
  "Extract the shared implementation into a single exported helper and import it, instead of copying the body.",
123
+ "akan.global.test-only-export":
124
+ "Delete the export together with its test. If a live caller in the same file needs it, keep it unexported and test it through that caller.",
118
125
  "akan.file.recommended-max-lines":
119
126
  "Split the file by responsibility — move Zones, Utils, or subcomponents into sibling files.",
120
127
  "akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
@@ -163,6 +170,10 @@ const RULE_FIXES: Record<string, string> = {
163
170
  "Add a <Model>.Unit.tsx for list/card rendering and a <Model>.View.tsx for the detail surface, then have the Zone delegate to them.",
164
171
  "akan.ssr.template-client-state":
165
172
  "Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
173
+ "akan.comment.density":
174
+ 'Delete comments that restate the code, label a section, or narrate a step; keep only those carrying a fact the code cannot show, such as a vendor quirk or security reasoning (AGENTS.md "Comments").',
175
+ "akan.comment.deprecated-marker":
176
+ "Replace the comment with `/** @deprecated <what to use instead> */` on the declaration, so every use site shows it struck through.",
166
177
  };
167
178
 
168
179
  function getRuleFix(rule: string): string | undefined {
@@ -193,6 +204,9 @@ export class AkanQualityScanner {
193
204
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
194
205
  ...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
195
206
  ...new FormSetterScanner().scan(sourceFiles),
207
+ ...new CommentDensityScanner().scan(sourceFiles),
208
+ ...new DeprecatedCommentScanner().scan(sourceFiles),
209
+ ...(await new MaintainabilityExceptionScanner().scan(workspaceRoot)),
196
210
  ...ssr.warnings,
197
211
  ];
198
212
 
@@ -297,6 +311,8 @@ export class AkanQualityScanner {
297
311
  });
298
312
  }
299
313
 
314
+ warnings.push(...new TestOnlyExportScanner().scan(sourceFiles));
315
+ warnings.push(...new KindBranchScanner().scan(sourceFiles));
300
316
  return warnings;
301
317
  }
302
318
 
@@ -43,12 +43,14 @@ describe("RouteSourceValidator route chains", () => {
43
43
  const chain = (source: string, kind: "page" | "layout", pattern: string) =>
44
44
  RouteSourceValidator.validateRouteSourceExports(source, `page/${kind}.tsx`, kind, { pattern });
45
45
 
46
- test("reads params, prompt, apps and devOnly off a page() chain without evaluating it", () => {
46
+ test("reads params, search names, prompt, apps, guard and devOnly off a page() chain without evaluating it", () => {
47
47
  const source = [
48
48
  'import { page } from "akanjs/client";',
49
49
  "export default page()",
50
50
  ' .param("projectId", ID, { desc: "The project." })',
51
51
  ' .search("status", String)',
52
+ " .search(dynamicName, String)",
53
+ " .guard(() => signedIn())",
52
54
  " .config({ transition: 'stack', devOnly: true })",
53
55
  ' .prompt("briefProject", "Brief one project.")',
54
56
  ' .app("showProject", "Show one project.", ({ projectId }) => null)',
@@ -57,7 +59,14 @@ describe("RouteSourceValidator route chains", () => {
57
59
  ].join("\n");
58
60
  expect(chain(source, "page", "/:lang/project/:projectId")).toEqual({
59
61
  devOnly: true,
60
- chain: { kind: "page", params: ["projectId"], prompt: "briefProject", apps: ["showProject", "showBoard"] },
62
+ chain: {
63
+ kind: "page",
64
+ params: ["projectId"],
65
+ prompt: "briefProject",
66
+ apps: ["showProject", "showBoard"],
67
+ search: ["status"],
68
+ guarded: true,
69
+ },
61
70
  });
62
71
  });
63
72
 
@@ -17,6 +17,10 @@ export interface RouteSourceChain {
17
17
  prompt?: string;
18
18
  /** The `.app()` names, each one MCP App the build gives a view of its own. */
19
19
  apps: string[];
20
+ /** The `.search()` names, in chain order; a name that is not a literal is left out. */
21
+ search: string[];
22
+ /** The chain calls `.guard()`, which gates this route and, on a layout, every route under it. */
23
+ guarded: boolean;
20
24
  }
21
25
 
22
26
  interface ChainStage {
@@ -104,6 +108,8 @@ export class RouteSourceValidator {
104
108
  params: chain.params,
105
109
  ...(chain.prompt ? { prompt: chain.prompt } : {}),
106
110
  apps: chain.apps,
111
+ search: chain.search,
112
+ guarded: chain.guarded,
107
113
  },
108
114
  };
109
115
  }
@@ -168,8 +174,10 @@ export class RouteSourceValidator {
168
174
  static #chainOf(kind: RouteSourceChain["kind"], stages: ChainStage[], filePath: string) {
169
175
  const params: string[] = [];
170
176
  const apps: string[] = [];
177
+ const search: string[] = [];
171
178
  let prompt: string | undefined;
172
179
  let devOnly = false;
180
+ let guarded = false;
173
181
  const literal = (stage: ChainStage) => {
174
182
  const first = stage.args[0];
175
183
  if (!first || !ts.isStringLiteralLike(first))
@@ -182,10 +190,14 @@ export class RouteSourceValidator {
182
190
  if (stage.name === "param") params.push(literal(stage));
183
191
  else if (stage.name === "prompt") prompt = literal(stage);
184
192
  else if (stage.name === "app") apps.push(literal(stage));
185
- else if (stage.name === "config")
193
+ else if (stage.name === "guard") guarded = true;
194
+ else if (stage.name === "search") {
195
+ const first = stage.args[0];
196
+ if (first && ts.isStringLiteralLike(first)) search.push(first.text);
197
+ } else if (stage.name === "config")
186
198
  devOnly = RouteSourceValidator.#devOnlyOf(RouteSourceValidator.#unwrapExpression(stage.args[0]), filePath);
187
199
  }
188
- return { kind, params, prompt, apps, devOnly };
200
+ return { kind, params, prompt, apps, search, guarded, devOnly };
189
201
  }
190
202
 
191
203
  /** The same rule the loader applies: a page names every `[x]` it sits under, a layout may name a subset. */
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { ServiceLoopCallScanner } from "./serviceLoopCallScanner";
4
+
5
+ const scan = (body: string, answersCaller: (method: string) => boolean = () => true) =>
6
+ new ServiceLoopCallScanner().scan(
7
+ ts.createSourceFile(
8
+ "court.service.ts",
9
+ `export class CourtService extends serve(db.court, () => ({})) {\n${body}\n}\n`,
10
+ ts.ScriptTarget.Latest,
11
+ true,
12
+ ),
13
+ answersCaller,
14
+ );
15
+
16
+ describe("ServiceLoopCallScanner", () => {
17
+ test("names a service, model or generated query called per item, directly or through a method of its own", () => {
18
+ const calls = scan(` async syncAll(ids: string[]) {
19
+ for (const id of await this.listIds()) await this.pcService.ping(id);
20
+ await Promise.all(ids.map((id) => this.#touch(id)));
21
+ ids.forEach((id) => this.logger.info(id));
22
+ }
23
+ async #touch(id: string) {
24
+ await this.getCourt(id);
25
+ }`);
26
+
27
+ expect(calls).toEqual([{ method: "syncAll", line: 3, calls: ["this.pcService.ping", "this.#touch"] }]);
28
+ });
29
+
30
+ test("spares a counted loop, a call made once, and a method no endpoint reaches", () => {
31
+ const body = ` async retry(id: string) {
32
+ for (let attempt = 0; attempt < 3; attempt += 1) await this.courtModel.touch(id);
33
+ while (await this.courtModel.isLocked(id)) await Bun.sleep(10);
34
+ }
35
+ async sweep(ids: string[]) {
36
+ const courts = await this.loadCourtMany(ids);
37
+ for (const court of courts) court.touch();
38
+ await Promise.all(ids.map((id) => this.pcService.ping(id)));
39
+ }`;
40
+
41
+ expect(scan(body, (method) => method === "retry")).toEqual([]);
42
+ });
43
+
44
+ test("spares a get or load a per-element callback issues in one tick, which the DataLoader batches", () => {
45
+ const calls = scan(` async courtsOf(ids: string[], pcIds: string[]) {
46
+ await Promise.all(ids.map((id) => this.getCourt(id)));
47
+ await Promise.all(pcIds.map((id) => this.pcModel.loadPc(id)));
48
+ await Promise.all(ids.map((id) => this.courtModel.countCourt({ id })));
49
+ for (const id of ids) await this.courtModel.getCourt(id);
50
+ }`);
51
+
52
+ expect(calls).toEqual([
53
+ { method: "courtsOf", line: 5, calls: ["this.courtModel.countCourt", "this.courtModel.getCourt"] },
54
+ ]);
55
+ });
56
+ });
@@ -0,0 +1,122 @@
1
+ import ts from "typescript";
2
+ import { WritePath } from "./writePath";
3
+
4
+ export interface ServiceLoopCall {
5
+ method: string;
6
+ line: number;
7
+ calls: string[];
8
+ }
9
+
10
+ type NamedMethod = ts.MethodDeclaration & { name: ts.Identifier | ts.PrivateIdentifier };
11
+
12
+ type PerItem = false | "loop" | "callback";
13
+
14
+ //? A counted loop (`for (;;)`, `while`) retries or pages through a query, so only a loop over items is per item.
15
+ export class ServiceLoopCallScanner {
16
+ static readonly #reachingField = /(?:Service|Model)$/;
17
+ static readonly #generatedQuery =
18
+ /^_*(?:get|load|list|find|pick|exists|count|insight|create|update|remove|search)[A-Z]/;
19
+
20
+ scan(sourceFile: ts.SourceFile, answersCaller: (method: string) => boolean): ServiceLoopCall[] {
21
+ return sourceFile.statements.filter(ts.isClassDeclaration).flatMap((cls) => {
22
+ const methods = cls.members.filter(
23
+ (member): member is NamedMethod =>
24
+ ts.isMethodDeclaration(member) &&
25
+ !!member.body &&
26
+ (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)),
27
+ );
28
+ const declared = new Set(methods.map((method) => method.name.text));
29
+ const reaching = ServiceLoopCallScanner.#reachingMethodsOf(methods, declared);
30
+ const reaches = (call: ts.CallExpression) => ServiceLoopCallScanner.#reaches(call, declared, reaching);
31
+ const ownModel = cls.name?.text.replace(/Service$/, "") ?? "";
32
+ return methods.flatMap((method) => {
33
+ if (!answersCaller(method.name.text)) return [];
34
+ const calls: ts.CallExpression[] = [];
35
+ const visit = (node: ts.Node, perItem: PerItem) => {
36
+ if (
37
+ perItem &&
38
+ ts.isCallExpression(node) &&
39
+ reaches(node) &&
40
+ !(perItem === "callback" && ServiceLoopCallScanner.#isBatchedLoad(node, declared, ownModel))
41
+ )
42
+ calls.push(node);
43
+ if (ts.isForOfStatement(node) || ts.isForInStatement(node)) {
44
+ visit(node.expression, perItem);
45
+ visit(node.statement, "loop");
46
+ return;
47
+ }
48
+ ts.forEachChild(node, (child) =>
49
+ visit(child, perItem === "loop" || !WritePath.isPerElementCallback(child) ? perItem : "callback"),
50
+ );
51
+ };
52
+ visit(method, false);
53
+ const first = calls[0];
54
+ if (!first) return [];
55
+ return [
56
+ {
57
+ method: method.name.text,
58
+ line: sourceFile.getLineAndCharacterOfPosition(first.getStart(sourceFile)).line + 1,
59
+ calls: calls.map((call) => call.expression.getText(sourceFile)),
60
+ },
61
+ ];
62
+ });
63
+ });
64
+ }
65
+
66
+ static #reachingMethodsOf(methods: NamedMethod[], declared: Set<string>) {
67
+ const reaching = new Set<string>();
68
+ let grew = true;
69
+ while (grew) {
70
+ grew = false;
71
+ for (const method of methods) {
72
+ const name = method.name.text;
73
+ if (reaching.has(name)) continue;
74
+ let found = false;
75
+ const visit = (node: ts.Node) => {
76
+ if (found) return;
77
+ if (ts.isCallExpression(node) && ServiceLoopCallScanner.#reaches(node, declared, reaching)) found = true;
78
+ else ts.forEachChild(node, visit);
79
+ };
80
+ visit(method);
81
+ if (found) {
82
+ reaching.add(name);
83
+ grew = true;
84
+ }
85
+ }
86
+ }
87
+ return reaching;
88
+ }
89
+
90
+ //? `get<Model>` / `load<Model>` / `load<Model>Many` go through the model's DataLoader, so the calls a per-element
91
+ //? callback issues in one tick are one query; a `for…of` awaiting each item issues them a tick apart, one by one.
92
+ static #isBatchedLoad(call: ts.CallExpression, declared: Set<string>, ownModel: string) {
93
+ const callee = call.expression;
94
+ if (!ts.isPropertyAccessExpression(callee)) return false;
95
+ const model = ServiceLoopCallScanner.#loadedModelOf(callee, declared, ownModel);
96
+ if (!model) return false;
97
+ const className = `${model.charAt(0).toUpperCase()}${model.slice(1)}`;
98
+ return [`get${className}`, `load${className}`, `load${className}Many`].includes(callee.name.text);
99
+ }
100
+
101
+ static #loadedModelOf(callee: ts.PropertyAccessExpression, declared: Set<string>, ownModel: string) {
102
+ const owner = callee.expression;
103
+ if (owner.kind === ts.SyntaxKind.ThisKeyword) return declared.has(callee.name.text) ? null : ownModel;
104
+ if (!ts.isPropertyAccessExpression(owner) || owner.expression.kind !== ts.SyntaxKind.ThisKeyword) return null;
105
+ return owner.name.text.match(/^(\w+)Model$/)?.[1] ?? null;
106
+ }
107
+
108
+ static #reaches(call: ts.CallExpression, declared: Set<string>, reaching: Set<string>) {
109
+ const callee = call.expression;
110
+ if (!ts.isPropertyAccessExpression(callee)) return false;
111
+ const owner = callee.expression;
112
+ if (owner.kind === ts.SyntaxKind.ThisKeyword) {
113
+ const name = callee.name.text;
114
+ return declared.has(name) ? reaching.has(name) : ServiceLoopCallScanner.#generatedQuery.test(name);
115
+ }
116
+ return (
117
+ ts.isPropertyAccessExpression(owner) &&
118
+ owner.expression.kind === ts.SyntaxKind.ThisKeyword &&
119
+ ServiceLoopCallScanner.#reachingField.test(owner.name.text)
120
+ );
121
+ }
122
+ }
@@ -110,6 +110,21 @@ export class ItemEndpoint extends endpoint(srv.item, ({ query, mutation }) => ({
110
110
  expect(index.isMutation("apps/third/page/x.tsx", "approveTicket")).toBe(false);
111
111
  });
112
112
 
113
+ test("reads a lib page's call by its own lib's endpoint before another lib's of that name", () => {
114
+ const shared = fileOf(
115
+ "libs/shared/lib/_shared/shared.signal.ts",
116
+ `export class SharedEndpoint extends endpoint(srv.shared, ({ mutation }) => ({ syncFeed: mutation(Boolean, {}).exec(() => true) })) {}`,
117
+ );
118
+ const social = fileOf(
119
+ "libs/social/lib/_social/social.signal.ts",
120
+ `export class SocialEndpoint extends endpoint(srv.social, ({ query }) => ({ syncFeed: query(Boolean, {}).exec(() => true) })) {}`,
121
+ );
122
+ const index = new SignalMutationIndex([shared, social]);
123
+ expect(index.isMutation("libs/social/page/x.tsx", "syncFeed")).toBe(false);
124
+ expect(index.policyIndexOf("libs/social/page/x.tsx", "syncFeed")).toBeNull();
125
+ expect(index.isMutation("libs/shared/page/x.tsx", "syncFeed")).toBe(true);
126
+ });
127
+
113
128
  test("ignores signal tests and specs", () => {
114
129
  const spec = fileOf(
115
130
  "apps/demo/lib/ticket/ticket.signal.spec.ts",
@@ -34,7 +34,7 @@ export class SignalMutationIndex {
34
34
 
35
35
  #shapeOf(consumerFile: string, name: string) {
36
36
  const [root, owner] = consumerFile.split("/");
37
- const own = root === "apps" ? this.#shapesByOwner.get(`apps/${owner}`)?.get(name) : undefined;
37
+ const own = this.#shapesByOwner.get(`${root}/${owner}`)?.get(name);
38
38
  if (own) return own;
39
39
  for (const [key, shapes] of this.#shapesByOwner) {
40
40
  const shape = shapes.get(name);
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { SourceComments } from "./sourceComments";
4
+
5
+ const commentsOf = (content: string) =>
6
+ SourceComments.of(ts.createSourceFile("demo.tsx", content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)).map(
7
+ ({ line, body, isJsDoc }) => ({ line, body, isJsDoc }),
8
+ );
9
+
10
+ describe("SourceComments", () => {
11
+ test("finds leading, trailing, empty-block and end-of-file comments", () => {
12
+ expect(
13
+ commentsOf(
14
+ ["// leading", "const a = 1; // trailing", "const f = () => {", " /* inside */", "};", "// last", ""].join(
15
+ "\n",
16
+ ),
17
+ ),
18
+ ).toEqual([
19
+ { line: 1, body: ["leading"], isJsDoc: false },
20
+ { line: 2, body: ["trailing"], isJsDoc: false },
21
+ { line: 4, body: ["inside"], isJsDoc: false },
22
+ { line: 6, body: ["last"], isJsDoc: false },
23
+ ]);
24
+ });
25
+
26
+ test("ignores comment syntax inside strings, templates, regexes and JSX text", () => {
27
+ expect(
28
+ commentsOf(
29
+ [
30
+ 'const url = "https://host//path";',
31
+ `const tpl = \`a // b \${url} // c\`;`,
32
+ "const re = /\\/\\/x/;",
33
+ 'export const C = () => <div title="//x">// text {/* real */}</div>;',
34
+ "",
35
+ ].join("\n"),
36
+ ),
37
+ ).toEqual([{ line: 4, body: ["real"], isJsDoc: false }]);
38
+ });
39
+
40
+ test("strips block delimiters and marks a JSDoc", () => {
41
+ expect(
42
+ commentsOf(["/**", " * Summary", " * @deprecated use b", " */", "export const a = 1;", ""].join("\n")),
43
+ ).toEqual([{ line: 1, body: ["", "Summary", "@deprecated use b", ""], isJsDoc: true }]);
44
+ });
45
+ });
@@ -0,0 +1,70 @@
1
+ import ts from "typescript";
2
+
3
+ export interface SourceComment {
4
+ raw: string;
5
+ line: number;
6
+ isJsDoc: boolean;
7
+ body: string[];
8
+ }
9
+
10
+ export class SourceComments {
11
+ static #scanner = ts.createScanner(ts.ScriptTarget.Latest, false);
12
+
13
+ static of(sourceFile: ts.SourceFile): SourceComment[] {
14
+ const comments: SourceComment[] = [];
15
+ const scanGap = (start: number, end: number) => {
16
+ if (end <= start) return;
17
+ const scanner = SourceComments.#scanner;
18
+ scanner.setText(sourceFile.text, start, end - start);
19
+ for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) {
20
+ if (kind !== ts.SyntaxKind.SingleLineCommentTrivia && kind !== ts.SyntaxKind.MultiLineCommentTrivia) continue;
21
+ comments.push(SourceComments.#commentOf(sourceFile, scanner.getTokenStart(), scanner.getTokenText()));
22
+ }
23
+ scanner.setText(undefined);
24
+ };
25
+ //? Only the trivia between AST children is scanned: string, template, regex and JSX text tokens can hold `//`.
26
+ const visit = (node: ts.Node) => {
27
+ if (node.kind === ts.SyntaxKind.JsxText) return;
28
+ if (ts.isToken(node)) {
29
+ scanGap(node.pos, node.getStart(sourceFile));
30
+ return;
31
+ }
32
+ const children: ts.Node[] = [];
33
+ ts.forEachChild(
34
+ node,
35
+ (child) => {
36
+ children.push(child);
37
+ },
38
+ (nodes) => {
39
+ children.push(...nodes);
40
+ },
41
+ );
42
+ let cursor = node.pos;
43
+ for (const child of children) {
44
+ scanGap(cursor, child.pos);
45
+ visit(child);
46
+ cursor = Math.max(cursor, child.end);
47
+ }
48
+ scanGap(cursor, node.end);
49
+ };
50
+ visit(sourceFile);
51
+ return comments;
52
+ }
53
+
54
+ static #commentOf(sourceFile: ts.SourceFile, pos: number, raw: string): SourceComment {
55
+ return {
56
+ raw,
57
+ line: sourceFile.getLineAndCharacterOfPosition(pos).line + 1,
58
+ isJsDoc: raw.startsWith("/**") && !raw.startsWith("/**/"),
59
+ body: SourceComments.#bodyOf(raw),
60
+ };
61
+ }
62
+
63
+ static #bodyOf(raw: string) {
64
+ if (raw.startsWith("//")) return [raw.replace(/^\/\/+/, "").trim()];
65
+ return raw
66
+ .slice(2, raw.endsWith("*/") ? -2 : undefined)
67
+ .split(/\r?\n/)
68
+ .map((line) => line.replace(/^\s*\*+/, "").trim());
69
+ }
70
+ }