@akanjs/devkit 3.1.1-rc.15 → 3.1.1-rc.17
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.
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanContext.ts +44 -2
- package/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +3 -1
- package/biome.base.json +4 -1
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/skills/akan-agent/SKILL.md +36 -0
- package/codeAgent/skills/akan-debug/SKILL.md +3 -3
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +38 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/akanCodePlugins.test.ts +12 -30
- package/commentDensityScanner.test.ts +121 -0
- package/commentDensityScanner.ts +101 -0
- package/componentSizeScanner.test.ts +36 -0
- package/componentSizeScanner.ts +52 -0
- package/componentWriteChainScanner.test.ts +137 -0
- package/componentWriteChainScanner.ts +104 -0
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/deprecatedCommentScanner.test.ts +54 -0
- package/deprecatedCommentScanner.ts +30 -0
- package/endpointReach.test.ts +58 -0
- package/endpointReach.ts +80 -0
- package/executors.test.ts +0 -4
- package/index.ts +1 -0
- package/kindBranchScanner.test.ts +89 -0
- package/kindBranchScanner.ts +158 -0
- package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
- package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
- package/lint/no-oversized-list-limit.grit +73 -0
- package/maintainabilityBaseline.test.ts +280 -0
- package/maintainabilityBaseline.ts +220 -0
- package/maintainabilityContract.test.ts +846 -0
- package/maintainabilityContract.ts +404 -0
- package/maintainabilityExceptionScanner.test.ts +90 -0
- package/maintainabilityExceptionScanner.ts +85 -0
- package/maintainabilityLimits.ts +60 -0
- package/maintainabilityReport.test.ts +130 -0
- package/maintainabilityReport.ts +153 -0
- package/package.json +2 -2
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/sourceComments.test.ts +45 -0
- package/sourceComments.ts +70 -0
- package/storeAccessScanner.test.ts +84 -0
- package/storeAccessScanner.ts +71 -0
- package/storeSource.ts +159 -0
- package/storeWriteScanner.test.ts +292 -0
- package/storeWriteScanner.ts +50 -0
- package/testOnlyExportScanner.test.ts +83 -0
- package/testOnlyExportScanner.ts +124 -0
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/workflow/artifacts.ts +16 -2
- package/workflow/executor.ts +22 -7
- package/workflow/guardCatalog.test.ts +59 -0
- package/workflow/guardCatalog.ts +108 -0
- package/workflow/index.ts +4 -0
- package/workflow/interactionScaffold.ts +122 -0
- package/workflow/mutationScaffold.ts +519 -0
- package/workflow/mutationSource.test.ts +108 -0
- package/workflow/mutationSource.ts +219 -0
- package/workflow/plan.ts +54 -0
- package/workflow/render.ts +19 -4
- package/workflow/source.test.ts +185 -0
- package/workflow/source.ts +151 -24
- package/workflow/types.ts +18 -1
- package/workspaceLayout.ts +2 -0
- package/writePath.test.ts +109 -0
- package/writePath.ts +219 -0
- package/writePlacementScanner.test.ts +77 -0
- package/writePlacementScanner.ts +50 -0
- package/writingActionIndex.test.ts +99 -0
- package/writingActionIndex.ts +63 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MaintainabilityBaseline } from "./maintainabilityBaseline";
|
|
3
|
+
import { type DebtRuleId, MaintainabilityContract, type MaintainabilityViolation } from "./maintainabilityContract";
|
|
4
|
+
|
|
5
|
+
const sizeOf = (key: string, lineCount: number): MaintainabilityViolation => ({
|
|
6
|
+
kind: "size",
|
|
7
|
+
rule: key.includes("#") ? "akan.size.component" : "akan.size.file",
|
|
8
|
+
key,
|
|
9
|
+
file: key.split("#")[0] ?? key,
|
|
10
|
+
line: 1,
|
|
11
|
+
lineCount,
|
|
12
|
+
maxLines: 400,
|
|
13
|
+
});
|
|
14
|
+
const writeOf = (key: string, writeCount = 2): MaintainabilityViolation => ({
|
|
15
|
+
kind: "multiWrite",
|
|
16
|
+
rule: "akan.store.multi-write",
|
|
17
|
+
key,
|
|
18
|
+
file: key.split("#")[0] ?? key,
|
|
19
|
+
line: 1,
|
|
20
|
+
owner: key.split("#")[1] ?? "",
|
|
21
|
+
writes: [],
|
|
22
|
+
writeCount,
|
|
23
|
+
});
|
|
24
|
+
const accessOf = (key: string, accessCount: number): MaintainabilityViolation => ({
|
|
25
|
+
kind: "storeAccess",
|
|
26
|
+
rule: "akan.component.store-access",
|
|
27
|
+
key,
|
|
28
|
+
file: key.split("#")[0] ?? key,
|
|
29
|
+
line: 1,
|
|
30
|
+
component: key.split("#")[1] ?? "",
|
|
31
|
+
accessCount,
|
|
32
|
+
maxAccesses: 16,
|
|
33
|
+
});
|
|
34
|
+
const placementOf = (file: string, writes: string[]): MaintainabilityViolation => ({
|
|
35
|
+
kind: "writePlacement",
|
|
36
|
+
rule: "akan.write.placement",
|
|
37
|
+
key: file,
|
|
38
|
+
file,
|
|
39
|
+
line: 1,
|
|
40
|
+
writes,
|
|
41
|
+
});
|
|
42
|
+
const baselineOf = (
|
|
43
|
+
sections: { [rule in DebtRuleId]?: Record<string, number> },
|
|
44
|
+
reasons: Record<string, string> = {},
|
|
45
|
+
) =>
|
|
46
|
+
new MaintainabilityBaseline(
|
|
47
|
+
new Map(Object.entries(sections).map(([rule, counts]) => [rule as DebtRuleId, new Map(Object.entries(counts))])),
|
|
48
|
+
new Map(Object.entries(reasons)),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
describe("MaintainabilityBaseline", () => {
|
|
52
|
+
test("keeps one section per rule the gate can fail, the growth check aside", () => {
|
|
53
|
+
expect(new Set(MaintainabilityBaseline.debtRules)).toEqual(
|
|
54
|
+
new Set(MaintainabilityContract.ruleIds.filter((rule) => rule !== "akan.baseline.growth")),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("allows a recorded entry up to its count, writes included, and nothing it does not record", () => {
|
|
59
|
+
const baseline = MaintainabilityBaseline.parse(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900 },
|
|
62
|
+
"akan.store.multi-write": { "lib/a/a.store.ts#run": 2 },
|
|
63
|
+
"akan.write.placement": { "ui/Board.tsx": 1 },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
expect(baseline.allows(sizeOf("lib/a/A.Zone.tsx", 900))).toBe(true);
|
|
67
|
+
expect(baseline.allows(sizeOf("lib/a/A.Zone.tsx", 901))).toBe(false);
|
|
68
|
+
expect(baseline.allows(sizeOf("lib/b/B.Zone.tsx", 401))).toBe(false);
|
|
69
|
+
expect(baseline.allows(writeOf("lib/a/a.store.ts#run"))).toBe(true);
|
|
70
|
+
expect(baseline.allows(writeOf("lib/a/a.store.ts#run", 3))).toBe(false);
|
|
71
|
+
expect(baseline.allows(writeOf("lib/a/a.store.ts#other"))).toBe(false);
|
|
72
|
+
expect(baseline.allows(placementOf("ui/Board.tsx", ["st.do.removeBoard"]))).toBe(true);
|
|
73
|
+
expect(baseline.allows(placementOf("ui/Board.tsx", ["st.do.removeBoard", "fetch.placeBoard"]))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("records the busiest of several breaches under one key", () => {
|
|
77
|
+
const chainOf = (writeCount: number): MaintainabilityViolation => ({
|
|
78
|
+
kind: "multiWrite",
|
|
79
|
+
rule: "akan.component.write-chain",
|
|
80
|
+
key: "lib/a/A.Zone.tsx#Canvas",
|
|
81
|
+
file: "lib/a/A.Zone.tsx",
|
|
82
|
+
line: 1,
|
|
83
|
+
owner: "Canvas",
|
|
84
|
+
writes: [],
|
|
85
|
+
writeCount,
|
|
86
|
+
});
|
|
87
|
+
const baseline = MaintainabilityBaseline.of([chainOf(2), chainOf(4)]);
|
|
88
|
+
expect([...(baseline.sections.get("akan.component.write-chain") ?? [])]).toEqual([["lib/a/A.Zone.tsx#Canvas", 4]]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("tightens to what still breaks: lowers a shrunk entry, keeps a grown one, drops the rest", () => {
|
|
92
|
+
const baseline = baselineOf({
|
|
93
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900, "lib/b/B.Zone.tsx": 700, "lib/gone/Gone.Zone.tsx": 600 },
|
|
94
|
+
"akan.store.multi-write": { "lib/a/a.store.ts#run": 3, "lib/a/a.store.ts#fixed": 2 },
|
|
95
|
+
});
|
|
96
|
+
const tightened = baseline.tightenedBy([
|
|
97
|
+
sizeOf("lib/a/A.Zone.tsx", 850),
|
|
98
|
+
sizeOf("lib/b/B.Zone.tsx", 720),
|
|
99
|
+
sizeOf("lib/c/C.Zone.tsx", 500),
|
|
100
|
+
writeOf("lib/a/a.store.ts#run", 2),
|
|
101
|
+
]);
|
|
102
|
+
expect([...(tightened.sections.get("akan.size.file") ?? [])]).toEqual([
|
|
103
|
+
["lib/a/A.Zone.tsx", 850],
|
|
104
|
+
["lib/b/B.Zone.tsx", 700],
|
|
105
|
+
]);
|
|
106
|
+
expect([...(tightened.sections.get("akan.store.multi-write") ?? [])]).toEqual([["lib/a/a.store.ts#run", 2]]);
|
|
107
|
+
expect(baseline.tightenedBy([]).isEmpty).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("names every entry a working baseline added or raised over the reference one, per rule", () => {
|
|
111
|
+
const committed = baselineOf({
|
|
112
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900 },
|
|
113
|
+
"akan.store.multi-write": { "a.store.ts#run": 2 },
|
|
114
|
+
});
|
|
115
|
+
const working = baselineOf({
|
|
116
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 950, "lib/b/B.Zone.tsx": 500 },
|
|
117
|
+
"akan.store.multi-write": { "a.store.ts#run": 3, "a.store.ts#sneak": 2 },
|
|
118
|
+
});
|
|
119
|
+
expect(working.grownFrom(committed)).toEqual([
|
|
120
|
+
{ rule: "akan.size.file", key: "lib/a/A.Zone.tsx", count: 950, committed: 900 },
|
|
121
|
+
{ rule: "akan.size.file", key: "lib/b/B.Zone.tsx", count: 500, committed: undefined },
|
|
122
|
+
{ rule: "akan.store.multi-write", key: "a.store.ts#run", count: 3, committed: 2 },
|
|
123
|
+
{ rule: "akan.store.multi-write", key: "a.store.ts#sneak", count: 2, committed: undefined },
|
|
124
|
+
]);
|
|
125
|
+
expect(committed.tightenedBy([sizeOf("lib/a/A.Zone.tsx", 800)]).grownFrom(committed)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("ratchets a component's store accesses like a size: allows up to its entry, lowers it, refuses a raise", () => {
|
|
129
|
+
const baseline = MaintainabilityBaseline.parse(
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
"akan.component.store-access": { "lib/a/A.Zone.tsx#Canvas": 40, "lib/gone/G.Zone.tsx#Old": 30 },
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(baseline.allows(accessOf("lib/a/A.Zone.tsx#Canvas", 40))).toBe(true);
|
|
135
|
+
expect(baseline.allows(accessOf("lib/a/A.Zone.tsx#Canvas", 41))).toBe(false);
|
|
136
|
+
expect(baseline.allows(accessOf("lib/b/B.Zone.tsx#Board", 17))).toBe(false);
|
|
137
|
+
expect(baseline.recordedOf(accessOf("lib/a/A.Zone.tsx#Canvas", 41))).toBe(40);
|
|
138
|
+
|
|
139
|
+
const tightened = baseline.tightenedBy([accessOf("lib/a/A.Zone.tsx#Canvas", 33)]);
|
|
140
|
+
expect([...(tightened.sections.get("akan.component.store-access") ?? [])]).toEqual([
|
|
141
|
+
["lib/a/A.Zone.tsx#Canvas", 33],
|
|
142
|
+
]);
|
|
143
|
+
expect(baseline.grownFrom(tightened)).toEqual([
|
|
144
|
+
{ rule: "akan.component.store-access", key: "lib/a/A.Zone.tsx#Canvas", count: 40, committed: 33 },
|
|
145
|
+
{ rule: "akan.component.store-access", key: "lib/gone/G.Zone.tsx#Old", count: 30, committed: undefined },
|
|
146
|
+
]);
|
|
147
|
+
expect(tightened.grownFrom(baseline)).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("lets a shape entry grow only with a reason of ten non-space characters, a write entry never", () => {
|
|
151
|
+
const committed = baselineOf({
|
|
152
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900 },
|
|
153
|
+
"akan.store.multi-write": { "a.store.ts#run": 2 },
|
|
154
|
+
});
|
|
155
|
+
const reason = "The canvas is one pointer state machine.";
|
|
156
|
+
const working = baselineOf(
|
|
157
|
+
{
|
|
158
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 950, "ui/Board.tsx": 450, "lib/b/B.Zone.tsx": 500 },
|
|
159
|
+
"akan.size.component": { "lib/c/C.Zone.tsx#Grid": 300 },
|
|
160
|
+
"akan.component.store-access": { "lib/d/D.Zone.tsx#Canvas": 30 },
|
|
161
|
+
"akan.store.multi-write": { "a.store.ts#run": 2, "a.store.ts#sneak": 2 },
|
|
162
|
+
"akan.component.write-chain": { "lib/c/C.Zone.tsx#Grid": 2 },
|
|
163
|
+
"akan.write.placement": { "ui/Board.tsx": 1 },
|
|
164
|
+
"akan.service.call-in-loop": { "lib/e/e.service.ts#sync": 1 },
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"lib/a/A.Zone.tsx": reason,
|
|
168
|
+
"ui/Board.tsx": reason,
|
|
169
|
+
"lib/b/B.Zone.tsx": "a b c d e f g h i",
|
|
170
|
+
"lib/c/C.Zone.tsx#Grid": reason,
|
|
171
|
+
"lib/d/D.Zone.tsx#Canvas": reason,
|
|
172
|
+
"a.store.ts#sneak": reason,
|
|
173
|
+
"lib/e/e.service.ts#sync": reason,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(working.grownFrom(committed)).toEqual([
|
|
178
|
+
{ rule: "akan.size.file", key: "lib/b/B.Zone.tsx", count: 500, committed: undefined },
|
|
179
|
+
{ rule: "akan.store.multi-write", key: "a.store.ts#sneak", count: 2, committed: undefined },
|
|
180
|
+
{ rule: "akan.component.write-chain", key: "lib/c/C.Zone.tsx#Grid", count: 2, committed: undefined },
|
|
181
|
+
{ rule: "akan.write.placement", key: "ui/Board.tsx", count: 1, committed: undefined },
|
|
182
|
+
]);
|
|
183
|
+
expect(working.reasonOf("lib/b/B.Zone.tsx")).toBeUndefined();
|
|
184
|
+
expect(working.reasonOf("a.store.ts#sneak")).toBeUndefined();
|
|
185
|
+
expect(working.reasonOf("lib/d/D.Zone.tsx#Canvas")).toBe(reason);
|
|
186
|
+
expect(working.reasonOf("lib/e/e.service.ts#sync")).toBe(reason);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("keeps a reason only beside a shape entry it still describes", () => {
|
|
190
|
+
const reason = "The canvas is one pointer state machine.";
|
|
191
|
+
const baseline = baselineOf(
|
|
192
|
+
{
|
|
193
|
+
"akan.size.file": { "lib/a/A.Zone.tsx": 900, "lib/gone/Gone.Zone.tsx": 600 },
|
|
194
|
+
"akan.component.store-access": { "lib/a/A.Zone.tsx#Canvas": 30 },
|
|
195
|
+
"akan.store.multi-write": { "lib/a/a.store.ts#run": 2 },
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"lib/a/A.Zone.tsx": reason,
|
|
199
|
+
"lib/gone/Gone.Zone.tsx": reason,
|
|
200
|
+
"lib/a/A.Zone.tsx#Canvas": reason,
|
|
201
|
+
"lib/a/a.store.ts#run": reason,
|
|
202
|
+
"lib/never/Recorded.tsx": reason,
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const tightened = baseline.tightenedBy([
|
|
207
|
+
sizeOf("lib/a/A.Zone.tsx", 850),
|
|
208
|
+
accessOf("lib/a/A.Zone.tsx#Canvas", 20),
|
|
209
|
+
writeOf("lib/a/a.store.ts#run"),
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
expect([...(tightened.sections.get("akan.size.file") ?? [])]).toEqual([["lib/a/A.Zone.tsx", 850]]);
|
|
213
|
+
expect([...tightened.reasons]).toEqual([
|
|
214
|
+
["lib/a/A.Zone.tsx", reason],
|
|
215
|
+
["lib/a/A.Zone.tsx#Canvas", reason],
|
|
216
|
+
]);
|
|
217
|
+
expect(baseline.tightenedBy([]).reasons.size).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("prints the rule sections in rule order with sorted keys, as Biome would, and reprints them unchanged", () => {
|
|
221
|
+
const baseline = MaintainabilityBaseline.of([
|
|
222
|
+
writeOf("lib/z/z.store.ts#run"),
|
|
223
|
+
sizeOf("lib/court/Court.Zone.tsx#Canvas", 640),
|
|
224
|
+
sizeOf("lib/court/Court.Zone.tsx", 947),
|
|
225
|
+
writeOf("lib/court/court.store.ts#assignCourtPcOnCourt", 3),
|
|
226
|
+
placementOf("ui/Board.tsx", ["st.do.removeBoard"]),
|
|
227
|
+
]);
|
|
228
|
+
const text = baseline.stringify();
|
|
229
|
+
expect(text).toBe(`{
|
|
230
|
+
"akan.size.file": {
|
|
231
|
+
"lib/court/Court.Zone.tsx": 947
|
|
232
|
+
},
|
|
233
|
+
"akan.size.component": {
|
|
234
|
+
"lib/court/Court.Zone.tsx#Canvas": 640
|
|
235
|
+
},
|
|
236
|
+
"akan.store.multi-write": {
|
|
237
|
+
"lib/court/court.store.ts#assignCourtPcOnCourt": 3,
|
|
238
|
+
"lib/z/z.store.ts#run": 2
|
|
239
|
+
},
|
|
240
|
+
"akan.write.placement": {
|
|
241
|
+
"ui/Board.tsx": 1
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
`);
|
|
245
|
+
expect(MaintainabilityBaseline.parse(text).stringify()).toBe(text);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("prints the reasons last and only once there are some, reprinting them unchanged", () => {
|
|
249
|
+
const text = `{
|
|
250
|
+
"akan.size.file": {
|
|
251
|
+
"lib/a/A.Zone.tsx": 950,
|
|
252
|
+
"ui/Board.tsx": 450
|
|
253
|
+
},
|
|
254
|
+
"reasons": {
|
|
255
|
+
"lib/a/A.Zone.tsx": "The canvas is one pointer state machine.",
|
|
256
|
+
"ui/Board.tsx": "Generated SVG path table for the court layout."
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
`;
|
|
260
|
+
const reasonless = '{\n "akan.size.file": {\n "lib/a/A.Zone.tsx": 950\n }\n}\n';
|
|
261
|
+
|
|
262
|
+
expect(MaintainabilityBaseline.parse(text).stringify()).toBe(text);
|
|
263
|
+
expect(MaintainabilityBaseline.parse(reasonless).stringify()).toBe(reasonless);
|
|
264
|
+
expect(MaintainabilityBaseline.parse('{ "reasons": {} }').stringify()).not.toContain("reasons");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("refuses a file that is not the baseline shape, naming it", () => {
|
|
268
|
+
expect(() => MaintainabilityBaseline.parse("{", "apps/demo/akan.baseline.json")).toThrow(
|
|
269
|
+
"apps/demo/akan.baseline.json is not valid JSON.",
|
|
270
|
+
);
|
|
271
|
+
expect(() => MaintainabilityBaseline.parse("[]")).toThrow("holds an object");
|
|
272
|
+
expect(() => MaintainabilityBaseline.parse('{ "akan.size.file": { "a": "900" } }')).toThrow('"akan.size.file"');
|
|
273
|
+
expect(() => MaintainabilityBaseline.parse('{ "akan.store.multi-write": ["a#b"] }')).toThrow(
|
|
274
|
+
'"akan.store.multi-write"',
|
|
275
|
+
);
|
|
276
|
+
expect(() => MaintainabilityBaseline.parse('{ "maxLines": {} }')).toThrow("akan quality baseline --force");
|
|
277
|
+
expect(() => MaintainabilityBaseline.parse('{ "reasons": { "ui/Board.tsx": 1 } }')).toThrow('"reasons"');
|
|
278
|
+
expect(MaintainabilityBaseline.parse("{}").isEmpty).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
});
|