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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/applicationBuildRunner.test.ts +17 -0
  2. package/applicationBuildRunner.ts +2 -1
  3. package/biome.base.json +4 -1
  4. package/commentDensityScanner.test.ts +121 -0
  5. package/commentDensityScanner.ts +101 -0
  6. package/componentSizeScanner.test.ts +36 -0
  7. package/componentSizeScanner.ts +52 -0
  8. package/componentWriteChainScanner.test.ts +137 -0
  9. package/componentWriteChainScanner.ts +104 -0
  10. package/deprecatedCommentScanner.test.ts +54 -0
  11. package/deprecatedCommentScanner.ts +30 -0
  12. package/endpointReach.test.ts +58 -0
  13. package/endpointReach.ts +80 -0
  14. package/index.ts +1 -0
  15. package/kindBranchScanner.test.ts +89 -0
  16. package/kindBranchScanner.ts +158 -0
  17. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  18. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  19. package/lint/no-oversized-list-limit.grit +73 -0
  20. package/maintainabilityBaseline.test.ts +280 -0
  21. package/maintainabilityBaseline.ts +220 -0
  22. package/maintainabilityContract.test.ts +846 -0
  23. package/maintainabilityContract.ts +404 -0
  24. package/maintainabilityExceptionScanner.test.ts +90 -0
  25. package/maintainabilityExceptionScanner.ts +85 -0
  26. package/maintainabilityLimits.ts +60 -0
  27. package/maintainabilityReport.test.ts +130 -0
  28. package/maintainabilityReport.ts +142 -0
  29. package/package.json +2 -2
  30. package/qualityScanner.test.ts +75 -0
  31. package/qualityScanner.ts +17 -1
  32. package/serviceLoopCallScanner.test.ts +56 -0
  33. package/serviceLoopCallScanner.ts +122 -0
  34. package/sourceComments.test.ts +45 -0
  35. package/sourceComments.ts +70 -0
  36. package/storeAccessScanner.test.ts +84 -0
  37. package/storeAccessScanner.ts +71 -0
  38. package/storeSource.ts +159 -0
  39. package/storeWriteScanner.test.ts +292 -0
  40. package/storeWriteScanner.ts +50 -0
  41. package/testOnlyExportScanner.test.ts +83 -0
  42. package/testOnlyExportScanner.ts +124 -0
  43. package/workflow/artifacts.ts +6 -1
  44. package/workflow/executor.ts +22 -7
  45. package/workflow/guardCatalog.test.ts +59 -0
  46. package/workflow/guardCatalog.ts +108 -0
  47. package/workflow/index.ts +4 -0
  48. package/workflow/interactionScaffold.ts +122 -0
  49. package/workflow/mutationScaffold.ts +519 -0
  50. package/workflow/mutationSource.test.ts +108 -0
  51. package/workflow/mutationSource.ts +219 -0
  52. package/workflow/plan.ts +54 -0
  53. package/workflow/render.ts +15 -1
  54. package/workflow/source.test.ts +130 -0
  55. package/workflow/source.ts +122 -14
  56. package/workflow/types.ts +17 -0
  57. package/workspaceLayout.ts +2 -0
  58. package/writePath.test.ts +109 -0
  59. package/writePath.ts +219 -0
  60. package/writePlacementScanner.test.ts +77 -0
  61. package/writePlacementScanner.ts +50 -0
  62. package/writingActionIndex.test.ts +99 -0
  63. package/writingActionIndex.ts +63 -0
