@akanjs/devkit 3.1.1-rc.8 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanApp/akanApp.host.test.ts +12 -0
- package/akanApp/akanApp.host.ts +12 -0
- package/akanConfig/akanConfig.test.ts +30 -0
- package/akanConfig/akanConfig.ts +10 -0
- package/akanConfig/productionLockfile.test.ts +31 -0
- package/akanConfig/productionLockfile.ts +2 -0
- package/akanContext.ts +129 -4
- package/applicationBuildRunner.test.ts +17 -0
- package/applicationBuildRunner.ts +3 -1
- package/biome.base.json +4 -1
- package/cloud/cloudUpdates.ts +3 -2
- package/codeAgent/agent/CodeAgent.ts +4 -0
- package/codeAgent/agent/SubagentPool.ts +5 -0
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/agent/codeAgent.test.ts +41 -2
- package/codeAgent/feedback/PreviewFeedback.ts +2 -2
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/index.ts +2 -2
- package/codeAgent/skills/akan-agent/SKILL.md +36 -0
- package/codeAgent/skills/akan-code/SKILL.md +1 -0
- package/codeAgent/skills/akan-debug/SKILL.md +40 -6
- 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/AkanCodePlugins.ts +5 -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 +60 -58
- package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
- package/commandDecorators/workspaceVersionCheck.ts +4 -1
- 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/csrE2e/ssrRegistry.e2e.test.ts +6 -1
- 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/executors.ts +18 -2
- package/index.ts +1 -0
- package/integration/devStability.integration.test.ts +9 -5
- 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/linter.test.ts +12 -0
- package/linter.ts +35 -12
- 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/mobile/desktopRelease.test.ts +94 -23
- package/mobile/desktopRelease.ts +83 -45
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +18 -11
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -6
- package/package.json +3 -2
- package/platformTest/LinuxDockerTarget.ts +3 -0
- package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
- package/preview/PreviewView.ts +219 -0
- package/preview/index.ts +2 -0
- package/preview/previewView.test.ts +76 -0
- package/prompter.ts +7 -0
- package/qualityScanner.test.ts +75 -0
- package/qualityScanner.ts +17 -1
- package/routeSourceValidator.test.ts +11 -2
- package/routeSourceValidator.ts +14 -2
- package/serviceLoopCallScanner.test.ts +56 -0
- package/serviceLoopCallScanner.ts +122 -0
- package/signalMutationIndex.test.ts +15 -0
- package/signalMutationIndex.ts +1 -1
- 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/types.ts +1 -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
- package/codeAgent/feedback/PreviewView.ts +0 -95
- /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
6
|
+
import { ComponentWriteChainScanner } from "./componentWriteChainScanner";
|
|
7
|
+
import { EndpointReach } from "./endpointReach";
|
|
8
|
+
import { MaintainabilityBaseline } from "./maintainabilityBaseline";
|
|
9
|
+
import { MaintainabilityLimits } from "./maintainabilityLimits";
|
|
10
|
+
import { MaintainabilityReport } from "./maintainabilityReport";
|
|
11
|
+
import type { SourceFileInfo } from "./qualityScanner";
|
|
12
|
+
import { ServiceLoopCallScanner } from "./serviceLoopCallScanner";
|
|
13
|
+
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
14
|
+
import { StoreAccessScanner } from "./storeAccessScanner";
|
|
15
|
+
import { StoreWriteScanner } from "./storeWriteScanner";
|
|
16
|
+
import { WritePlacementScanner } from "./writePlacementScanner";
|
|
17
|
+
import { WritingActionIndex } from "./writingActionIndex";
|
|
18
|
+
|
|
19
|
+
export interface MaintainabilityScope {
|
|
20
|
+
type: "app" | "lib";
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MaintainabilityRuleId = (typeof MaintainabilityContract.ruleIds)[number];
|
|
25
|
+
export type DebtRuleId = Exclude<MaintainabilityRuleId, "akan.baseline.growth">;
|
|
26
|
+
export type MaintainabilityCommands = { [rule in MaintainabilityRuleId]?: readonly string[] };
|
|
27
|
+
|
|
28
|
+
export interface SizeViolation {
|
|
29
|
+
kind: "size";
|
|
30
|
+
rule: "akan.size.file" | "akan.size.component";
|
|
31
|
+
key: string;
|
|
32
|
+
file: string;
|
|
33
|
+
line: number;
|
|
34
|
+
lineCount: number;
|
|
35
|
+
maxLines: number;
|
|
36
|
+
component?: string;
|
|
37
|
+
recorded?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MultiWriteViolation {
|
|
41
|
+
kind: "multiWrite";
|
|
42
|
+
rule: "akan.store.multi-write" | "akan.store.root-write-chain" | "akan.component.write-chain";
|
|
43
|
+
key: string;
|
|
44
|
+
file: string;
|
|
45
|
+
line: number;
|
|
46
|
+
owner: string;
|
|
47
|
+
writes: string[];
|
|
48
|
+
writeCount: number;
|
|
49
|
+
recorded?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WritePlacementViolation {
|
|
53
|
+
kind: "writePlacement";
|
|
54
|
+
rule: "akan.write.placement";
|
|
55
|
+
key: string;
|
|
56
|
+
file: string;
|
|
57
|
+
line: number;
|
|
58
|
+
writes: string[];
|
|
59
|
+
recorded?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LoopCallViolation {
|
|
63
|
+
kind: "loopCall";
|
|
64
|
+
rule: "akan.service.call-in-loop";
|
|
65
|
+
key: string;
|
|
66
|
+
file: string;
|
|
67
|
+
line: number;
|
|
68
|
+
owner: string;
|
|
69
|
+
calls: string[];
|
|
70
|
+
recorded?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface StoreAccessViolation {
|
|
74
|
+
kind: "storeAccess";
|
|
75
|
+
rule: "akan.component.store-access";
|
|
76
|
+
key: string;
|
|
77
|
+
file: string;
|
|
78
|
+
line: number;
|
|
79
|
+
component: string;
|
|
80
|
+
accessCount: number;
|
|
81
|
+
maxAccesses: number;
|
|
82
|
+
recorded?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface BaselineGrowth {
|
|
86
|
+
kind: "baselineGrowth";
|
|
87
|
+
rule: "akan.baseline.growth";
|
|
88
|
+
key: string;
|
|
89
|
+
file: string;
|
|
90
|
+
line: number;
|
|
91
|
+
debtRule: DebtRuleId;
|
|
92
|
+
count: number;
|
|
93
|
+
committed?: number;
|
|
94
|
+
reference: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type MaintainabilityViolation =
|
|
98
|
+
| SizeViolation
|
|
99
|
+
| MultiWriteViolation
|
|
100
|
+
| StoreAccessViolation
|
|
101
|
+
| WritePlacementViolation
|
|
102
|
+
| LoopCallViolation;
|
|
103
|
+
export type MaintainabilityFailure = MaintainabilityViolation | BaselineGrowth;
|
|
104
|
+
|
|
105
|
+
export type BaselineSeed = "written" | "kept" | "removed" | "clean";
|
|
106
|
+
|
|
107
|
+
export class MaintainabilityContract {
|
|
108
|
+
static readonly ruleIds = [
|
|
109
|
+
"akan.size.file",
|
|
110
|
+
"akan.size.component",
|
|
111
|
+
"akan.store.multi-write",
|
|
112
|
+
"akan.store.root-write-chain",
|
|
113
|
+
"akan.component.write-chain",
|
|
114
|
+
"akan.component.store-access",
|
|
115
|
+
"akan.write.placement",
|
|
116
|
+
"akan.service.call-in-loop",
|
|
117
|
+
"akan.baseline.growth",
|
|
118
|
+
] as const;
|
|
119
|
+
static readonly #measuredFiles = "{lib,ui,webkit,srvkit,common,plugin}/**/*.{ts,tsx,md}";
|
|
120
|
+
static readonly #defaultBranches = ["upstream/HEAD", "upstream/main", "origin/HEAD", "origin/main", "main", "master"];
|
|
121
|
+
readonly #workspaceRoot: string;
|
|
122
|
+
readonly #scope: MaintainabilityScope;
|
|
123
|
+
readonly #libNames: string[];
|
|
124
|
+
|
|
125
|
+
constructor(workspaceRoot: string, scope: MaintainabilityScope, libNames: string[] = []) {
|
|
126
|
+
this.#workspaceRoot = workspaceRoot;
|
|
127
|
+
this.#scope = scope;
|
|
128
|
+
this.#libNames = libNames.filter((libName) => scope.type === "app" || libName !== scope.name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get sysPath() {
|
|
132
|
+
return `${this.#scope.type}s/${this.#scope.name}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get baselinePath() {
|
|
136
|
+
return path.join(this.#workspaceRoot, this.sysPath, MaintainabilityBaseline.fileName);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async measure(): Promise<MaintainabilityViolation[]> {
|
|
140
|
+
const [signalFiles, storeFiles, serviceFiles] = await Promise.all([
|
|
141
|
+
this.#reachedSources("lib/**/*.signal.ts"),
|
|
142
|
+
this.#reachedSources("lib/**/*.store.ts"),
|
|
143
|
+
this.#reachedSources("lib/**/*.service.ts"),
|
|
144
|
+
]);
|
|
145
|
+
const index = new WritingActionIndex(new SignalMutationIndex(signalFiles), storeFiles);
|
|
146
|
+
const reach = new EndpointReach(signalFiles, serviceFiles);
|
|
147
|
+
const files = await MaintainabilityContract.#filesUnder(
|
|
148
|
+
path.join(this.#workspaceRoot, this.sysPath),
|
|
149
|
+
MaintainabilityContract.#measuredFiles,
|
|
150
|
+
);
|
|
151
|
+
const violations = await Promise.all(files.map((file) => this.#measureFile(file, index, reach)));
|
|
152
|
+
return violations.flat().sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async enforce({ fix }: { fix: boolean }): Promise<MaintainabilityFailure[]> {
|
|
156
|
+
const [violations, existing, reference] = await Promise.all([
|
|
157
|
+
this.measure(),
|
|
158
|
+
this.#readBaseline(),
|
|
159
|
+
this.#readReferenceBaseline(),
|
|
160
|
+
]);
|
|
161
|
+
const baseline =
|
|
162
|
+
existing === null ? new MaintainabilityBaseline() : MaintainabilityBaseline.parse(existing, this.baselinePath);
|
|
163
|
+
const tightened = baseline.tightenedBy(violations);
|
|
164
|
+
if (fix && existing !== null) await this.#tighten(baseline, tightened);
|
|
165
|
+
const failures: MaintainabilityFailure[] = violations
|
|
166
|
+
.filter((violation) => !baseline.allows(violation))
|
|
167
|
+
.map((violation) => {
|
|
168
|
+
const recorded = baseline.recordedOf(violation);
|
|
169
|
+
return recorded === undefined ? violation : { ...violation, recorded };
|
|
170
|
+
});
|
|
171
|
+
if (reference === null) return failures;
|
|
172
|
+
const lines = ((await this.#readBaseline()) ?? "").split(/\r?\n/);
|
|
173
|
+
const label = `${reference.ref}:${this.sysPath}/${MaintainabilityBaseline.fileName}`;
|
|
174
|
+
//? Without fix the file on disk is what merges, so an entry it holds above the measured count is checked as written.
|
|
175
|
+
const growths = (fix ? tightened : baseline)
|
|
176
|
+
.grownFrom(MaintainabilityBaseline.parse(reference.text, label, { lenient: true }))
|
|
177
|
+
.map(
|
|
178
|
+
({ rule, ...growth }): BaselineGrowth => ({
|
|
179
|
+
kind: "baselineGrowth",
|
|
180
|
+
rule: "akan.baseline.growth",
|
|
181
|
+
file: MaintainabilityBaseline.fileName,
|
|
182
|
+
line: MaintainabilityContract.#lineOf(lines, rule, growth.key),
|
|
183
|
+
debtRule: rule,
|
|
184
|
+
reference: reference.ref,
|
|
185
|
+
...growth,
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
.sort((a, b) => a.line - b.line);
|
|
189
|
+
return [...growths, ...failures];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async seed({ force = false }: { force?: boolean } = {}): Promise<{
|
|
193
|
+
result: BaselineSeed;
|
|
194
|
+
violations: MaintainabilityViolation[];
|
|
195
|
+
}> {
|
|
196
|
+
const violations = await this.measure();
|
|
197
|
+
const exists = existsSync(this.baselinePath);
|
|
198
|
+
if (exists && !force) return { result: "kept", violations };
|
|
199
|
+
const next = MaintainabilityBaseline.of(violations, MaintainabilityContract.#reasonsIn(await this.#readBaseline()));
|
|
200
|
+
if (!next.isEmpty) await writeFile(this.baselinePath, next.stringify());
|
|
201
|
+
else if (exists) await rm(this.baselinePath, { force: true });
|
|
202
|
+
return { result: next.isEmpty ? (exists ? "removed" : "clean") : "written", violations };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe(failures: MaintainabilityFailure[], commands?: MaintainabilityCommands) {
|
|
206
|
+
return MaintainabilityReport.describe(this.sysPath, failures, commands);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async #measureFile(
|
|
210
|
+
file: string,
|
|
211
|
+
index: WritingActionIndex,
|
|
212
|
+
reach: EndpointReach,
|
|
213
|
+
): Promise<MaintainabilityViolation[]> {
|
|
214
|
+
const maxLines = MaintainabilityLimits.fileMaxLinesOf(file);
|
|
215
|
+
const isStore = file.endsWith(".store.ts");
|
|
216
|
+
const isService = file.endsWith(".service.ts");
|
|
217
|
+
const isClientCode = MaintainabilityLimits.isClientCode(file);
|
|
218
|
+
if (!maxLines && !isStore && !isClientCode) return [];
|
|
219
|
+
const workspaceFile = `${this.sysPath}/${file}`;
|
|
220
|
+
const content = await readFile(path.join(this.#workspaceRoot, workspaceFile), "utf8");
|
|
221
|
+
const violations: MaintainabilityViolation[] = [];
|
|
222
|
+
const lineCount = MaintainabilityLimits.lineCountOf(content);
|
|
223
|
+
if (maxLines && lineCount > maxLines && !MaintainabilityLimits.isReexportBarrel(file, content))
|
|
224
|
+
violations.push({ kind: "size", rule: "akan.size.file", key: file, file, line: 1, lineCount, maxLines });
|
|
225
|
+
if (!isStore && !isService && !isClientCode) return violations;
|
|
226
|
+
const source = this.#sourceOf(workspaceFile, content);
|
|
227
|
+
if (isClientCode) violations.push(...MaintainabilityContract.#measureClientCode(file, source, index));
|
|
228
|
+
if (isStore)
|
|
229
|
+
for (const { action, line, writes, writeCount, isRootChain } of new StoreWriteScanner(index).scan(source))
|
|
230
|
+
violations.push({
|
|
231
|
+
kind: "multiWrite",
|
|
232
|
+
rule: isRootChain ? "akan.store.root-write-chain" : "akan.store.multi-write",
|
|
233
|
+
key: `${file}#${action}`,
|
|
234
|
+
file,
|
|
235
|
+
line,
|
|
236
|
+
owner: action,
|
|
237
|
+
writes,
|
|
238
|
+
writeCount,
|
|
239
|
+
});
|
|
240
|
+
if (isService)
|
|
241
|
+
for (const { method, line, calls } of new ServiceLoopCallScanner().scan(source.sourceFile, (method) =>
|
|
242
|
+
reach.reaches(EndpointReach.serviceOf(file), method),
|
|
243
|
+
))
|
|
244
|
+
violations.push({
|
|
245
|
+
kind: "loopCall",
|
|
246
|
+
rule: "akan.service.call-in-loop",
|
|
247
|
+
key: `${file}#${method}`,
|
|
248
|
+
file,
|
|
249
|
+
line,
|
|
250
|
+
owner: method,
|
|
251
|
+
calls,
|
|
252
|
+
});
|
|
253
|
+
return violations;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
static #measureClientCode(
|
|
257
|
+
file: string,
|
|
258
|
+
{ file: workspaceFile, sourceFile }: SourceFileInfo,
|
|
259
|
+
index: WritingActionIndex,
|
|
260
|
+
) {
|
|
261
|
+
const violations: MaintainabilityViolation[] = [];
|
|
262
|
+
const placement = new WritePlacementScanner(index).scan(file, workspaceFile, sourceFile);
|
|
263
|
+
if (placement)
|
|
264
|
+
violations.push({ kind: "writePlacement", rule: "akan.write.placement", key: file, file, ...placement });
|
|
265
|
+
for (const component of new ComponentSizeScanner().measure(sourceFile))
|
|
266
|
+
if (component.lineCount > MaintainabilityLimits.componentMaxLines)
|
|
267
|
+
violations.push({
|
|
268
|
+
kind: "size",
|
|
269
|
+
rule: "akan.size.component",
|
|
270
|
+
key: `${file}#${component.name}`,
|
|
271
|
+
file,
|
|
272
|
+
line: component.line,
|
|
273
|
+
lineCount: component.lineCount,
|
|
274
|
+
maxLines: MaintainabilityLimits.componentMaxLines,
|
|
275
|
+
component: component.name,
|
|
276
|
+
});
|
|
277
|
+
for (const { component, line, writes, writeCount } of new ComponentWriteChainScanner(index).scan(sourceFile))
|
|
278
|
+
violations.push({
|
|
279
|
+
kind: "multiWrite",
|
|
280
|
+
rule: "akan.component.write-chain",
|
|
281
|
+
key: `${file}#${component}`,
|
|
282
|
+
file,
|
|
283
|
+
line,
|
|
284
|
+
owner: component,
|
|
285
|
+
writes,
|
|
286
|
+
writeCount,
|
|
287
|
+
});
|
|
288
|
+
for (const { component, line, accessCount } of new StoreAccessScanner(index).measure(sourceFile))
|
|
289
|
+
if (accessCount > MaintainabilityLimits.componentMaxStoreAccesses)
|
|
290
|
+
violations.push({
|
|
291
|
+
kind: "storeAccess",
|
|
292
|
+
rule: "akan.component.store-access",
|
|
293
|
+
key: `${file}#${component}`,
|
|
294
|
+
file,
|
|
295
|
+
line,
|
|
296
|
+
component,
|
|
297
|
+
accessCount,
|
|
298
|
+
maxAccesses: MaintainabilityLimits.componentMaxStoreAccesses,
|
|
299
|
+
});
|
|
300
|
+
return violations;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async #reachedSources(pattern: string) {
|
|
304
|
+
const roots = [this.sysPath, ...this.#libNames.map((libName) => `libs/${libName}`)];
|
|
305
|
+
const files = await Promise.all(
|
|
306
|
+
roots.map(async (root) =>
|
|
307
|
+
(await MaintainabilityContract.#filesUnder(path.join(this.#workspaceRoot, root), pattern)).map(
|
|
308
|
+
(file) => `${root}/${file}`,
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
return await Promise.all(
|
|
313
|
+
files
|
|
314
|
+
.flat()
|
|
315
|
+
.map(async (file) => this.#sourceOf(file, await readFile(path.join(this.#workspaceRoot, file), "utf8"))),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#sourceOf(file: string, content: string): SourceFileInfo {
|
|
320
|
+
const scriptKind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
321
|
+
return {
|
|
322
|
+
file,
|
|
323
|
+
absolutePath: path.join(this.#workspaceRoot, file),
|
|
324
|
+
content,
|
|
325
|
+
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, scriptKind),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async #readBaseline() {
|
|
330
|
+
return existsSync(this.baselinePath) ? await readFile(this.baselinePath, "utf8") : null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
//? `./` resolves the path from the workspace root, which may sit below the repository root.
|
|
334
|
+
async #readReferenceBaseline() {
|
|
335
|
+
const ref = await this.#referenceRef();
|
|
336
|
+
const text = await this.#git(["show", `${ref}:./${this.sysPath}/${MaintainabilityBaseline.fileName}`]);
|
|
337
|
+
return text === null ? null : { ref: ref === "HEAD" ? ref : ref.slice(0, 12), text };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
//* A branch's own commits cannot be the reference: one that raises an entry would make the raise the new floor.
|
|
341
|
+
//* The fork point from the default branch holds still, whatever the branch commits.
|
|
342
|
+
//* Without that history the only copy left is the branch's own, so a shallow checkout fails instead of passing.
|
|
343
|
+
async #referenceRef() {
|
|
344
|
+
for (const branch of MaintainabilityContract.#defaultBranches) {
|
|
345
|
+
if ((await this.#git(["rev-parse", "--verify", "--quiet", `${branch}^{commit}`])) === null) continue;
|
|
346
|
+
const forkPoint = (await this.#git(["merge-base", "HEAD", branch]))?.trim();
|
|
347
|
+
if (forkPoint) return forkPoint;
|
|
348
|
+
throw new Error(MaintainabilityContract.#missingHistory(this.sysPath, `HEAD shares no commit with ${branch}`));
|
|
349
|
+
}
|
|
350
|
+
if ((await this.#git(["rev-parse", "--is-shallow-repository"]))?.trim() === "true")
|
|
351
|
+
throw new Error(MaintainabilityContract.#missingHistory(this.sysPath, "this is a shallow clone"));
|
|
352
|
+
return "HEAD";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
static #missingHistory(sysPath: string, cause: string) {
|
|
356
|
+
return (
|
|
357
|
+
`${sysPath}/${MaintainabilityBaseline.fileName} is compared with its copy where this branch forked from the default branch, ` +
|
|
358
|
+
`but ${cause}. Fetch the default branch with its history (in GitHub Actions, actions/checkout with fetch-depth: 0).`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async #git(args: string[]) {
|
|
363
|
+
try {
|
|
364
|
+
const proc = Bun.spawn(["git", ...args], { cwd: this.#workspaceRoot, stdout: "pipe", stderr: "ignore" });
|
|
365
|
+
const [text, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
366
|
+
return exitCode === 0 ? text : null;
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
static #reasonsIn(text: string | null) {
|
|
373
|
+
if (text === null) return new Map<string, string>();
|
|
374
|
+
try {
|
|
375
|
+
return MaintainabilityBaseline.parse(text, MaintainabilityBaseline.fileName, { lenient: true }).reasons;
|
|
376
|
+
} catch {
|
|
377
|
+
//? A file too broken to read is what `--force` replaces; its reasons go with it.
|
|
378
|
+
return new Map<string, string>();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async #tighten(current: MaintainabilityBaseline, next: MaintainabilityBaseline) {
|
|
383
|
+
if (next.isEmpty) await rm(this.baselinePath, { force: true });
|
|
384
|
+
else if (next.stringify() !== current.stringify()) await writeFile(this.baselinePath, next.stringify());
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
static #lineOf(lines: string[], rule: DebtRuleId, key: string) {
|
|
388
|
+
const from = Math.max(
|
|
389
|
+
lines.findIndex((line) => line.includes(`${JSON.stringify(rule)}:`)),
|
|
390
|
+
0,
|
|
391
|
+
);
|
|
392
|
+
const idx = lines.findIndex((line, lineIdx) => lineIdx >= from && line.includes(`${JSON.stringify(key)}:`));
|
|
393
|
+
return Math.max(idx + 1, 1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
static async #filesUnder(root: string, pattern: string) {
|
|
397
|
+
if (!existsSync(root)) return [];
|
|
398
|
+
const files = await Array.fromAsync(new Bun.Glob(pattern).scan({ cwd: root, onlyFiles: true }));
|
|
399
|
+
return files
|
|
400
|
+
.map((file) => file.split(path.sep).join("/"))
|
|
401
|
+
.filter((file) => !file.includes("node_modules/"))
|
|
402
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { MaintainabilityExceptionScanner } from "./maintainabilityExceptionScanner";
|
|
4
|
+
import { tempDirs, writeText } from "./testHelpers";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-maintainability-exceptions-");
|
|
7
|
+
|
|
8
|
+
const makeWorkspace = async (files: Record<string, string>) => {
|
|
9
|
+
const root = await makeTempRoot();
|
|
10
|
+
for (const [filePath, content] of Object.entries(files)) await writeText(path.join(root, filePath), content);
|
|
11
|
+
return root;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const baselineOf = (json: object) => `${JSON.stringify(json, null, 2)}\n`;
|
|
15
|
+
const reason = "The court canvas is one pointer state machine.";
|
|
16
|
+
|
|
17
|
+
describe("MaintainabilityExceptionScanner", () => {
|
|
18
|
+
test("warns once per app or lib and rule, counting reasoned exceptions apart from adopted debt", async () => {
|
|
19
|
+
const root = await makeWorkspace({
|
|
20
|
+
"apps/demo/akan.baseline.json": baselineOf({
|
|
21
|
+
"akan.size.file": {
|
|
22
|
+
"lib/court/Court.Zone.tsx": 450,
|
|
23
|
+
"ui/Board.tsx": 420,
|
|
24
|
+
"webkit/useBoard.tsx": 410,
|
|
25
|
+
},
|
|
26
|
+
"akan.size.component": { "lib/court/Court.Zone.tsx#Canvas": 300 },
|
|
27
|
+
"akan.component.store-access": { "lib/court/Court.Zone.tsx#Canvas": 30 },
|
|
28
|
+
"akan.store.multi-write": { "lib/court/court.store.ts#assign": 2 },
|
|
29
|
+
"akan.component.write-chain": { "lib/court/Court.Zone.tsx#Install": 2 },
|
|
30
|
+
"akan.write.placement": { "ui/Board.tsx": 1 },
|
|
31
|
+
"akan.service.call-in-loop": { "lib/court/court.service.ts#syncAll": 1 },
|
|
32
|
+
reasons: {
|
|
33
|
+
"lib/court/Court.Zone.tsx": reason,
|
|
34
|
+
"ui/Board.tsx": "Generated SVG path table.",
|
|
35
|
+
"webkit/useBoard.tsx": "too short",
|
|
36
|
+
"lib/court/court.store.ts#assign": reason,
|
|
37
|
+
"lib/court/court.service.ts#syncAll": reason,
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
"libs/shared/akan.baseline.json": baselineOf({ "akan.size.file": { "ui/Table.tsx": 500 } }),
|
|
41
|
+
"libs/util/akan.baseline.json": baselineOf({}),
|
|
42
|
+
"libs/broken/akan.baseline.json": "{",
|
|
43
|
+
"apps/clean/main.ts": "export {};\n",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const warnings = await new MaintainabilityExceptionScanner().scan(root);
|
|
47
|
+
const byRule = (file: string, rule: string) =>
|
|
48
|
+
warnings.find((warning) => warning.file === file && warning.message.includes(`for ${rule}:`));
|
|
49
|
+
|
|
50
|
+
expect(warnings.map(({ rule }) => rule).every((rule) => rule === MaintainabilityExceptionScanner.rule)).toBe(true);
|
|
51
|
+
expect(warnings).toHaveLength(8);
|
|
52
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.size.file")).toMatchObject({
|
|
53
|
+
scope: "file",
|
|
54
|
+
severity: "warning",
|
|
55
|
+
line: 3,
|
|
56
|
+
message:
|
|
57
|
+
`apps/demo carries 2 reasoned exceptions and 1 adopted-debt entry for akan.size.file: ` +
|
|
58
|
+
`lib/court/Court.Zone.tsx (${JSON.stringify(reason)}), ui/Board.tsx ("Generated SVG path table."), webkit/useBoard.tsx.`,
|
|
59
|
+
locations: [
|
|
60
|
+
{ file: "apps/demo/akan.baseline.json", line: 3 },
|
|
61
|
+
{ file: "apps/demo/akan.baseline.json", line: 4 },
|
|
62
|
+
{ file: "apps/demo/akan.baseline.json", line: 5 },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.size.component")?.message).toBe(
|
|
66
|
+
"apps/demo carries 0 reasoned exceptions and 1 adopted-debt entry for akan.size.component: lib/court/Court.Zone.tsx#Canvas.",
|
|
67
|
+
);
|
|
68
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.component.store-access")?.line).toBe(11);
|
|
69
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.component.store-access")?.message).toContain(
|
|
70
|
+
"0 reasoned exceptions and 1 adopted-debt entry",
|
|
71
|
+
);
|
|
72
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.store.multi-write")?.message).toBe(
|
|
73
|
+
"apps/demo carries 0 reasoned exceptions and 1 adopted-debt entry for akan.store.multi-write: lib/court/court.store.ts#assign.",
|
|
74
|
+
);
|
|
75
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.component.write-chain")?.message).toContain(
|
|
76
|
+
"lib/court/Court.Zone.tsx#Install",
|
|
77
|
+
);
|
|
78
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.write.placement")?.message).toBe(
|
|
79
|
+
"apps/demo carries 0 reasoned exceptions and 1 adopted-debt entry for akan.write.placement: ui/Board.tsx.",
|
|
80
|
+
);
|
|
81
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.service.call-in-loop")?.message).toBe(
|
|
82
|
+
`apps/demo carries 1 reasoned exception and 0 adopted-debt entries for akan.service.call-in-loop: lib/court/court.service.ts#syncAll (${JSON.stringify(reason)}).`,
|
|
83
|
+
);
|
|
84
|
+
expect(byRule("libs/shared/akan.baseline.json", "akan.size.file")?.message).toBe(
|
|
85
|
+
"libs/shared carries 0 reasoned exceptions and 1 adopted-debt entry for akan.size.file: ui/Table.tsx.",
|
|
86
|
+
);
|
|
87
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.size.component")?.fix).toContain("threshold is wrong");
|
|
88
|
+
expect(byRule("apps/demo/akan.baseline.json", "akan.store.multi-write")?.fix).toContain("add-mutation");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { type BaselineEntry, MaintainabilityBaseline } from "./maintainabilityBaseline";
|
|
5
|
+
import type { DebtRuleId } from "./maintainabilityContract";
|
|
6
|
+
import type { QualityWarning } from "./qualityScanner";
|
|
7
|
+
|
|
8
|
+
export class MaintainabilityExceptionScanner {
|
|
9
|
+
static readonly rule = "akan.baseline.exceptions";
|
|
10
|
+
static readonly #shapeFix =
|
|
11
|
+
"Many reasoned exceptions under one rule mean its threshold is wrong: fix the limit in MaintainabilityLimits rather than record another. Otherwise shrink the code, and `akan lint` lowers each entry and drops it once it passes.";
|
|
12
|
+
static readonly #correctnessFix =
|
|
13
|
+
"A write rule takes no exception: send the writes through one store action and one endpoint (`akan workflow plan add-mutation`), and `akan lint` drops the entry.";
|
|
14
|
+
|
|
15
|
+
async scan(workspaceRoot: string): Promise<QualityWarning[]> {
|
|
16
|
+
const sysPaths = await Promise.all(
|
|
17
|
+
["apps", "libs"].map(async (sysRoot) => {
|
|
18
|
+
const absoluteRoot = path.join(workspaceRoot, sysRoot);
|
|
19
|
+
if (!existsSync(absoluteRoot)) return [];
|
|
20
|
+
const entries = await readdir(absoluteRoot, { withFileTypes: true });
|
|
21
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => `${sysRoot}/${entry.name}`);
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
const warnings = await Promise.all(
|
|
25
|
+
sysPaths.flat().map((sysPath) => MaintainabilityExceptionScanner.#scanSys(workspaceRoot, sysPath)),
|
|
26
|
+
);
|
|
27
|
+
return warnings.flat();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static async #scanSys(workspaceRoot: string, sysPath: string): Promise<QualityWarning[]> {
|
|
31
|
+
const file = `${sysPath}/${MaintainabilityBaseline.fileName}`;
|
|
32
|
+
const absolutePath = path.join(workspaceRoot, file);
|
|
33
|
+
if (!existsSync(absolutePath)) return [];
|
|
34
|
+
const text = await readFile(absolutePath, "utf8");
|
|
35
|
+
let baseline: MaintainabilityBaseline;
|
|
36
|
+
try {
|
|
37
|
+
baseline = MaintainabilityBaseline.parse(text, file);
|
|
38
|
+
} catch {
|
|
39
|
+
//? `akan lint` names a malformed baseline and how it breaks; the scan reports only the ones it can read.
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const lines = text.split(/\r?\n/);
|
|
43
|
+
return [...baseline.entriesByRule()].map(([rule, entries]) =>
|
|
44
|
+
MaintainabilityExceptionScanner.#warningOf(sysPath, file, lines, rule, entries),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static #warningOf(
|
|
49
|
+
sysPath: string,
|
|
50
|
+
file: string,
|
|
51
|
+
lines: string[],
|
|
52
|
+
rule: DebtRuleId,
|
|
53
|
+
entries: BaselineEntry[],
|
|
54
|
+
): QualityWarning {
|
|
55
|
+
const reasonedNum = entries.filter(({ reason }) => reason !== undefined).length;
|
|
56
|
+
const adoptedNum = entries.length - reasonedNum;
|
|
57
|
+
const listed = entries.map(({ key, reason }) =>
|
|
58
|
+
reason === undefined ? key : `${key} (${JSON.stringify(reason)})`,
|
|
59
|
+
);
|
|
60
|
+
const locations = entries.map(({ key }) => ({
|
|
61
|
+
file,
|
|
62
|
+
line: MaintainabilityExceptionScanner.#lineOf(lines, rule, key),
|
|
63
|
+
}));
|
|
64
|
+
return {
|
|
65
|
+
rule: MaintainabilityExceptionScanner.rule,
|
|
66
|
+
scope: "file",
|
|
67
|
+
severity: "warning",
|
|
68
|
+
file,
|
|
69
|
+
line: locations[0]?.line ?? 1,
|
|
70
|
+
message: `${sysPath} carries ${reasonedNum} reasoned exception${reasonedNum === 1 ? "" : "s"} and ${adoptedNum} adopted-debt entr${adoptedNum === 1 ? "y" : "ies"} for ${rule}: ${listed.join(", ")}.`,
|
|
71
|
+
locations,
|
|
72
|
+
fix: MaintainabilityBaseline.reasonedRules.has(rule)
|
|
73
|
+
? MaintainabilityExceptionScanner.#shapeFix
|
|
74
|
+
: MaintainabilityExceptionScanner.#correctnessFix,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static #lineOf(lines: string[], rule: DebtRuleId, key: string) {
|
|
79
|
+
const from = Math.max(
|
|
80
|
+
lines.findIndex((line) => line.includes(`${JSON.stringify(rule)}:`)),
|
|
81
|
+
0,
|
|
82
|
+
);
|
|
83
|
+
return Math.max(lines.findIndex((line, idx) => idx >= from && line.includes(`${JSON.stringify(key)}:`)) + 1, 1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { AbstractDoc } from "./abstractDoc";
|
|
4
|
+
|
|
5
|
+
export type FileKind =
|
|
6
|
+
| keyof typeof MaintainabilityLimits.fileMaxLines
|
|
7
|
+
| keyof typeof MaintainabilityLimits.folderMaxLines;
|
|
8
|
+
|
|
9
|
+
export class MaintainabilityLimits {
|
|
10
|
+
static readonly fileMaxLines = {
|
|
11
|
+
".Template.tsx": 400,
|
|
12
|
+
".Unit.tsx": 400,
|
|
13
|
+
".Util.tsx": 400,
|
|
14
|
+
".View.tsx": 400,
|
|
15
|
+
".Zone.tsx": 400,
|
|
16
|
+
".store.ts": 400,
|
|
17
|
+
".document.ts": 500,
|
|
18
|
+
".service.ts": 500,
|
|
19
|
+
[AbstractDoc.suffix]: AbstractDoc.maxLines,
|
|
20
|
+
} as const;
|
|
21
|
+
static readonly folderMaxLines = { "ui/": 400, "webkit/": 400 } as const;
|
|
22
|
+
static readonly #maxLinesByKind = { ...MaintainabilityLimits.fileMaxLines, ...MaintainabilityLimits.folderMaxLines };
|
|
23
|
+
static readonly componentMaxLines = 250;
|
|
24
|
+
static readonly componentMaxStoreAccesses = 16;
|
|
25
|
+
|
|
26
|
+
static fileKindOf(sysRelativePath: string): FileKind | null {
|
|
27
|
+
const kinds = Object.keys(MaintainabilityLimits.#maxLinesByKind) as FileKind[];
|
|
28
|
+
const suffix = kinds.find((kind) => sysRelativePath.endsWith(kind));
|
|
29
|
+
if (suffix) return suffix;
|
|
30
|
+
if (!/\.tsx?$/.test(sysRelativePath) || MaintainabilityLimits.#isTest(sysRelativePath)) return null;
|
|
31
|
+
return kinds.find((kind) => sysRelativePath.startsWith(kind)) ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static fileMaxLinesOf(sysRelativePath: string) {
|
|
35
|
+
const kind = MaintainabilityLimits.fileKindOf(sysRelativePath);
|
|
36
|
+
return kind ? MaintainabilityLimits.#maxLinesByKind[kind] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static isReexportBarrel(file: string, content: string) {
|
|
40
|
+
if (path.posix.basename(file) !== "index.ts") return false;
|
|
41
|
+
return ts
|
|
42
|
+
.createSourceFile(file, content, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
|
|
43
|
+
.statements.every((statement) => ts.isExportDeclaration(statement));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static lineCountOf(content: string) {
|
|
47
|
+
return AbstractDoc.lineCountOf(content);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
//? Under `lib/` only the `.tsx` components run in the browser; `ui/` and `webkit/` run there whole.
|
|
51
|
+
static isClientCode(sysRelativePath: string) {
|
|
52
|
+
if (MaintainabilityLimits.#isTest(sysRelativePath)) return false;
|
|
53
|
+
if (sysRelativePath.startsWith("lib/")) return sysRelativePath.endsWith(".tsx");
|
|
54
|
+
return /^(ui|webkit)\/.+\.tsx?$/.test(sysRelativePath) && !sysRelativePath.endsWith(".d.ts");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static #isTest(sysRelativePath: string) {
|
|
58
|
+
return /\.(test|spec)\.tsx?$/.test(sysRelativePath);
|
|
59
|
+
}
|
|
60
|
+
}
|