@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +348 -0
- package/abstractDoc.ts +1 -1
- package/agentAssets.ts +2 -2
- package/akanConfig/akanConfig.ts +2 -2
- package/akanMcpContract.ts +1 -1
- package/apiReference.test.ts +37 -0
- package/apiReference.ts +240 -0
- package/applicationBuildReporter.test.ts +25 -3
- package/applicationBuildReporter.ts +8 -1
- package/applicationBuildRunner.test.ts +34 -14
- package/applicationBuildRunner.ts +76 -14
- package/artifact/implicitRootLayout.test.ts +42 -0
- package/artifact/implicitRootLayout.ts +5 -6
- package/artifact/storeBootstrap.ts +24 -0
- package/cloud/cloudApi.test.ts +70 -7
- package/cloud/cloudApi.ts +10 -4
- package/cloud/constants.ts +3 -0
- package/cloud/globalConfig.test.ts +36 -0
- package/cloud/globalConfig.ts +10 -1
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-page/SKILL.md +16 -7
- package/codeAgent/skills/akan-service/SKILL.md +48 -19
- package/commandDecorators/command.ts +55 -9
- package/commandDecorators/commandDecorators.test.ts +131 -2
- package/declarationReader.ts +211 -0
- package/executors.test.ts +65 -8
- package/executors.ts +5 -3
- package/frameworkGraph.test.ts +121 -0
- package/frameworkGraph.ts +165 -0
- package/frontendBuild/allRoutesBuilder.test.ts +84 -0
- package/frontendBuild/allRoutesBuilder.ts +32 -12
- package/frontendBuild/buildRouteClient.test.ts +192 -2
- package/frontendBuild/bundleDefine.ts +4 -0
- package/frontendBuild/clientBuildTypes.ts +7 -1
- package/frontendBuild/clientEntriesBundler.test.ts +82 -0
- package/frontendBuild/clientEntriesBundler.ts +55 -4
- package/frontendBuild/csrArtifactBuilder.test.ts +23 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/frontendBuild/fontOptimizer.test.ts +54 -0
- package/frontendBuild/fontOptimizer.ts +5 -4
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/routeClientBuilder.ts +6 -2
- package/frontendBuild/routeModulePreloads.test.ts +59 -0
- package/frontendBuild/routeModulePreloads.ts +52 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +35 -10
- package/frontendBuild/ssrDevBundler.test.ts +26 -0
- package/frontendBuild/ssrDevBundler.ts +2 -1
- package/frontendBuild/vendorImportScanner.test.ts +71 -0
- package/frontendBuild/vendorImportScanner.ts +80 -0
- package/frontendBuild/vendorNarrowing.test.ts +103 -0
- package/frontendBuild/vendorNarrowing.ts +64 -0
- package/frontendBuild/vendorSpecifiers.ts +13 -0
- package/gitRevision.ts +6 -2
- package/incrementalBuilder/builderGraphPayloads.ts +60 -0
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +65 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +5 -36
- package/integration/devStability.integration.test.ts +1 -1
- package/integration/devStabilityHarness.test.ts +1 -1
- package/lint/no-deep-internal-import.grit +1 -1
- package/maintainabilityBaseline.ts +7 -2
- package/maintainabilityContract.test.ts +183 -0
- package/maintainabilityContract.ts +145 -30
- package/maintainabilityExceptionScanner.test.ts +13 -2
- package/maintainabilityExceptionScanner.ts +16 -6
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.test.ts +35 -0
- package/maintainabilityReport.ts +29 -8
- package/mobile/nativePublicEnv.test.ts +5 -1
- package/package.json +3 -3
- package/routeJsBudget.test.ts +177 -0
- package/routeJsBudget.ts +222 -0
- package/structureScanner.test.ts +167 -0
- package/structureScanner.ts +164 -0
- package/transforms/barrelAnalyzer.ts +10 -3
- package/transforms/barrelImportsPlugin.ts +13 -7
- package/transforms/serverEnvPlugin.test.ts +1 -1
- package/transforms/serverEnvPlugin.ts +1 -1
- package/transforms/transforms.test.ts +64 -12
- package/transforms/tsconfigPackageResolver.test.ts +12 -0
- package/workflow/plan.ts +79 -17
- package/workflow/types.ts +1 -0
- package/workspaceGraph.test.ts +58 -0
- package/workspaceGraph.ts +32 -3
- package/workspaceLayout.ts +1 -0
- /package/integration/{devStabilityHarness.ts → devStabilityHarness.fixture.ts} +0 -0
|
@@ -5,6 +5,7 @@ import ts from "typescript";
|
|
|
5
5
|
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
6
6
|
import { ComponentWriteChainScanner } from "./componentWriteChainScanner";
|
|
7
7
|
import { EndpointReach } from "./endpointReach";
|
|
8
|
+
import { GitRevision } from "./gitRevision";
|
|
8
9
|
import { MaintainabilityBaseline } from "./maintainabilityBaseline";
|
|
9
10
|
import { MaintainabilityLimits } from "./maintainabilityLimits";
|
|
10
11
|
import { MaintainabilityReport } from "./maintainabilityReport";
|
|
@@ -13,11 +14,12 @@ import { ServiceLoopCallScanner } from "./serviceLoopCallScanner";
|
|
|
13
14
|
import { SignalMutationIndex } from "./signalMutationIndex";
|
|
14
15
|
import { StoreAccessScanner } from "./storeAccessScanner";
|
|
15
16
|
import { StoreWriteScanner } from "./storeWriteScanner";
|
|
17
|
+
import { StructureScanner } from "./structureScanner";
|
|
16
18
|
import { WritePlacementScanner } from "./writePlacementScanner";
|
|
17
19
|
import { WritingActionIndex } from "./writingActionIndex";
|
|
18
20
|
|
|
19
21
|
export interface MaintainabilityScope {
|
|
20
|
-
type: "app" | "lib";
|
|
22
|
+
type: "app" | "lib" | "pkg";
|
|
21
23
|
name: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -27,13 +29,26 @@ export type MaintainabilityCommands = { [rule in MaintainabilityRuleId]?: readon
|
|
|
27
29
|
|
|
28
30
|
export interface SizeViolation {
|
|
29
31
|
kind: "size";
|
|
30
|
-
rule: "akan.size.file" | "akan.size.component";
|
|
32
|
+
rule: "akan.size.file" | "akan.size.component" | "akan.size.function";
|
|
31
33
|
key: string;
|
|
32
34
|
file: string;
|
|
33
35
|
line: number;
|
|
34
36
|
lineCount: number;
|
|
35
37
|
maxLines: number;
|
|
36
38
|
component?: string;
|
|
39
|
+
function?: string;
|
|
40
|
+
recorded?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ClassSizeViolation {
|
|
44
|
+
kind: "classSize";
|
|
45
|
+
rule: "akan.size.class";
|
|
46
|
+
key: string;
|
|
47
|
+
file: string;
|
|
48
|
+
line: number;
|
|
49
|
+
owner: string;
|
|
50
|
+
memberCount: number;
|
|
51
|
+
maxMembers: number;
|
|
37
52
|
recorded?: number;
|
|
38
53
|
}
|
|
39
54
|
|
|
@@ -92,10 +107,12 @@ export interface BaselineGrowth {
|
|
|
92
107
|
count: number;
|
|
93
108
|
committed?: number;
|
|
94
109
|
reference: string;
|
|
110
|
+
measured?: boolean;
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
export type MaintainabilityViolation =
|
|
98
114
|
| SizeViolation
|
|
115
|
+
| ClassSizeViolation
|
|
99
116
|
| MultiWriteViolation
|
|
100
117
|
| StoreAccessViolation
|
|
101
118
|
| WritePlacementViolation
|
|
@@ -108,6 +125,8 @@ export class MaintainabilityContract {
|
|
|
108
125
|
static readonly ruleIds = [
|
|
109
126
|
"akan.size.file",
|
|
110
127
|
"akan.size.component",
|
|
128
|
+
"akan.size.function",
|
|
129
|
+
"akan.size.class",
|
|
111
130
|
"akan.store.multi-write",
|
|
112
131
|
"akan.store.root-write-chain",
|
|
113
132
|
"akan.component.write-chain",
|
|
@@ -121,6 +140,7 @@ export class MaintainabilityContract {
|
|
|
121
140
|
readonly #workspaceRoot: string;
|
|
122
141
|
readonly #scope: MaintainabilityScope;
|
|
123
142
|
readonly #libNames: string[];
|
|
143
|
+
#tree: ReadonlyMap<string, string> | null = null;
|
|
124
144
|
|
|
125
145
|
constructor(workspaceRoot: string, scope: MaintainabilityScope, libNames: string[] = []) {
|
|
126
146
|
this.#workspaceRoot = workspaceRoot;
|
|
@@ -137,6 +157,7 @@ export class MaintainabilityContract {
|
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
async measure(): Promise<MaintainabilityViolation[]> {
|
|
160
|
+
if (this.#scope.type === "pkg") return await this.#measurePackage();
|
|
140
161
|
const [signalFiles, storeFiles, serviceFiles] = await Promise.all([
|
|
141
162
|
this.#reachedSources("lib/**/*.signal.ts"),
|
|
142
163
|
this.#reachedSources("lib/**/*.store.ts"),
|
|
@@ -144,10 +165,7 @@ export class MaintainabilityContract {
|
|
|
144
165
|
]);
|
|
145
166
|
const index = new WritingActionIndex(new SignalMutationIndex(signalFiles), storeFiles);
|
|
146
167
|
const reach = new EndpointReach(signalFiles, serviceFiles);
|
|
147
|
-
const files = await MaintainabilityContract.#
|
|
148
|
-
path.join(this.#workspaceRoot, this.sysPath),
|
|
149
|
-
MaintainabilityContract.#measuredFiles,
|
|
150
|
-
);
|
|
168
|
+
const files = await this.#filesIn(this.sysPath, MaintainabilityContract.#measuredFiles);
|
|
151
169
|
const violations = await Promise.all(files.map((file) => this.#measureFile(file, index, reach)));
|
|
152
170
|
return violations.flat().sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
153
171
|
}
|
|
@@ -168,12 +186,19 @@ export class MaintainabilityContract {
|
|
|
168
186
|
const recorded = baseline.recordedOf(violation);
|
|
169
187
|
return recorded === undefined ? violation : { ...violation, recorded };
|
|
170
188
|
});
|
|
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
189
|
//? Without fix the file on disk is what merges, so an entry it holds above the measured count is checked as written.
|
|
175
|
-
const
|
|
176
|
-
|
|
190
|
+
const working = fix ? tightened : baseline;
|
|
191
|
+
const source = `${reference.label}:${this.sysPath}/${MaintainabilityBaseline.fileName}`;
|
|
192
|
+
const committed =
|
|
193
|
+
reference.text !== null
|
|
194
|
+
? MaintainabilityBaseline.parse(reference.text, source, { lenient: true })
|
|
195
|
+
: working.isEmpty
|
|
196
|
+
? null
|
|
197
|
+
: await this.#measuredAt(reference.ref);
|
|
198
|
+
if (committed === null) return failures;
|
|
199
|
+
const lines = ((await this.#readBaseline()) ?? "").split(/\r?\n/);
|
|
200
|
+
const growths = working
|
|
201
|
+
.grownFrom(committed)
|
|
177
202
|
.map(
|
|
178
203
|
({ rule, ...growth }): BaselineGrowth => ({
|
|
179
204
|
kind: "baselineGrowth",
|
|
@@ -181,7 +206,8 @@ export class MaintainabilityContract {
|
|
|
181
206
|
file: MaintainabilityBaseline.fileName,
|
|
182
207
|
line: MaintainabilityContract.#lineOf(lines, rule, growth.key),
|
|
183
208
|
debtRule: rule,
|
|
184
|
-
reference: reference.
|
|
209
|
+
reference: reference.label,
|
|
210
|
+
...(reference.text === null ? { measured: true } : {}),
|
|
185
211
|
...growth,
|
|
186
212
|
}),
|
|
187
213
|
)
|
|
@@ -206,6 +232,77 @@ export class MaintainabilityContract {
|
|
|
206
232
|
return MaintainabilityReport.describe(this.sysPath, failures, commands);
|
|
207
233
|
}
|
|
208
234
|
|
|
235
|
+
async #measurePackage() {
|
|
236
|
+
const files = (await this.#packageFiles()).filter(MaintainabilityLimits.isPackageSource);
|
|
237
|
+
const violations = await Promise.all(
|
|
238
|
+
files.map(async (file) => {
|
|
239
|
+
const workspaceFile = `${this.sysPath}/${file}`;
|
|
240
|
+
const content = await this.#read(workspaceFile);
|
|
241
|
+
return MaintainabilityContract.#measurePackageFile(file, this.#sourceOf(workspaceFile, content).sourceFile);
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
return violations.flat();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
static #measurePackageFile(file: string, sourceFile: ts.SourceFile) {
|
|
248
|
+
const violations: MaintainabilityViolation[] = [];
|
|
249
|
+
const maxLines = MaintainabilityLimits.packageFileMaxLines;
|
|
250
|
+
const lineCount = MaintainabilityLimits.lineCountOf(sourceFile.text);
|
|
251
|
+
if (lineCount > maxLines && !MaintainabilityLimits.isReexportBarrel(file, sourceFile.text))
|
|
252
|
+
violations.push({ kind: "size", rule: "akan.size.file", key: file, file, line: 1, lineCount, maxLines });
|
|
253
|
+
const { functions, classes } = new StructureScanner().measure(sourceFile);
|
|
254
|
+
const longFunctions = functions.filter(({ lineCount }) => lineCount > MaintainabilityLimits.functionMaxLines);
|
|
255
|
+
for (const { name, line, lineCount } of MaintainabilityContract.#numbered(longFunctions))
|
|
256
|
+
violations.push({
|
|
257
|
+
kind: "size",
|
|
258
|
+
rule: "akan.size.function",
|
|
259
|
+
key: `${file}#${name}`,
|
|
260
|
+
file,
|
|
261
|
+
line,
|
|
262
|
+
lineCount,
|
|
263
|
+
maxLines: MaintainabilityLimits.functionMaxLines,
|
|
264
|
+
function: name,
|
|
265
|
+
});
|
|
266
|
+
const largeClasses = classes.filter(({ memberCount }) => memberCount > MaintainabilityLimits.classMaxMembers);
|
|
267
|
+
for (const { name, line, memberCount } of MaintainabilityContract.#numbered(largeClasses))
|
|
268
|
+
violations.push({
|
|
269
|
+
kind: "classSize",
|
|
270
|
+
rule: "akan.size.class",
|
|
271
|
+
key: `${file}#${name}`,
|
|
272
|
+
file,
|
|
273
|
+
line,
|
|
274
|
+
owner: name,
|
|
275
|
+
memberCount,
|
|
276
|
+
maxMembers: MaintainabilityLimits.classMaxMembers,
|
|
277
|
+
});
|
|
278
|
+
return violations;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
//? Only breaches are numbered: a short function sharing a recorded one's name never moves the recorded key.
|
|
282
|
+
static #numbered<Item extends { name: string }>(items: Item[]) {
|
|
283
|
+
const seen = new Map<string, number>();
|
|
284
|
+
return items.map((item) => {
|
|
285
|
+
const count = (seen.get(item.name) ?? 0) + 1;
|
|
286
|
+
seen.set(item.name, count);
|
|
287
|
+
return count === 1 ? item : { ...item, name: `${item.name}[${count}]` };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
//? Git lists what the package holds without its ignored build output, caches and sample artifacts.
|
|
292
|
+
async #packageFiles() {
|
|
293
|
+
const listed = this.#tree
|
|
294
|
+
? null
|
|
295
|
+
: await this.#git(["ls-files", "-z", "-co", "--exclude-standard", "--", `./${this.sysPath}`]);
|
|
296
|
+
if (listed === null) return await this.#filesIn(this.sysPath, "**/*.{ts,tsx}");
|
|
297
|
+
const prefix = `${this.sysPath}/`;
|
|
298
|
+
return listed
|
|
299
|
+
.split("\0")
|
|
300
|
+
.filter((file) => file.startsWith(prefix) && existsSync(path.join(this.#workspaceRoot, file)))
|
|
301
|
+
.map((file) => file.slice(prefix.length))
|
|
302
|
+
.filter((file) => !file.includes("node_modules/"))
|
|
303
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
304
|
+
}
|
|
305
|
+
|
|
209
306
|
async #measureFile(
|
|
210
307
|
file: string,
|
|
211
308
|
index: WritingActionIndex,
|
|
@@ -217,7 +314,7 @@ export class MaintainabilityContract {
|
|
|
217
314
|
const isClientCode = MaintainabilityLimits.isClientCode(file);
|
|
218
315
|
if (!maxLines && !isStore && !isClientCode) return [];
|
|
219
316
|
const workspaceFile = `${this.sysPath}/${file}`;
|
|
220
|
-
const content = await
|
|
317
|
+
const content = await this.#read(workspaceFile);
|
|
221
318
|
const violations: MaintainabilityViolation[] = [];
|
|
222
319
|
const lineCount = MaintainabilityLimits.lineCountOf(content);
|
|
223
320
|
if (maxLines && lineCount > maxLines && !MaintainabilityLimits.isReexportBarrel(file, content))
|
|
@@ -303,17 +400,9 @@ export class MaintainabilityContract {
|
|
|
303
400
|
async #reachedSources(pattern: string) {
|
|
304
401
|
const roots = [this.sysPath, ...this.#libNames.map((libName) => `libs/${libName}`)];
|
|
305
402
|
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"))),
|
|
403
|
+
roots.map(async (root) => (await this.#filesIn(root, pattern)).map((file) => `${root}/${file}`)),
|
|
316
404
|
);
|
|
405
|
+
return await Promise.all(files.flat().map(async (file) => this.#sourceOf(file, await this.#read(file))));
|
|
317
406
|
}
|
|
318
407
|
|
|
319
408
|
#sourceOf(file: string, content: string): SourceFileInfo {
|
|
@@ -334,7 +423,21 @@ export class MaintainabilityContract {
|
|
|
334
423
|
async #readReferenceBaseline() {
|
|
335
424
|
const ref = await this.#referenceRef();
|
|
336
425
|
const text = await this.#git(["show", `${ref}:./${this.sysPath}/${MaintainabilityBaseline.fileName}`]);
|
|
337
|
-
return
|
|
426
|
+
return { ref, label: ref === "HEAD" ? ref : ref.slice(0, 12), text };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
//* With no baseline file at the reference, the debt its code already holds is the floor: none for a scope new to the
|
|
430
|
+
//* branch or one whose paid-off file was removed, and the breaches it had for a scope that adopts the gate here.
|
|
431
|
+
async #measuredAt(ref: string) {
|
|
432
|
+
if ((await this.#git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])) === null) return null;
|
|
433
|
+
const roots = [this.sysPath, ...this.#libNames.map((libName) => `libs/${libName}`)];
|
|
434
|
+
const contract = new MaintainabilityContract(this.#workspaceRoot, this.#scope, this.#libNames);
|
|
435
|
+
contract.#tree = await new GitRevision(this.#workspaceRoot).filesAt(
|
|
436
|
+
ref,
|
|
437
|
+
(file) => /\.(tsx?|md)$/.test(file),
|
|
438
|
+
roots,
|
|
439
|
+
);
|
|
440
|
+
return MaintainabilityBaseline.of(await contract.measure());
|
|
338
441
|
}
|
|
339
442
|
|
|
340
443
|
//* A branch's own commits cannot be the reference: one that raises an entry would make the raise the new floor.
|
|
@@ -393,12 +496,24 @@ export class MaintainabilityContract {
|
|
|
393
496
|
return Math.max(idx + 1, 1);
|
|
394
497
|
}
|
|
395
498
|
|
|
396
|
-
|
|
499
|
+
async #filesIn(root: string, pattern: string) {
|
|
500
|
+
const glob = new Bun.Glob(pattern);
|
|
501
|
+
const files = this.#tree
|
|
502
|
+
? [...this.#tree.keys()]
|
|
503
|
+
.filter((file) => file.startsWith(`${root}/`))
|
|
504
|
+
.map((file) => file.slice(root.length + 1))
|
|
505
|
+
.filter((file) => glob.match(file))
|
|
506
|
+
: await MaintainabilityContract.#filesOnDisk(path.join(this.#workspaceRoot, root), glob);
|
|
507
|
+
return files.filter((file) => !file.includes("node_modules/")).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
static async #filesOnDisk(root: string, glob: Bun.Glob) {
|
|
397
511
|
if (!existsSync(root)) return [];
|
|
398
|
-
const files = await Array.fromAsync(
|
|
399
|
-
return files
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
512
|
+
const files = await Array.fromAsync(glob.scan({ cwd: root, onlyFiles: true }));
|
|
513
|
+
return files.map((file) => file.split(path.sep).join("/"));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async #read(file: string) {
|
|
517
|
+
return this.#tree ? (this.#tree.get(file) ?? "") : await readFile(path.join(this.#workspaceRoot, file), "utf8");
|
|
403
518
|
}
|
|
404
519
|
}
|
|
@@ -15,7 +15,7 @@ const baselineOf = (json: object) => `${JSON.stringify(json, null, 2)}\n`;
|
|
|
15
15
|
const reason = "The court canvas is one pointer state machine.";
|
|
16
16
|
|
|
17
17
|
describe("MaintainabilityExceptionScanner", () => {
|
|
18
|
-
test("warns once per app or
|
|
18
|
+
test("warns once per app, lib or package and rule, counting reasoned exceptions apart from adopted debt", async () => {
|
|
19
19
|
const root = await makeWorkspace({
|
|
20
20
|
"apps/demo/akan.baseline.json": baselineOf({
|
|
21
21
|
"akan.size.file": {
|
|
@@ -38,6 +38,11 @@ describe("MaintainabilityExceptionScanner", () => {
|
|
|
38
38
|
},
|
|
39
39
|
}),
|
|
40
40
|
"libs/shared/akan.baseline.json": baselineOf({ "akan.size.file": { "ui/Table.tsx": 500 } }),
|
|
41
|
+
"pkgs/core/akan.baseline.json": baselineOf({ "akan.size.class": { "server/host.ts#Host": 70 } }),
|
|
42
|
+
"pkgs/@demo/tool/akan.baseline.json": baselineOf({
|
|
43
|
+
"akan.size.function": { "boot.ts#boot": 200 },
|
|
44
|
+
reasons: { "boot.ts#boot": reason },
|
|
45
|
+
}),
|
|
41
46
|
"libs/util/akan.baseline.json": baselineOf({}),
|
|
42
47
|
"libs/broken/akan.baseline.json": "{",
|
|
43
48
|
"apps/clean/main.ts": "export {};\n",
|
|
@@ -48,7 +53,7 @@ describe("MaintainabilityExceptionScanner", () => {
|
|
|
48
53
|
warnings.find((warning) => warning.file === file && warning.message.includes(`for ${rule}:`));
|
|
49
54
|
|
|
50
55
|
expect(warnings.map(({ rule }) => rule).every((rule) => rule === MaintainabilityExceptionScanner.rule)).toBe(true);
|
|
51
|
-
expect(warnings).toHaveLength(
|
|
56
|
+
expect(warnings).toHaveLength(10);
|
|
52
57
|
expect(byRule("apps/demo/akan.baseline.json", "akan.size.file")).toMatchObject({
|
|
53
58
|
scope: "file",
|
|
54
59
|
severity: "warning",
|
|
@@ -84,6 +89,12 @@ describe("MaintainabilityExceptionScanner", () => {
|
|
|
84
89
|
expect(byRule("libs/shared/akan.baseline.json", "akan.size.file")?.message).toBe(
|
|
85
90
|
"libs/shared carries 0 reasoned exceptions and 1 adopted-debt entry for akan.size.file: ui/Table.tsx.",
|
|
86
91
|
);
|
|
92
|
+
expect(byRule("pkgs/core/akan.baseline.json", "akan.size.class")?.message).toBe(
|
|
93
|
+
"pkgs/core carries 0 reasoned exceptions and 1 adopted-debt entry for akan.size.class: server/host.ts#Host.",
|
|
94
|
+
);
|
|
95
|
+
expect(byRule("pkgs/@demo/tool/akan.baseline.json", "akan.size.function")?.message).toBe(
|
|
96
|
+
`pkgs/@demo/tool carries 1 reasoned exception and 0 adopted-debt entries for akan.size.function: boot.ts#boot (${JSON.stringify(reason)}).`,
|
|
97
|
+
);
|
|
87
98
|
expect(byRule("apps/demo/akan.baseline.json", "akan.size.component")?.fix).toContain("threshold is wrong");
|
|
88
99
|
expect(byRule("apps/demo/akan.baseline.json", "akan.store.multi-write")?.fix).toContain("add-mutation");
|
|
89
100
|
});
|
|
@@ -14,12 +14,7 @@ export class MaintainabilityExceptionScanner {
|
|
|
14
14
|
|
|
15
15
|
async scan(workspaceRoot: string): Promise<QualityWarning[]> {
|
|
16
16
|
const sysPaths = await Promise.all(
|
|
17
|
-
["apps", "libs"].map(
|
|
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
|
-
}),
|
|
17
|
+
["apps", "libs", "pkgs"].map((sysRoot) => MaintainabilityExceptionScanner.#dirsIn(workspaceRoot, sysRoot)),
|
|
23
18
|
);
|
|
24
19
|
const warnings = await Promise.all(
|
|
25
20
|
sysPaths.flat().map((sysPath) => MaintainabilityExceptionScanner.#scanSys(workspaceRoot, sysPath)),
|
|
@@ -27,6 +22,21 @@ export class MaintainabilityExceptionScanner {
|
|
|
27
22
|
return warnings.flat();
|
|
28
23
|
}
|
|
29
24
|
|
|
25
|
+
static async #dirsIn(workspaceRoot: string, root: string): Promise<string[]> {
|
|
26
|
+
const absoluteRoot = path.join(workspaceRoot, root);
|
|
27
|
+
if (!existsSync(absoluteRoot)) return [];
|
|
28
|
+
const entries = await readdir(absoluteRoot, { withFileTypes: true });
|
|
29
|
+
const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => `${root}/${entry.name}`);
|
|
30
|
+
const nested = await Promise.all(
|
|
31
|
+
dirs.map(async (dir) =>
|
|
32
|
+
root === "pkgs" && path.posix.basename(dir).startsWith("@")
|
|
33
|
+
? await MaintainabilityExceptionScanner.#dirsIn(workspaceRoot, dir)
|
|
34
|
+
: [dir],
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
return nested.flat();
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
static async #scanSys(workspaceRoot: string, sysPath: string): Promise<QualityWarning[]> {
|
|
31
41
|
const file = `${sysPath}/${MaintainabilityBaseline.fileName}`;
|
|
32
42
|
const absolutePath = path.join(workspaceRoot, file);
|
package/maintainabilityLimits.ts
CHANGED
|
@@ -22,6 +22,9 @@ export class MaintainabilityLimits {
|
|
|
22
22
|
static readonly #maxLinesByKind = { ...MaintainabilityLimits.fileMaxLines, ...MaintainabilityLimits.folderMaxLines };
|
|
23
23
|
static readonly componentMaxLines = 250;
|
|
24
24
|
static readonly componentMaxStoreAccesses = 16;
|
|
25
|
+
static readonly packageFileMaxLines = 800;
|
|
26
|
+
static readonly functionMaxLines = 150;
|
|
27
|
+
static readonly classMaxMembers = 60;
|
|
25
28
|
|
|
26
29
|
static fileKindOf(sysRelativePath: string): FileKind | null {
|
|
27
30
|
const kinds = Object.keys(MaintainabilityLimits.#maxLinesByKind) as FileKind[];
|
|
@@ -54,6 +57,15 @@ export class MaintainabilityLimits {
|
|
|
54
57
|
return /^(ui|webkit)\/.+\.tsx?$/.test(sysRelativePath) && !sysRelativePath.endsWith(".d.ts");
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
static isPackageSource(pkgRelativePath: string) {
|
|
61
|
+
return (
|
|
62
|
+
/\.tsx?$/.test(pkgRelativePath) &&
|
|
63
|
+
!pkgRelativePath.endsWith(".d.ts") &&
|
|
64
|
+
!/(^|\/)(examples|test|tests|__tests__|__fixtures__|__mocks__)\//.test(pkgRelativePath) &&
|
|
65
|
+
!/\.(test|spec|fixture|instance|bench)\.tsx?$/.test(pkgRelativePath)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
static #isTest(sysRelativePath: string) {
|
|
58
70
|
return /\.(test|spec)\.tsx?$/.test(sysRelativePath);
|
|
59
71
|
}
|
|
@@ -127,4 +127,39 @@ describe("MaintainabilityReport", () => {
|
|
|
127
127
|
" *.service.ts: Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
|
|
128
128
|
]);
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
test("gives a package file, whatever its folder, a long function and a large class the package fixes", () => {
|
|
132
|
+
const message = MaintainabilityReport.describe("pkgs/akanjs", [
|
|
133
|
+
{ ...fileOf("ui/Constant/Doc.tsx", 813), maxLines: 800 },
|
|
134
|
+
{
|
|
135
|
+
...fileOf("store/action.ts", 824),
|
|
136
|
+
rule: "akan.size.function",
|
|
137
|
+
key: "store/action.ts#makeActions",
|
|
138
|
+
maxLines: 150,
|
|
139
|
+
function: "makeActions",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
kind: "classSize",
|
|
143
|
+
rule: "akan.size.class",
|
|
144
|
+
key: "server/akanApp.ts#AkanApp",
|
|
145
|
+
file: "server/akanApp.ts",
|
|
146
|
+
line: 90,
|
|
147
|
+
owner: "AkanApp",
|
|
148
|
+
memberCount: 154,
|
|
149
|
+
maxMembers: 60,
|
|
150
|
+
recorded: 150,
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
expect(message).toContain(
|
|
155
|
+
"pkgs/akanjs/ui/Constant/Doc.tsx:1 akan.size.file file has 813 lines, over the 800-line limit",
|
|
156
|
+
);
|
|
157
|
+
expect(message).toContain(
|
|
158
|
+
"pkgs/akanjs/store/action.ts:1 akan.size.function function makeActions has 824 lines, over the 150-line limit",
|
|
159
|
+
);
|
|
160
|
+
expect(message).toContain(
|
|
161
|
+
"pkgs/akanjs/server/akanApp.ts:90 akan.size.class class AkanApp has 154 members, over the 60-member limit, up from 150",
|
|
162
|
+
);
|
|
163
|
+
expect(fixLinesOf(message).map((line) => line.split(":")[0])).toEqual([" package file", " function", " class"]);
|
|
164
|
+
});
|
|
130
165
|
});
|
package/maintainabilityReport.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
} from "./maintainabilityContract";
|
|
8
8
|
import { type FileKind, MaintainabilityLimits } from "./maintainabilityLimits";
|
|
9
9
|
|
|
10
|
-
type FixKey = FileKind | Exclude<MaintainabilityRuleId, "akan.size.file">;
|
|
10
|
+
type FixKey = FileKind | "package" | Exclude<MaintainabilityRuleId, "akan.size.file">;
|
|
11
11
|
|
|
12
12
|
export class MaintainabilityReport {
|
|
13
13
|
static readonly #clientSplitFix =
|
|
@@ -30,9 +30,15 @@ export class MaintainabilityReport {
|
|
|
30
30
|
"Move display and predicate logic onto Light<Model>, and split an unrelated concept into its own module.",
|
|
31
31
|
".service.ts":
|
|
32
32
|
"Move state transitions into document chain methods, and split an unrelated responsibility into its own module.",
|
|
33
|
+
package:
|
|
34
|
+
"Split it by responsibility: one class per file, and each family of helpers in a file of its own behind the package's existing exports.",
|
|
33
35
|
[AbstractDoc.suffix]:
|
|
34
36
|
"Trim the abstract to a title, one sentence naming what the module owns, and a `## Rules` list of the invariants the code cannot show.",
|
|
35
37
|
"akan.size.component": MaintainabilityReport.#clientSplitFix,
|
|
38
|
+
"akan.size.function":
|
|
39
|
+
"Split it by family into named functions or a collaborator class, each under the limit, keeping its signature for callers.",
|
|
40
|
+
"akan.size.class":
|
|
41
|
+
"Move each family of members into a collaborator the class holds, and keep the public members on the class as its facade.",
|
|
36
42
|
"akan.store.multi-write": MaintainabilityReport.#oneEndpointFix,
|
|
37
43
|
"akan.store.root-write-chain": `A store may call another through \`RootStore\`, but that does not make the writes one. ${MaintainabilityReport.#oneEndpointFix}`,
|
|
38
44
|
"akan.component.write-chain":
|
|
@@ -49,7 +55,10 @@ export class MaintainabilityReport {
|
|
|
49
55
|
static readonly #labels: { [key in FixKey]?: string } = {
|
|
50
56
|
"ui/": "ui/**",
|
|
51
57
|
"webkit/": "webkit/**",
|
|
58
|
+
package: "package file",
|
|
52
59
|
"akan.size.component": "component or hook",
|
|
60
|
+
"akan.size.function": "function",
|
|
61
|
+
"akan.size.class": "class",
|
|
53
62
|
"akan.store.multi-write": "store action with several writes",
|
|
54
63
|
"akan.store.root-write-chain": "store method chaining other stores' writes",
|
|
55
64
|
"akan.component.write-chain": "handler with several writes",
|
|
@@ -62,6 +71,8 @@ export class MaintainabilityReport {
|
|
|
62
71
|
static readonly #units: { [rule in DebtRuleId]: string } = {
|
|
63
72
|
"akan.size.file": "lines",
|
|
64
73
|
"akan.size.component": "lines",
|
|
74
|
+
"akan.size.function": "lines",
|
|
75
|
+
"akan.size.class": "members",
|
|
65
76
|
"akan.component.store-access": "store accesses",
|
|
66
77
|
"akan.store.multi-write": "writes",
|
|
67
78
|
"akan.store.root-write-chain": "writes",
|
|
@@ -73,7 +84,7 @@ export class MaintainabilityReport {
|
|
|
73
84
|
static describe(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
|
|
74
85
|
const groups = new Map<string, { labels: string[]; rules: Set<MaintainabilityRuleId> }>();
|
|
75
86
|
for (const failure of failures) {
|
|
76
|
-
const fixKey = MaintainabilityReport.#fixKeyOf(failure);
|
|
87
|
+
const fixKey = MaintainabilityReport.#fixKeyOf(sysPath, failure);
|
|
77
88
|
const fix = MaintainabilityReport.#fixes[fixKey];
|
|
78
89
|
const group = groups.get(fix) ?? { labels: [], rules: new Set() };
|
|
79
90
|
const label = MaintainabilityReport.#labelOf(fixKey);
|
|
@@ -101,7 +112,7 @@ export class MaintainabilityReport {
|
|
|
101
112
|
line: failure.line,
|
|
102
113
|
rule: failure.rule,
|
|
103
114
|
problem: MaintainabilityReport.#problemOf(failure),
|
|
104
|
-
fix: MaintainabilityReport.#fixes[MaintainabilityReport.#fixKeyOf(failure)],
|
|
115
|
+
fix: MaintainabilityReport.#fixes[MaintainabilityReport.#fixKeyOf(sysPath, failure)],
|
|
105
116
|
path: commands[failure.rule]?.[0] ?? null,
|
|
106
117
|
}));
|
|
107
118
|
}
|
|
@@ -109,9 +120,14 @@ export class MaintainabilityReport {
|
|
|
109
120
|
static #problemOf(failure: MaintainabilityFailure) {
|
|
110
121
|
if (failure.kind === "baselineGrowth") {
|
|
111
122
|
const entry = `entry "${failure.key}" under ${failure.debtRule}`;
|
|
123
|
+
const units = MaintainabilityReport.#units[failure.debtRule];
|
|
124
|
+
if (failure.measured)
|
|
125
|
+
return failure.committed === undefined
|
|
126
|
+
? `${entry} is debt the code at ${failure.reference} does not hold, and with no baseline there the baseline may only shrink from that code`
|
|
127
|
+
: `${entry} allows ${failure.count} ${units}, above the ${failure.committed} the code at ${failure.reference} holds with no baseline there; the baseline may only shrink`;
|
|
112
128
|
return failure.committed === undefined
|
|
113
129
|
? `${entry} is not in the baseline at ${failure.reference}, which may only shrink`
|
|
114
|
-
: `${entry} allows ${failure.count} ${
|
|
130
|
+
: `${entry} allows ${failure.count} ${units}, above the ${failure.committed} at ${failure.reference}; the baseline may only shrink`;
|
|
115
131
|
}
|
|
116
132
|
const grown = failure.recorded === undefined ? "" : `, more than the ${failure.recorded} recorded`;
|
|
117
133
|
if (failure.kind === "multiWrite") {
|
|
@@ -129,11 +145,15 @@ export class MaintainabilityReport {
|
|
|
129
145
|
return `sends ${failure.writes.length} server write(s) from outside a store action or a module component: ${failure.writes.join(", ")}${grown}`;
|
|
130
146
|
if (failure.kind === "loopCall")
|
|
131
147
|
return `service method ${failure.owner} calls once per item: ${failure.calls.join(", ")}${grown}`;
|
|
148
|
+
if (failure.kind === "classSize")
|
|
149
|
+
return `class ${failure.owner} has ${failure.memberCount} members, over the ${failure.maxMembers}-member limit${failure.recorded === undefined ? "" : `, up from ${failure.recorded}`}`;
|
|
132
150
|
if (failure.kind === "storeAccess")
|
|
133
151
|
return `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} reaches the store ${failure.accessCount} times, over the ${failure.maxAccesses}-access limit${failure.recorded === undefined ? "" : `, up from ${failure.recorded}`}`;
|
|
134
|
-
const subject = failure.
|
|
135
|
-
?
|
|
136
|
-
:
|
|
152
|
+
const subject = failure.function
|
|
153
|
+
? `function ${failure.function} has`
|
|
154
|
+
: failure.component
|
|
155
|
+
? `${MaintainabilityReport.#unitOf(failure.component)} ${failure.component} has`
|
|
156
|
+
: "file has";
|
|
137
157
|
const up = failure.recorded === undefined ? "" : `, up from ${failure.recorded}`;
|
|
138
158
|
return `${subject} ${failure.lineCount} lines, over the ${failure.maxLines}-line limit${up}`;
|
|
139
159
|
}
|
|
@@ -142,8 +162,9 @@ export class MaintainabilityReport {
|
|
|
142
162
|
return /^use[A-Z]/.test(name) ? "hook" : "component";
|
|
143
163
|
}
|
|
144
164
|
|
|
145
|
-
static #fixKeyOf(failure: MaintainabilityFailure): FixKey {
|
|
165
|
+
static #fixKeyOf(sysPath: string, failure: MaintainabilityFailure): FixKey {
|
|
146
166
|
if (failure.rule !== "akan.size.file") return failure.rule;
|
|
167
|
+
if (sysPath.startsWith("pkgs/")) return "package";
|
|
147
168
|
return MaintainabilityLimits.fileKindOf(failure.file) ?? "akan.size.component";
|
|
148
169
|
}
|
|
149
170
|
|
|
@@ -114,7 +114,7 @@ describe("NativePublicEnv", () => {
|
|
|
114
114
|
WorkspaceExecutor.publicEnvPin = null;
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
test("leaves exactly the pinned values and the derived ones, and outranks the root .env", async () => {
|
|
117
|
+
test("leaves exactly the pinned values and the derived ones, and outranks the shell and the root .env", async () => {
|
|
118
118
|
const root = await makeTempRoot();
|
|
119
119
|
const envFile = path.join(root, ".env");
|
|
120
120
|
await writeText(
|
|
@@ -125,6 +125,10 @@ describe("NativePublicEnv", () => {
|
|
|
125
125
|
NativePublicEnv.pin(recorded);
|
|
126
126
|
expect(NativePublicEnv.of(process.env)).toEqual(recorded);
|
|
127
127
|
expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("lu");
|
|
128
|
+
Object.assign(process.env, {
|
|
129
|
+
AKAN_PUBLIC_REPO_NAME: "from-shell",
|
|
130
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "shell.example.com",
|
|
131
|
+
});
|
|
128
132
|
const base = WorkspaceExecutor.getBaseDevEnv(envFile);
|
|
129
133
|
expect(base.repoName).toBe("softs2");
|
|
130
134
|
expect(base.serveDomain).toBe("akamir.com");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0-rc.0",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"./*": "./*.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@akanjs/language": "3.
|
|
44
|
+
"@akanjs/language": "3.2.0-rc.0",
|
|
45
45
|
"@earendil-works/pi-agent-core": "1.3.0",
|
|
46
46
|
"@earendil-works/pi-ai": "1.3.0",
|
|
47
47
|
"@earendil-works/pi-codemode": "1.3.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@earendil-works/pi-tui": "1.3.0",
|
|
51
51
|
"@inquirer/prompts": "^8.7.3",
|
|
52
52
|
"@tailwindcss/node": "^4.3.3",
|
|
53
|
-
"akanjs": "3.
|
|
53
|
+
"akanjs": "3.2.0-rc.0",
|
|
54
54
|
"chalk": "^6.0.1",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"dayjs": "^1.11.23",
|