@@ -0,0 +1,30 @@
1
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
2
+ import { type SourceComment, SourceComments } from "./sourceComments";
3
+
4
+ export class DeprecatedCommentScanner {
5
+ static readonly rule = "akan.comment.deprecated-marker";
6
+ static #marker = /^[!?*\s]*(?:(?:TODO|FIXME|XXX|NOTE)\b\s*:?\s*)?@?deprecated\b/i;
7
+
8
+ scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
9
+ return sourceFiles
10
+ .filter(({ file }) => /^(apps|libs)\//.test(file) && !/\.(test|spec)\.tsx?$/.test(file))
11
+ .flatMap(({ file, sourceFile }) =>
12
+ SourceComments.of(sourceFile)
13
+ .filter((comment) => DeprecatedCommentScanner.#isLooseMarker(comment))
14
+ .map((comment) => ({
15
+ rule: DeprecatedCommentScanner.rule,
16
+ scope: "comment" as const,
17
+ severity: "warning" as const,
18
+ file,
19
+ line: comment.line,
20
+ message: `"${comment.raw.split(/\r?\n/)[0]?.trim().slice(0, 80)}" marks nothing at the use sites; editors strike through only a JSDoc @deprecated tag.`,
21
+ })),
22
+ );
23
+ }
24
+
25
+ static #isLooseMarker({ isJsDoc, body }: SourceComment) {
26
+ const text = body.filter((line) => line !== "").join(" ");
27
+ if (isJsDoc && /(^|\s)@deprecated\b/.test(text)) return false;
28
+ return DeprecatedCommentScanner.#marker.test(text);
29
+ }
30
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { EndpointReach } from "./endpointReach";
4
+
5
+ const fileOf = (file: string, content: string) => ({
6
+ file,
7
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true),
8
+ });
9
+
10
+ describe("EndpointReach", () => {
11
+ test("follows an endpoint or a slice through its service's own and other services' methods, not a job", () => {
12
+ const reach = new EndpointReach(
13
+ [
14
+ fileOf(
15
+ "apps/demo/lib/court/court.signal.ts",
16
+ `export class CourtInternal extends internal(srv.court, ({ cron }) => ({
17
+ sweep: cron("0 * * * *").exec(function () { return this.courtService.sweep(); }),
18
+ })) {}
19
+ export class CourtSlice extends slice(srv.court, { guards: { root: Admin } }, (init) => ({
20
+ inOrg: init().exec(function (orgId) { return this.courtService.queryInOrg(orgId); }),
21
+ })) {}
22
+ export class CourtEndpoint extends endpoint(srv.court, ({ mutation }) => ({
23
+ assign: mutation(Boolean).exec(function (id) { return this.courtService.assign(id); }),
24
+ })) {}
25
+ `,
26
+ ),
27
+ ],
28
+ [
29
+ fileOf(
30
+ "apps/demo/lib/court/court.service.ts",
31
+ `export class CourtService extends serve(db.court, () => ({})) {
32
+ queryInOrg(orgId: string) { return { orgId }; }
33
+ async assign(id: string) { return await this.#pick(id); }
34
+ async #pick(id: string) { return await this.pcService.claim(id); }
35
+ async sweep() { return await this.#pick("all"); }
36
+ }
37
+ `,
38
+ ),
39
+ fileOf(
40
+ "libs/shared/lib/_pc/pc.service.ts",
41
+ `export class PcService extends serve("pc", () => ({})) {
42
+ async claim(id: string) { return id; }
43
+ async release(id: string) { return id; }
44
+ }
45
+ `,
46
+ ),
47
+ ],
48
+ );
49
+
50
+ expect(EndpointReach.serviceOf("libs/shared/lib/_pc/pc.service.ts")).toBe("pc");
51
+ expect(
52
+ ["court.queryInOrg", "court.assign", "court.#pick", "pc.claim", "court.sweep", "pc.release"].map((key) => {
53
+ const [service = "", method = ""] = key.split(".");
54
+ return reach.reaches(service, method);
55
+ }),
56
+ ).toEqual([true, true, true, true, false, false]);
57
+ });
58
+ });
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import type { SourceFileInfo } from "./qualityScanner";
4
+
5
+ type ServiceMethod = [service: string, method: string];
6
+
7
+ //? Only `endpoint(…)` and `slice(…)` answer a caller who waits; an `internal(…)` job (cron, interval, queue) works
8
+ //? per item on purpose, so that one failing item leaves the rest done.
9
+ export class EndpointReach {
10
+ static readonly #requestBuilders = new Set(["endpoint", "slice"]);
11
+ readonly #reached = new Set<string>();
12
+
13
+ constructor(
14
+ signalFiles: Pick<SourceFileInfo, "sourceFile">[],
15
+ serviceFiles: Pick<SourceFileInfo, "file" | "sourceFile">[],
16
+ ) {
17
+ const methodsByService = new Map<string, Map<string, ts.MethodDeclaration>>();
18
+ for (const { file, sourceFile } of serviceFiles) {
19
+ const methods = methodsByService.get(EndpointReach.serviceOf(file)) ?? new Map<string, ts.MethodDeclaration>();
20
+ for (const cls of sourceFile.statements.filter(ts.isClassDeclaration))
21
+ for (const member of cls.members)
22
+ if (
23
+ ts.isMethodDeclaration(member) &&
24
+ member.body &&
25
+ (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name))
26
+ )
27
+ methods.set(member.name.text, member);
28
+ methodsByService.set(EndpointReach.serviceOf(file), methods);
29
+ }
30
+ const pending: ServiceMethod[] = [];
31
+ const reach = ([service, method]: ServiceMethod) => {
32
+ const key = `${service}.${method}`;
33
+ if (this.#reached.has(key) || !methodsByService.get(service)?.has(method)) return;
34
+ this.#reached.add(key);
35
+ pending.push([service, method]);
36
+ };
37
+ for (const { sourceFile } of signalFiles)
38
+ for (const cls of sourceFile.statements.filter(ts.isClassDeclaration)) {
39
+ const base = cls.heritageClauses?.[0]?.types[0]?.expression;
40
+ if (
41
+ base &&
42
+ ts.isCallExpression(base) &&
43
+ ts.isIdentifier(base.expression) &&
44
+ EndpointReach.#requestBuilders.has(base.expression.text)
45
+ )
46
+ EndpointReach.#serviceCallsIn(base, null).forEach(reach);
47
+ }
48
+ for (let next = pending.pop(); next; next = pending.pop()) {
49
+ const [service, method] = next;
50
+ const declaration = methodsByService.get(service)?.get(method);
51
+ if (declaration) EndpointReach.#serviceCallsIn(declaration, service).forEach(reach);
52
+ }
53
+ }
54
+
55
+ static serviceOf(file: string) {
56
+ return path.posix.basename(file).replace(/\.service\.ts$/, "");
57
+ }
58
+
59
+ reaches(service: string, method: string) {
60
+ return this.#reached.has(`${service}.${method}`);
61
+ }
62
+
63
+ static #serviceCallsIn(node: ts.Node, ownService: string | null) {
64
+ const calls: ServiceMethod[] = [];
65
+ const visit = (child: ts.Node) => {
66
+ if (ts.isCallExpression(child) && ts.isPropertyAccessExpression(child.expression)) {
67
+ const { expression: owner, name } = child.expression;
68
+ if (owner.kind === ts.SyntaxKind.ThisKeyword && ownService) calls.push([ownService, name.text]);
69
+ else if (ts.isPropertyAccessExpression(owner) && owner.expression.kind === ts.SyntaxKind.ThisKeyword) {
70
+ const field = owner.name.text;
71
+ if (field.length > "Service".length && field.endsWith("Service"))
72
+ calls.push([field.slice(0, -"Service".length), name.text]);
73
+ }
74
+ }
75
+ ts.forEachChild(child, visit);
76
+ };
77
+ visit(node);
78
+ return calls;
79
+ }
80
+ }
package/index.ts CHANGED
@@ -19,6 +19,7 @@ export type * from "./fileSys";
19
19
  export type * from "./frontendBuild";
20
20
  export type * from "./getRelatedCnsts";
21
21
  export type * from "./incrementalBuilder";
22
+ export type * from "./maintainabilityContract";
22
23
  export type * from "./mobile";
23
24
  export type * from "./packageExportsMap";
24
25
  export type * from "./prompter";
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { KindBranchScanner } from "./kindBranchScanner";
4
+ import type { SourceFileInfo } from "./qualityScanner";
5
+
6
+ const filesOf = (files: Record<string, string>): SourceFileInfo[] =>
7
+ Object.entries(files).map(([file, content]) => ({
8
+ file,
9
+ absolutePath: `/repo/${file}`,
10
+ content,
11
+ sourceFile: ts.createSourceFile(
12
+ file,
13
+ content,
14
+ ts.ScriptTarget.Latest,
15
+ true,
16
+ file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
17
+ ),
18
+ }));
19
+
20
+ const pinConstant = `import { enumOf } from "akanjs/base";
21
+ export class PinKind extends enumOf("pinKind", ["videoScene", "videoTake", "memo"] as const) {}
22
+ export class PinStatus extends enumOf("pinStatus", ["active", "videoTake"] as const) {}
23
+ `;
24
+ const comparing = (expression: string) => `export const isIt = (pin: { kind: string }) => ${expression};\n`;
25
+
26
+ describe("KindBranchScanner", () => {
27
+ test("warns on a kind value compared in three files outside its module, naming them and the table to declare", () => {
28
+ const warnings = new KindBranchScanner().scan(
29
+ filesOf({
30
+ "apps/demo/lib/pin/pin.constant.ts": pinConstant,
31
+ "apps/demo/lib/pin/Pin.Zone.tsx": comparing('pin.kind === "videoScene"'),
32
+ "apps/demo/lib/board/Board.Zone.tsx": comparing('pin.kind === "videoScene"'),
33
+ "apps/demo/lib/take/take.store.ts": comparing('"videoScene" !== pin.kind'),
34
+ "apps/demo/ui/PinCard.tsx": `export const label = (kind: string) => {\n switch (kind) {\n case "videoScene":\n return 1;\n }\n};\n`,
35
+ "apps/demo/ui/PinCard.test.tsx": comparing('pin.kind === "videoScene"'),
36
+ }),
37
+ );
38
+
39
+ expect(warnings).toHaveLength(1);
40
+ expect(warnings[0]).toMatchObject({
41
+ rule: "akan.global.kind-branch",
42
+ severity: "warning",
43
+ file: "apps/demo/lib/pin/pin.constant.ts",
44
+ line: 2,
45
+ message: '"videoScene" of cnst.PinKind is branched on in 3 files outside the enum\'s module.',
46
+ locations: [
47
+ { file: "apps/demo/lib/board/Board.Zone.tsx", line: 1 },
48
+ { file: "apps/demo/lib/take/take.store.ts", line: 1 },
49
+ { file: "apps/demo/ui/PinCard.tsx", line: 3 },
50
+ ],
51
+ });
52
+ expect(warnings[0]?.fix).toContain('{ [kind in cnst.PinKind["value"]]: … }');
53
+ });
54
+
55
+ test("reads only a subject named for a kind, and only enums named for one", () => {
56
+ const warnings = new KindBranchScanner().scan(
57
+ filesOf({
58
+ "apps/demo/lib/pin/pin.constant.ts": pinConstant,
59
+ "apps/demo/lib/a/A.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
60
+ "apps/demo/lib/b/B.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
61
+ "apps/demo/lib/c/C.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
62
+ }),
63
+ );
64
+
65
+ expect(warnings).toEqual([]);
66
+ });
67
+
68
+ test("groups the enums sharing a value, reached from the app's own lib or a lib, never another app or lib", () => {
69
+ const warnings = new KindBranchScanner().scan(
70
+ filesOf({
71
+ "apps/demo/lib/pin/pin.constant.ts": pinConstant,
72
+ "libs/media/lib/clip/clip.constant.ts": `export class ClipType extends enumOf("clipType", ["videoTake"] as const) {}\n`,
73
+ "apps/demo/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
74
+ "apps/demo/lib/b/B.Zone.tsx": comparing('pin.kind === "videoTake"'),
75
+ "apps/demo/lib/c/C.Zone.tsx": comparing('pin.kind === "videoTake"'),
76
+ "apps/other/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
77
+ "apps/other/lib/b/B.Zone.tsx": comparing('pin.kind === "videoTake"'),
78
+ "libs/shared/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
79
+ }),
80
+ );
81
+
82
+ expect(warnings.map(({ file, message }) => ({ file, message }))).toEqual([
83
+ {
84
+ file: "apps/demo/lib/pin/pin.constant.ts",
85
+ message: '"videoTake" of cnst.PinKind / cnst.ClipType is branched on in 3 files outside the enum\'s module.',
86
+ },
87
+ ]);
88
+ });
89
+ });
@@ -0,0 +1,158 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
4
+
5
+ interface EnumDeclaration {
6
+ name: string;
7
+ sys: string;
8
+ file: string;
9
+ line: number;
10
+ module: string;
11
+ values: string[];
12
+ }
13
+
14
+ interface BranchSite {
15
+ value: string;
16
+ file: string;
17
+ line: number;
18
+ }
19
+
20
+ //? Enums sharing a value form one group: short of the type checker, nothing tells which one a literal means.
21
+ export class KindBranchScanner {
22
+ static readonly rule = "akan.global.kind-branch";
23
+ static readonly minFileNum = 3;
24
+ static readonly #kindName = /(Kind|Type|Mode)$/;
25
+ static readonly #kindSubject = /^(kind|type|mode)$|[a-z](Kind|Type|Mode)$/;
26
+ static readonly #equalities = new Set([
27
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
28
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
29
+ ts.SyntaxKind.EqualsEqualsToken,
30
+ ts.SyntaxKind.ExclamationEqualsToken,
31
+ ]);
32
+
33
+ scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
34
+ const enums = sourceFiles.flatMap((sourceFile) => KindBranchScanner.#enumsOf(sourceFile));
35
+ const ownersByValue = new Map<string, EnumDeclaration[]>();
36
+ for (const declaration of enums)
37
+ for (const value of declaration.values)
38
+ ownersByValue.set(value, [...(ownersByValue.get(value) ?? []), declaration]);
39
+ const sitesByGroup = new Map<string, { owners: EnumDeclaration[]; sites: BranchSite[] }>();
40
+ for (const sourceFile of sourceFiles) {
41
+ if (!/^(apps|libs)\//.test(sourceFile.file) || /\.(test|spec)\.tsx?$/.test(sourceFile.file)) continue;
42
+ const sys = KindBranchScanner.#sysOf(sourceFile.file);
43
+ //? An app reaches every lib's enums; a lib only its own, as nothing here knows which libs it depends on.
44
+ for (const site of KindBranchScanner.#sitesIn(sourceFile, ownersByValue)) {
45
+ const owners = (ownersByValue.get(site.value) ?? []).filter(
46
+ (owner) => owner.sys === sys || (owner.sys.startsWith("libs/") && sys.startsWith("apps/")),
47
+ );
48
+ if (!owners.length || owners.some(({ module }) => site.file.startsWith(module))) continue;
49
+ const group = owners.map(({ name, file }) => `${file}#${name}`).join(",");
50
+ const entry = sitesByGroup.get(group) ?? { owners, sites: [] };
51
+ entry.sites.push(site);
52
+ sitesByGroup.set(group, entry);
53
+ }
54
+ }
55
+ return [...sitesByGroup.values()].flatMap(({ owners, sites }) => KindBranchScanner.#warningOf(owners, sites));
56
+ }
57
+
58
+ static #warningOf(owners: EnumDeclaration[], sites: BranchSite[]): QualityWarning[] {
59
+ const filesByValue = new Map<string, Set<string>>();
60
+ for (const { value, file } of sites) filesByValue.set(value, (filesByValue.get(value) ?? new Set()).add(file));
61
+ const values = [...filesByValue]
62
+ .filter(([, files]) => files.size >= KindBranchScanner.minFileNum)
63
+ .map(([value]) => value)
64
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
65
+ const [owner] = owners;
66
+ if (!owner || !values.length) return [];
67
+ const branched = sites.filter(({ value }) => values.includes(value));
68
+ const fileNum = new Set(branched.map(({ file }) => file)).size;
69
+ const enumNames = owners.map(({ name }) => `cnst.${name}`).join(" / ");
70
+ return [
71
+ {
72
+ rule: KindBranchScanner.rule,
73
+ scope: "global",
74
+ severity: "warning",
75
+ file: owner.file,
76
+ line: owner.line,
77
+ message: `${values.map((value) => `"${value}"`).join(", ")} of ${enumNames} ${values.length > 1 ? "are" : "is"} branched on in ${fileNum} files outside the enum's module.`,
78
+ locations: branched.map(({ file, line }) => ({ file, line })),
79
+ fix: `Declare one table keyed by the kind, \`{ [kind in cnst.${owner.name}["value"]]: … }\` (a component, label or handler per kind), and look the kind up in it instead of comparing it in each file.`,
80
+ },
81
+ ];
82
+ }
83
+
84
+ static #sitesIn({ file, sourceFile }: SourceFileInfo, ownersByValue: Map<string, EnumDeclaration[]>) {
85
+ const sites: BranchSite[] = [];
86
+ const record = (literal: ts.Expression, subject: ts.Expression) => {
87
+ if (!ts.isStringLiteralLike(literal) || !ownersByValue.has(literal.text)) return;
88
+ if (!KindBranchScanner.#isKindSubject(subject)) return;
89
+ sites.push({
90
+ value: literal.text,
91
+ file,
92
+ line: sourceFile.getLineAndCharacterOfPosition(literal.getStart(sourceFile)).line + 1,
93
+ });
94
+ };
95
+ const visit = (node: ts.Node) => {
96
+ if (ts.isBinaryExpression(node) && KindBranchScanner.#equalities.has(node.operatorToken.kind)) {
97
+ record(node.left, node.right);
98
+ record(node.right, node.left);
99
+ } else if (ts.isCaseClause(node) && ts.isSwitchStatement(node.parent.parent))
100
+ record(node.expression, node.parent.parent.expression);
101
+ ts.forEachChild(node, visit);
102
+ };
103
+ visit(sourceFile);
104
+ return sites;
105
+ }
106
+
107
+ //? Only a subject named for a kind (`kind`, `pin.kind`, `viewMode`) counts: a bare `"active"` matches too much.
108
+ static #isKindSubject(subject: ts.Expression) {
109
+ let current = subject;
110
+ while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current))
111
+ current = current.expression;
112
+ const name = ts.isIdentifier(current)
113
+ ? current.text
114
+ : ts.isPropertyAccessExpression(current)
115
+ ? current.name.text
116
+ : ts.isElementAccessExpression(current) && ts.isStringLiteralLike(current.argumentExpression)
117
+ ? current.argumentExpression.text
118
+ : "";
119
+ return KindBranchScanner.#kindSubject.test(name);
120
+ }
121
+
122
+ static #enumsOf({ file, sourceFile }: SourceFileInfo): EnumDeclaration[] {
123
+ if (!/^(apps|libs)\/[^/]+\/lib\/.+\.constant\.ts$/.test(file)) return [];
124
+ return sourceFile.statements.flatMap((statement) => {
125
+ if (!ts.isClassDeclaration(statement) || !statement.name) return [];
126
+ if (!KindBranchScanner.#kindName.test(statement.name.text)) return [];
127
+ const values = KindBranchScanner.#enumValuesOf(statement);
128
+ if (!values) return [];
129
+ return [
130
+ {
131
+ name: statement.name.text,
132
+ sys: KindBranchScanner.#sysOf(file),
133
+ file,
134
+ line: sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile)).line + 1,
135
+ module: `${path.posix.dirname(file)}/`,
136
+ values,
137
+ },
138
+ ];
139
+ });
140
+ }
141
+
142
+ static #sysOf(file: string) {
143
+ return file.split("/").slice(0, 2).join("/");
144
+ }
145
+
146
+ static #enumValuesOf(statement: ts.ClassDeclaration) {
147
+ for (const clause of statement.heritageClauses ?? [])
148
+ for (const { expression } of clause.types) {
149
+ if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression)) continue;
150
+ if (expression.expression.text !== "enumOf") continue;
151
+ let list = expression.arguments[1];
152
+ while (list && (ts.isAsExpression(list) || ts.isParenthesizedExpression(list))) list = list.expression;
153
+ if (!list || !ts.isArrayLiteralExpression(list)) return null;
154
+ return list.elements.filter(ts.isStringLiteralLike).map(({ text }) => text);
155
+ }
156
+ return null;
157
+ }
158
+ }
@@ -0,0 +1,18 @@
1
+ export const everyCourt = () => fetch.courtListInPublic(0, 10000); // @flag
2
+ export const devicesOfOrg = (orgId: string) => fetch.deviceListInOrg(orgId, 0, 1000); // @flag
3
+ export const sortedDevices = (orgId: string) => fetch.deviceListInOrg(orgId, 0, 1000, "latest"); // @flag
4
+ export const withPolicy = (orgId: string) => fetch.deviceListInOrg(orgId, 0, 1000, null, { crystalize: false }); // @flag
5
+ export const rootList = () => fetch.deviceList(null, 501); // @flag
6
+ export const separated = () => fetch.deviceList(undefined, 10_000); // @flag
7
+ export const exponent = () => fetch.deviceList(0, 1e4); // @flag
8
+ export const namedSkip = (orgId: string, skip: number) => fetch.alertListInOrg(orgId, skip, 2000); // @flag
9
+ export const pagedSkip = (orgId: string, page: number) => fetch.alertListInOrg(orgId, (page - 1) * 20, 2000); // @flag
10
+ export const afterSliceNumbers = () => fetch.statListInYear(2026, 0, 600); // @flag
11
+ export const initInRoute = (orgId: string) => fetch.initDeviceInOrg(orgId, { limit: 1000 }); // @flag
12
+ export const initRoot = () => fetch.initDevice({ sort: "latest", limit: 9999 }); // @flag
13
+ export const getInit = (orgId: string) => fetch.getDeviceInitInOrg(orgId, { limit: 1000 }); // @flag
14
+ export const storeInit = (orgId: string) => st.do.initDeviceInOrg(orgId, { limit: 1000 }); // @flag
15
+ export const storeRefresh = () => st.do.refreshDeviceInOrg({ limit: 1000 }); // @flag
16
+ export const quotedKey = (orgId: string) => st.do.initDeviceInOrg(orgId, { "limit": 1000 }); // @flag
17
+ export const storeSetLimit = () => st.do.setLimitOfDeviceInOrg(1000); // @flag
18
+ export const Monitor = () => <button type="button" onClick={() => st.do.setLimitOfAlertInOrg(5000)}>All</button>; // @flag
@@ -0,0 +1,23 @@
1
+ export const atTheCap = () => fetch.courtListInPublic(0, 500); // @ok
2
+ export const onePage = (orgId: string) => fetch.deviceListInOrg(orgId, 0, 20, "latest"); // @ok
3
+ export const variableLimit = (orgId: string, limit: number) => fetch.deviceListInOrg(orgId, 0, limit); // @ok
4
+ export const everyRowMode = (orgId: string) => fetch.deviceListInOrg(orgId, 0, 0); // @ok
5
+ export const yearSlice = () => fetch.statListInYear(2026); // @ok
6
+ export const yearAfterId = (orgId: string) => fetch.statListInOrgYear(orgId, 2026); // @ok
7
+ export const monthAndYear = () => fetch.statListInMonthOfYear(11, 2026); // @ok
8
+ export const twoYears = () => fetch.statListInRange(2025, 2026); // @ok
9
+ export const numbersBeforePaging = () => fetch.statListInRange(0, 2026, 0, 20); // @ok
10
+ export const numbersWithoutPaging = () => fetch.statListInRange(0, 2026, 0); // @ok
11
+ export const notAList = () => fetch.device(0, 1000); // @ok
12
+ export const notFetch = () => api.deviceList(0, 1000); // @ok
13
+ export const lowercaseList = () => fetch.checklist(0, 1000); // @ok
14
+ export const initAtTheCap = (orgId: string) => fetch.initDeviceInOrg(orgId, { limit: 500 }); // @ok
15
+ export const rateLimit = (orgId: string) => fetch.initDeviceInOrg(orgId, { rateLimit: 1000 }); // @ok
16
+ export const nestedLimit = (orgId: string) => fetch.initDeviceInOrg(orgId, { default: { limit: 1000 } }); // @ok
17
+ export const mutationLimit = (id: string) => fetch.updateQuota(id, { limit: 1000 }); // @ok
18
+ export const storeWrite = () => st.do.setLimitOnQuota(1000); // @ok
19
+ export const storeSetSmallLimit = () => st.do.setLimitOfDeviceInOrg(100); // @ok
20
+ export const storeSetPage = () => st.do.setPageOfDeviceInOrg(1000); // @ok
21
+ export const policyOnList = (orgId: string) => fetch.deviceListInOrg(orgId, 0, 20, "latest", { limit: 1000 }); // @ok
22
+ export const Docs = () => <code>fetch.courtListInPublic(0, 10000)</code>; // @ok
23
+ export const sample = "fetch.courtListInPublic(0, 10000)"; // @ok
@@ -0,0 +1,73 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // A client list request takes at most MAX_PAGE_SIZE (500) rows: SignalResolver.#clientPageLimit
5
+ // (pkgs/akanjs/server/resolver/signal.resolver.ts) clamps a larger limit and answers with 500 rows and no
6
+ // error, so a screen that asked for 10000 silently stops at row 500. A store list reads hasMoreOf<Model><Suffix>
7
+ // as true after a full 500, so only a screen that calls loadMoreOf<Model><Suffix> ever sees the rest. The server
8
+ // logs one warning per list; this rule moves it to the line that asked.
9
+ //
10
+ // Matched: a literal limit above 500 in the generated list fetch, `fetch.<model>List<Suffix>(...sliceArgs,
11
+ // skip, limit, sort?, fetchPolicy?)`; as `limit:` in the option object of `fetch.init<Model><Suffix>`,
12
+ // `fetch.get<Model>Init<Suffix>`, `st.do.init<Model><Suffix>` and `st.do.refresh<Model><Suffix>`; and as the
13
+ // argument of `st.do.setLimitOf<Model><Suffix>`.
14
+ //
15
+ // A rule cannot know a slice's arity, so the positional limit is found by shape: an argument that reads as a
16
+ // skip (`0`, `null`, `undefined`, a name with skip/offset, a page multiplied out) directly before the literal,
17
+ // and after it nothing but a non-numeric sort and a fetch policy. That keeps a numeric slice argument out
18
+ // (`fetch.statListInYear(2026)`, `fetch.statListInMonth(11, 2026)`), as it does a variable limit, `limit: 0`
19
+ // (the store's one-page mode, which the server warns about itself) and a `limit:` key anywhere else (rate
20
+ // limits). Left over: a slice whose own last two arguments read as `(0, 1000)`, such as a price range; suppress
21
+ // that one with `// biome-ignore lint/plugin: <reason>`.
22
+ pattern over_max_page_size() {
23
+ JsNumberLiteralExpression() as $size where {
24
+ $size <: r"(?:50[1-9]|5[1-9][0-9]|[6-9][0-9]{2}|[1-9][0-9]{0,2}(?:_?[0-9]{3})+|[1-9]e\+?(?:[3-9]|[1-9][0-9]+))"
25
+ }
26
+ }
27
+
28
+ pattern page_skip() {
29
+ or { r"(?:0|null|undefined)", r"(?i)[\s\S]*(?:skip|offset)[\s\S]*", r"(?i)[\s\S]*page[\s\S]*\*[\s\S]*" }
30
+ }
31
+
32
+ pattern page_sort() {
33
+ not JsNumberLiteralExpression()
34
+ }
35
+
36
+ JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) where {
37
+ or {
38
+ and {
39
+ $callee <: `fetch.$endpoint`,
40
+ $endpoint <: r"[a-z][A-Za-z0-9_$]*List(?:[A-Z][A-Za-z0-9_$]*)?",
41
+ $args <: or {
42
+ [..., page_skip(), over_max_page_size() as $limit],
43
+ [..., page_skip(), over_max_page_size() as $limit, page_sort()],
44
+ [..., page_skip(), over_max_page_size() as $limit, page_sort(), $_]
45
+ }
46
+ },
47
+ and {
48
+ or {
49
+ and {
50
+ $callee <: `fetch.$endpoint`,
51
+ $endpoint <: or { r"init[A-Z][A-Za-z0-9_$]*", r"get[A-Z][A-Za-z0-9_$]*Init(?:[A-Z][A-Za-z0-9_$]*)?" }
52
+ },
53
+ and { $callee <: `st.do.$action`, $action <: r"(?:init|refresh)[A-Z][A-Za-z0-9_$]*" }
54
+ },
55
+ $args <: some JsObjectExpression(
56
+ members = some JsPropertyObjectMember(
57
+ name = r"(?:limit|\x22limit\x22|'limit')",
58
+ value = over_max_page_size() as $limit
59
+ )
60
+ )
61
+ },
62
+ and {
63
+ $callee <: `st.do.$action`,
64
+ $action <: r"setLimitOf[A-Z][A-Za-z0-9_$]*",
65
+ $args <: [over_max_page_size() as $limit, ...]
66
+ }
67
+ },
68
+ register_diagnostic(
69
+ span = $limit,
70
+ message = "The server serves at most 500 rows per list request and drops the rest without an error, so this list silently stops at row 500. Page through it at 500 or fewer per request (skip/limit, or st.do.loadMoreOf<Model><Suffix>() while hasMoreOf<Model><Suffix>), or compute what the screen needs on the server in an insight or a dedicated endpoint.",
71
+ severity = "error"
72
+ )
73
+ }