@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.10
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 +233 -11
- package/agentAssets.ts +1 -1
- package/akanApp/akanApp.host.test.ts +127 -0
- package/akanApp/akanApp.host.ts +50 -57
- package/akanApp/devHostPolicy.test.ts +11 -11
- package/akanApp/devHostPolicy.ts +2 -4
- package/akanContext.ts +2 -5
- package/akanMcpContract.ts +21 -10
- package/akanMcpToolError.ts +16 -0
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +497 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +65 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.test.ts +4 -0
- package/artifact/implicitRootLayout.ts +13 -1
- package/artifact/storeBootstrap.ts +3 -2
- package/bareErrorScanner.ts +126 -0
- package/biome.base.json +2 -1
- package/codeAgent/skills/akan-debug/SKILL.md +7 -3
- package/codeAgent/skills/akan-err/SKILL.md +7 -0
- package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
- package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/codeAgent/skills/akan-validate/SKILL.md +1 -0
- package/codeAgent/tools/McpClient.ts +28 -6
- package/codeAgent/tools/McpToolPack.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/cliError.ts +72 -0
- package/commandDecorators/command.ts +186 -96
- package/commandDecorators/commandDecorators.test.ts +240 -17
- package/commandDecorators/exitCodeError.ts +6 -7
- package/commandDecorators/index.ts +1 -0
- package/commandDecorators/targetMeta.ts +3 -0
- package/conventionSet.test.ts +5 -2
- package/csrE2e/csrE2eHarness.fixture.ts +1 -1
- package/errorCodeReference.test.ts +282 -0
- package/errorCodeReference.ts +71 -0
- package/errorCodeScan.ts +551 -0
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
- package/frontendBuild/clientEntryDiscovery.ts +53 -9
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/precompressArtifacts.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
- package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
- package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
- package/lint/no-import-common-err.grit +31 -0
- package/lint/no-init-fetch-in-client.grit +1 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/maintainabilityBaseline.test.ts +49 -0
- package/maintainabilityBaseline.ts +73 -8
- package/maintainabilityContract.test.ts +302 -1
- package/maintainabilityContract.ts +87 -22
- package/maintainabilityExceptionScanner.test.ts +14 -0
- package/maintainabilityExceptionScanner.ts +5 -1
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.ts +20 -3
- package/moduleFieldNames.test.ts +56 -0
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewView.ts +32 -11
- package/preview/previewView.test.ts +76 -9
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/typecheck/projectTypecheck.ts +5 -1
- package/typecheck/typecheck.proc.ts +3 -2
- package/typecheck/typecheckFailure.ts +6 -1
- package/workflow/artifacts.ts +25 -7
- package/workflow/fieldType.ts +92 -0
- package/workflow/index.ts +2 -0
- package/workflow/plainName.ts +28 -0
- package/workflow/plan.ts +67 -22
- package/workflow/source.ts +0 -5
- package/workspaceGraph.test.ts +200 -3
- package/workspaceGraph.ts +46 -8
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import ts from "typescript";
|
|
5
|
+
import { BareErrorScanner } from "./bareErrorScanner";
|
|
5
6
|
import { ComponentSizeScanner } from "./componentSizeScanner";
|
|
6
7
|
import { ComponentWriteChainScanner } from "./componentWriteChainScanner";
|
|
7
8
|
import { EndpointReach } from "./endpointReach";
|
|
@@ -97,6 +98,16 @@ export interface StoreAccessViolation {
|
|
|
97
98
|
recorded?: number;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
export interface BareErrorViolation {
|
|
102
|
+
kind: "bareError";
|
|
103
|
+
rule: "akan.error.bare-error";
|
|
104
|
+
key: string;
|
|
105
|
+
file: string;
|
|
106
|
+
line: number;
|
|
107
|
+
lines: number[];
|
|
108
|
+
recorded?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
export interface BaselineGrowth {
|
|
101
112
|
kind: "baselineGrowth";
|
|
102
113
|
rule: "akan.baseline.growth";
|
|
@@ -110,14 +121,25 @@ export interface BaselineGrowth {
|
|
|
110
121
|
measured?: boolean;
|
|
111
122
|
}
|
|
112
123
|
|
|
124
|
+
export interface StaleTotal {
|
|
125
|
+
kind: "staleTotal";
|
|
126
|
+
rule: DebtRuleId;
|
|
127
|
+
key: string;
|
|
128
|
+
file: string;
|
|
129
|
+
line: number;
|
|
130
|
+
count: number;
|
|
131
|
+
recorded: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
113
134
|
export type MaintainabilityViolation =
|
|
114
135
|
| SizeViolation
|
|
115
136
|
| ClassSizeViolation
|
|
116
137
|
| MultiWriteViolation
|
|
117
138
|
| StoreAccessViolation
|
|
118
139
|
| WritePlacementViolation
|
|
119
|
-
| LoopCallViolation
|
|
120
|
-
|
|
140
|
+
| LoopCallViolation
|
|
141
|
+
| BareErrorViolation;
|
|
142
|
+
export type MaintainabilityFailure = MaintainabilityViolation | BaselineGrowth | StaleTotal;
|
|
121
143
|
|
|
122
144
|
export type BaselineSeed = "written" | "kept" | "removed" | "clean";
|
|
123
145
|
|
|
@@ -133,6 +155,7 @@ export class MaintainabilityContract {
|
|
|
133
155
|
"akan.component.store-access",
|
|
134
156
|
"akan.write.placement",
|
|
135
157
|
"akan.service.call-in-loop",
|
|
158
|
+
"akan.error.bare-error",
|
|
136
159
|
"akan.baseline.growth",
|
|
137
160
|
] as const;
|
|
138
161
|
static readonly #measuredFiles = "{lib,ui,webkit,srvkit,common,plugin}/**/*.{ts,tsx,md}";
|
|
@@ -180,23 +203,33 @@ export class MaintainabilityContract {
|
|
|
180
203
|
existing === null ? new MaintainabilityBaseline() : MaintainabilityBaseline.parse(existing, this.baselinePath);
|
|
181
204
|
const tightened = baseline.tightenedBy(violations);
|
|
182
205
|
if (fix && existing !== null) await this.#tighten(baseline, tightened);
|
|
183
|
-
const failures: MaintainabilityFailure[] = violations
|
|
184
|
-
.filter((violation) => !baseline.allows(violation))
|
|
185
|
-
.map((violation) => {
|
|
186
|
-
const recorded = baseline.recordedOf(violation);
|
|
187
|
-
return recorded === undefined ? violation : { ...violation, recorded };
|
|
188
|
-
});
|
|
189
206
|
//? Without fix the file on disk is what merges, so an entry it holds above the measured count is checked as written.
|
|
190
207
|
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
208
|
const lines = ((await this.#readBaseline()) ?? "").split(/\r?\n/);
|
|
209
|
+
const failures: MaintainabilityFailure[] = [
|
|
210
|
+
...working.staleTotals(violations).map(
|
|
211
|
+
({ rule, count, recorded }): StaleTotal => ({
|
|
212
|
+
kind: "staleTotal",
|
|
213
|
+
rule,
|
|
214
|
+
key: MaintainabilityBaseline.totalKey,
|
|
215
|
+
file: MaintainabilityBaseline.fileName,
|
|
216
|
+
line: MaintainabilityContract.#lineOf(lines, rule, MaintainabilityBaseline.totalKey),
|
|
217
|
+
count,
|
|
218
|
+
recorded,
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
...baseline.unallowed(violations).map((violation) => {
|
|
222
|
+
const recorded = baseline.recordedOf(violation);
|
|
223
|
+
return recorded === undefined ? violation : { ...violation, recorded };
|
|
224
|
+
}),
|
|
225
|
+
];
|
|
226
|
+
const source = `${reference.label}:${this.sysPath}/${MaintainabilityBaseline.fileName}`;
|
|
227
|
+
const recorded =
|
|
228
|
+
reference.text !== null ? MaintainabilityBaseline.parse(reference.text, source, { lenient: true }) : null;
|
|
229
|
+
const unrecorded = new Set(working.rulesOutside(recorded));
|
|
230
|
+
const measured = unrecorded.size ? await this.#measuredAt(reference.ref) : null;
|
|
231
|
+
if (recorded === null && measured === null) return failures;
|
|
232
|
+
const committed = (recorded ?? new MaintainabilityBaseline()).withSectionsOf(measured, unrecorded);
|
|
200
233
|
const growths = working
|
|
201
234
|
.grownFrom(committed)
|
|
202
235
|
.map(
|
|
@@ -207,7 +240,7 @@ export class MaintainabilityContract {
|
|
|
207
240
|
line: MaintainabilityContract.#lineOf(lines, rule, growth.key),
|
|
208
241
|
debtRule: rule,
|
|
209
242
|
reference: reference.label,
|
|
210
|
-
...(
|
|
243
|
+
...(unrecorded.has(rule) ? { measured: true } : {}),
|
|
211
244
|
...growth,
|
|
212
245
|
}),
|
|
213
246
|
)
|
|
@@ -234,17 +267,37 @@ export class MaintainabilityContract {
|
|
|
234
267
|
|
|
235
268
|
async #measurePackage() {
|
|
236
269
|
const files = (await this.#packageFiles()).filter(MaintainabilityLimits.isPackageSource);
|
|
270
|
+
const packageName = await this.#packageName();
|
|
237
271
|
const violations = await Promise.all(
|
|
238
272
|
files.map(async (file) => {
|
|
239
273
|
const workspaceFile = `${this.sysPath}/${file}`;
|
|
240
274
|
const content = await this.#read(workspaceFile);
|
|
241
|
-
return MaintainabilityContract.#measurePackageFile(
|
|
275
|
+
return MaintainabilityContract.#measurePackageFile(
|
|
276
|
+
file,
|
|
277
|
+
this.#sourceOf(workspaceFile, content).sourceFile,
|
|
278
|
+
MaintainabilityLimits.holdsBareErrors(packageName, file),
|
|
279
|
+
);
|
|
242
280
|
}),
|
|
243
281
|
);
|
|
244
282
|
return violations.flat();
|
|
245
283
|
}
|
|
246
284
|
|
|
247
|
-
|
|
285
|
+
async #packageName() {
|
|
286
|
+
const manifest = `${this.sysPath}/package.json`;
|
|
287
|
+
const text = this.#tree
|
|
288
|
+
? this.#tree.get(manifest)
|
|
289
|
+
: existsSync(path.join(this.#workspaceRoot, manifest))
|
|
290
|
+
? await this.#read(manifest)
|
|
291
|
+
: undefined;
|
|
292
|
+
try {
|
|
293
|
+
const { name } = JSON.parse(text ?? "{}") as { name?: unknown };
|
|
294
|
+
return typeof name === "string" ? name : null;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
static #measurePackageFile(file: string, sourceFile: ts.SourceFile, holdsBareErrors: boolean) {
|
|
248
301
|
const violations: MaintainabilityViolation[] = [];
|
|
249
302
|
const maxLines = MaintainabilityLimits.packageFileMaxLines;
|
|
250
303
|
const lineCount = MaintainabilityLimits.lineCountOf(sourceFile.text);
|
|
@@ -275,6 +328,16 @@ export class MaintainabilityContract {
|
|
|
275
328
|
memberCount,
|
|
276
329
|
maxMembers: MaintainabilityLimits.classMaxMembers,
|
|
277
330
|
});
|
|
331
|
+
const bareErrors = holdsBareErrors ? new BareErrorScanner().lines(sourceFile) : [];
|
|
332
|
+
if (bareErrors.length)
|
|
333
|
+
violations.push({
|
|
334
|
+
kind: "bareError",
|
|
335
|
+
rule: "akan.error.bare-error",
|
|
336
|
+
key: file,
|
|
337
|
+
file,
|
|
338
|
+
line: bareErrors[0] ?? 1,
|
|
339
|
+
lines: bareErrors,
|
|
340
|
+
});
|
|
278
341
|
return violations;
|
|
279
342
|
}
|
|
280
343
|
|
|
@@ -426,15 +489,16 @@ export class MaintainabilityContract {
|
|
|
426
489
|
return { ref, label: ref === "HEAD" ? ref : ref.slice(0, 12), text };
|
|
427
490
|
}
|
|
428
491
|
|
|
429
|
-
//* With no baseline file at the reference,
|
|
430
|
-
//* branch or one whose paid-off file was removed, and the breaches it had for a
|
|
492
|
+
//* With no baseline file at the reference, or no section there for a rule, the debt its code already holds is the
|
|
493
|
+
//* floor: none for a scope new to the branch or one whose paid-off file was removed, and the breaches it had for a
|
|
494
|
+
//* scope that adopts the gate here or a rule this version adds.
|
|
431
495
|
async #measuredAt(ref: string) {
|
|
432
496
|
if ((await this.#git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])) === null) return null;
|
|
433
497
|
const roots = [this.sysPath, ...this.#libNames.map((libName) => `libs/${libName}`)];
|
|
434
498
|
const contract = new MaintainabilityContract(this.#workspaceRoot, this.#scope, this.#libNames);
|
|
435
499
|
contract.#tree = await new GitRevision(this.#workspaceRoot).filesAt(
|
|
436
500
|
ref,
|
|
437
|
-
(file) => /\.(tsx?|md)$/.test(file)
|
|
501
|
+
(file) => /\.(tsx?|md)$/.test(file) || file === `${this.sysPath}/package.json`,
|
|
438
502
|
roots,
|
|
439
503
|
);
|
|
440
504
|
return MaintainabilityBaseline.of(await contract.measure());
|
|
@@ -492,6 +556,7 @@ export class MaintainabilityContract {
|
|
|
492
556
|
lines.findIndex((line) => line.includes(`${JSON.stringify(rule)}:`)),
|
|
493
557
|
0,
|
|
494
558
|
);
|
|
559
|
+
if (key === MaintainabilityBaseline.totalKey) return from + 1;
|
|
495
560
|
const idx = lines.findIndex((line, lineIdx) => lineIdx >= from && line.includes(`${JSON.stringify(key)}:`));
|
|
496
561
|
return Math.max(idx + 1, 1);
|
|
497
562
|
}
|
|
@@ -98,4 +98,18 @@ describe("MaintainabilityExceptionScanner", () => {
|
|
|
98
98
|
expect(byRule("apps/demo/akan.baseline.json", "akan.size.component")?.fix).toContain("threshold is wrong");
|
|
99
99
|
expect(byRule("apps/demo/akan.baseline.json", "akan.store.multi-write")?.fix).toContain("add-mutation");
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
test("tells a bare Error entry to construct a coded error, since the rule takes no exception", async () => {
|
|
103
|
+
const root = await makeWorkspace({
|
|
104
|
+
"pkgs/akanjs/akan.baseline.json": baselineOf({ "akan.error.bare-error": { "server/host.ts": 2 } }),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(await new MaintainabilityExceptionScanner().scan(root)).toMatchObject([
|
|
108
|
+
{
|
|
109
|
+
message:
|
|
110
|
+
"pkgs/akanjs carries 0 reasoned exceptions and 1 adopted-debt entry for akan.error.bare-error: server/host.ts.",
|
|
111
|
+
fix: expect.stringContaining("construct an `AkanError` subclass"),
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
101
115
|
});
|
|
@@ -11,6 +11,8 @@ export class MaintainabilityExceptionScanner {
|
|
|
11
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
12
|
static readonly #correctnessFix =
|
|
13
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
|
+
static readonly #bareErrorFix =
|
|
15
|
+
"A bare Error takes no exception: construct an `AkanError` subclass with a code, and `akan lint` lowers the entries as they go.";
|
|
14
16
|
|
|
15
17
|
async scan(workspaceRoot: string): Promise<QualityWarning[]> {
|
|
16
18
|
const sysPaths = await Promise.all(
|
|
@@ -81,7 +83,9 @@ export class MaintainabilityExceptionScanner {
|
|
|
81
83
|
locations,
|
|
82
84
|
fix: MaintainabilityBaseline.reasonedRules.has(rule)
|
|
83
85
|
? MaintainabilityExceptionScanner.#shapeFix
|
|
84
|
-
:
|
|
86
|
+
: rule === "akan.error.bare-error"
|
|
87
|
+
? MaintainabilityExceptionScanner.#bareErrorFix
|
|
88
|
+
: MaintainabilityExceptionScanner.#correctnessFix,
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
|
package/maintainabilityLimits.ts
CHANGED
|
@@ -25,6 +25,18 @@ export class MaintainabilityLimits {
|
|
|
25
25
|
static readonly packageFileMaxLines = 800;
|
|
26
26
|
static readonly functionMaxLines = 150;
|
|
27
27
|
static readonly classMaxMembers = 60;
|
|
28
|
+
//? The request-path facets: a failure there reaches a caller, who branches on its code rather than its message.
|
|
29
|
+
static readonly bareErrorRoots: ReadonlyMap<string, readonly string[]> = new Map([
|
|
30
|
+
["akanjs", ["server/", "service/", "signal/"]],
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
//? Rules this repository holds its own framework to: no workspace has a package they measure, so none is told of them.
|
|
34
|
+
static readonly repoOnlyRules: ReadonlySet<string> = new Set(["akan.error.bare-error"]);
|
|
35
|
+
|
|
36
|
+
static holdsBareErrors(pkgName: string | null, pkgRelativePath: string) {
|
|
37
|
+
const roots = pkgName === null ? undefined : MaintainabilityLimits.bareErrorRoots.get(pkgName);
|
|
38
|
+
return (roots ?? []).some((root) => pkgRelativePath.startsWith(root));
|
|
39
|
+
}
|
|
28
40
|
|
|
29
41
|
static fileKindOf(sysRelativePath: string): FileKind | null {
|
|
30
42
|
const kinds = Object.keys(MaintainabilityLimits.#maxLinesByKind) as FileKind[];
|
package/maintainabilityReport.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AbstractDoc } from "./abstractDoc";
|
|
2
|
+
import { MaintainabilityBaseline } from "./maintainabilityBaseline";
|
|
2
3
|
import type {
|
|
3
4
|
DebtRuleId,
|
|
4
5
|
MaintainabilityCommands,
|
|
@@ -7,7 +8,7 @@ import type {
|
|
|
7
8
|
} from "./maintainabilityContract";
|
|
8
9
|
import { type FileKind, MaintainabilityLimits } from "./maintainabilityLimits";
|
|
9
10
|
|
|
10
|
-
type FixKey = FileKind | "package" | Exclude<MaintainabilityRuleId, "akan.size.file">;
|
|
11
|
+
type FixKey = FileKind | "package" | "staleTotal" | Exclude<MaintainabilityRuleId, "akan.size.file">;
|
|
11
12
|
|
|
12
13
|
export class MaintainabilityReport {
|
|
13
14
|
static readonly #clientSplitFix =
|
|
@@ -48,8 +49,12 @@ export class MaintainabilityReport {
|
|
|
48
49
|
"A server write leaves the browser through one store action, started by one module component. Move a `fetch.<mutation>` into a store action, and hand `ui/` and `webkit/` code the write as an `onX` prop or hook argument from the module's `Zone` or `Util`: they stay free of any one model, and an agent or another screen reaches the write through the store.",
|
|
49
50
|
"akan.service.call-in-loop":
|
|
50
51
|
"Load the set once (`load<Model>Many(ids)`, one filter query) or write it once (`create<Model>Many(rows)`, one batch method), then loop in memory: a call per item is a round trip per item, so the endpoint slows as its input grows.",
|
|
52
|
+
"akan.error.bare-error":
|
|
53
|
+
"Construct an `AkanError` subclass from `akanjs/base` with a code the caller can branch on, and a `statusCode` when it answers a request: an existing class whose code fits, or a new one beside the code that fails; a class extending a built-in Error extends `AkanError` instead. A failure another process reported travels as itself. The package's total is what is held, so moving a bare Error between files passes and `akan lint` rewrites the entries.",
|
|
51
54
|
"akan.baseline.growth":
|
|
52
55
|
"Restore the entry from the default branch, then split the file or move the writes instead: `akan lint` lowers an entry as the code shrinks and never raises one.",
|
|
56
|
+
staleTotal:
|
|
57
|
+
"Run `akan verify <package>`, which lowers the section, and commit `akan.baseline.json` in this change: a recorded total above the code is room for a new failure anywhere. A bare `akan verify` covers only uncommitted files.",
|
|
53
58
|
};
|
|
54
59
|
|
|
55
60
|
static readonly #labels: { [key in FixKey]?: string } = {
|
|
@@ -65,7 +70,9 @@ export class MaintainabilityReport {
|
|
|
65
70
|
"akan.component.store-access": "component or hook reaching the store often",
|
|
66
71
|
"akan.write.placement": "write sent outside a store action or module component",
|
|
67
72
|
"akan.service.call-in-loop": "service call per item",
|
|
73
|
+
"akan.error.bare-error": "bare Error",
|
|
68
74
|
"akan.baseline.growth": "baseline entry",
|
|
75
|
+
staleTotal: "baseline total",
|
|
69
76
|
};
|
|
70
77
|
|
|
71
78
|
static readonly #units: { [rule in DebtRuleId]: string } = {
|
|
@@ -79,6 +86,7 @@ export class MaintainabilityReport {
|
|
|
79
86
|
"akan.component.write-chain": "writes",
|
|
80
87
|
"akan.write.placement": "writes",
|
|
81
88
|
"akan.service.call-in-loop": "calls per item",
|
|
89
|
+
"akan.error.bare-error": "bare Errors",
|
|
82
90
|
};
|
|
83
91
|
|
|
84
92
|
static describe(sysPath: string, failures: MaintainabilityFailure[], commands: MaintainabilityCommands = {}) {
|
|
@@ -118,13 +126,17 @@ export class MaintainabilityReport {
|
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
static #problemOf(failure: MaintainabilityFailure) {
|
|
129
|
+
if (failure.kind === "staleTotal")
|
|
130
|
+
return `records ${failure.recorded} ${MaintainabilityReport.#units[failure.rule]} in all, above the ${failure.count} the code holds`;
|
|
121
131
|
if (failure.kind === "baselineGrowth") {
|
|
122
132
|
const entry = `entry "${failure.key}" under ${failure.debtRule}`;
|
|
123
133
|
const units = MaintainabilityReport.#units[failure.debtRule];
|
|
134
|
+
if (failure.key === MaintainabilityBaseline.totalKey)
|
|
135
|
+
return `${failure.debtRule} allows ${failure.count} ${units} in all, above the ${failure.committed ?? 0} ${failure.measured ? `the code at ${failure.reference} holds with no entry for its rule recorded there` : `at ${failure.reference}`}; the baseline may only shrink`;
|
|
124
136
|
if (failure.measured)
|
|
125
137
|
return failure.committed === undefined
|
|
126
|
-
? `${entry} is debt the code at ${failure.reference} does not hold, and with no
|
|
127
|
-
: `${entry} allows ${failure.count} ${units}, above the ${failure.committed} the code at ${failure.reference} holds with no
|
|
138
|
+
? `${entry} is debt the code at ${failure.reference} does not hold, and with no entry for its rule recorded there the baseline may only shrink from that code`
|
|
139
|
+
: `${entry} allows ${failure.count} ${units}, above the ${failure.committed} the code at ${failure.reference} holds with no entry for its rule recorded there; the baseline may only shrink`;
|
|
128
140
|
return failure.committed === undefined
|
|
129
141
|
? `${entry} is not in the baseline at ${failure.reference}, which may only shrink`
|
|
130
142
|
: `${entry} allows ${failure.count} ${units}, above the ${failure.committed} at ${failure.reference}; the baseline may only shrink`;
|
|
@@ -143,6 +155,10 @@ export class MaintainabilityReport {
|
|
|
143
155
|
}
|
|
144
156
|
if (failure.kind === "writePlacement")
|
|
145
157
|
return `sends ${failure.writes.length} server write(s) from outside a store action or a module component: ${failure.writes.join(", ")}${grown}`;
|
|
158
|
+
if (failure.kind === "bareError") {
|
|
159
|
+
const plural = failure.lines.length === 1 ? "" : "s";
|
|
160
|
+
return `constructs or extends ${failure.lines.length} bare Error${plural}, on line${plural} ${failure.lines.join(", ")}${grown}, and the package's total grew`;
|
|
161
|
+
}
|
|
146
162
|
if (failure.kind === "loopCall")
|
|
147
163
|
return `service method ${failure.owner} calls once per item: ${failure.calls.join(", ")}${grown}`;
|
|
148
164
|
if (failure.kind === "classSize")
|
|
@@ -163,6 +179,7 @@ export class MaintainabilityReport {
|
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
static #fixKeyOf(sysPath: string, failure: MaintainabilityFailure): FixKey {
|
|
182
|
+
if (failure.kind === "staleTotal") return "staleTotal";
|
|
166
183
|
if (failure.rule !== "akan.size.file") return failure.rule;
|
|
167
184
|
if (sysPath.startsWith("pkgs/")) return "package";
|
|
168
185
|
return MaintainabilityLimits.fileKindOf(failure.file) ?? "akan.size.component";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { constantFieldNames, unlabeledFieldNames } from "./moduleFieldNames";
|
|
3
|
+
|
|
4
|
+
describe("moduleFieldNames", () => {
|
|
5
|
+
test("names each field the code declares, chained or not", () => {
|
|
6
|
+
const content = `export class TaskInput extends via((field) => ({
|
|
7
|
+
title: field(String),
|
|
8
|
+
tags: field(String).list().optional(),
|
|
9
|
+
workHistory: field([WorkHistory]),
|
|
10
|
+
})) {}
|
|
11
|
+
export class TaskObject extends via(TaskInput, (field) => ({
|
|
12
|
+
due: field(Date).optional(),
|
|
13
|
+
total: resolve<number>(),
|
|
14
|
+
})) {}
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
expect(constantFieldNames(content)).toEqual(["title", "tags", "workHistory", "due"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("leaves out a field that appears only in a comment or a string", () => {
|
|
21
|
+
const content = `export class TaskInput extends via((field) => ({
|
|
22
|
+
title: field(String),
|
|
23
|
+
// priority: field(TaskPriority, { default: "medium" })
|
|
24
|
+
/* assignee: field(ID).optional() */
|
|
25
|
+
note: field(String, { default: "completedAt: field(Date)" }),
|
|
26
|
+
})) {}
|
|
27
|
+
// ---- Expandable additional fields: ----
|
|
28
|
+
// - actor: field(String).optional() — who performed the action
|
|
29
|
+
export const example = \`previousStatus: field(TaskStatus)\`;
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
expect(constantFieldNames(content)).toEqual(["title", "note"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("unlabeledFieldNames finds a real label and reports one that is only commented out or inside a string", () => {
|
|
36
|
+
const constant = `export class TaskInput extends via((field) => ({
|
|
37
|
+
title: field(String),
|
|
38
|
+
content: field(String),
|
|
39
|
+
status: field(TaskStatus),
|
|
40
|
+
due: field(Date).optional(),
|
|
41
|
+
})) {}
|
|
42
|
+
`;
|
|
43
|
+
const dictionary = `export const dictionary = modelDictionary(["en", "ko"])
|
|
44
|
+
.model<Task>((t) => ({
|
|
45
|
+
title: t(["Title", "제목"]).desc(["Task title", "할 일 제목"]),
|
|
46
|
+
// content: t(["Content", "내용"]),
|
|
47
|
+
/* status: t(["Status", "상태"]), */
|
|
48
|
+
}))
|
|
49
|
+
.translate({
|
|
50
|
+
taskDueHint: ["due: pick a date", "마감: 날짜를 고르세요"],
|
|
51
|
+
});
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
expect(unlabeledFieldNames(constant, dictionary)).toEqual(["content", "status", "due"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
const isFieldCall = (node: ts.Expression): boolean =>
|
|
4
|
+
ts.isCallExpression(node) &&
|
|
5
|
+
(ts.isIdentifier(node.expression)
|
|
6
|
+
? node.expression.text === "field"
|
|
7
|
+
: ts.isPropertyAccessExpression(node.expression) && isFieldCall(node.expression.expression));
|
|
8
|
+
|
|
9
|
+
const propertyNames = (content: string, declares: (initializer: ts.Expression) => boolean) => {
|
|
10
|
+
const names: string[] = [];
|
|
11
|
+
const visit = (node: ts.Node) => {
|
|
12
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && declares(node.initializer))
|
|
13
|
+
names.push(node.name.text);
|
|
14
|
+
ts.forEachChild(node, visit);
|
|
15
|
+
};
|
|
16
|
+
visit(ts.createSourceFile("module.ts", content, ts.ScriptTarget.Latest));
|
|
17
|
+
return names;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const constantFieldNames = (content: string) => propertyNames(content, isFieldCall);
|
|
21
|
+
|
|
22
|
+
export const unlabeledFieldNames = (constantContent: string, dictionaryContent: string) => {
|
|
23
|
+
const labels = new Set(propertyNames(dictionaryContent, () => true));
|
|
24
|
+
return constantFieldNames(constantContent).filter((name) => !labels.has(name));
|
|
25
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.2.0-rc.
|
|
3
|
+
"version": "3.2.0-rc.10",
|
|
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.2.0-rc.
|
|
44
|
+
"@akanjs/language": "3.2.0-rc.10",
|
|
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.2.0-rc.
|
|
53
|
+
"akanjs": "3.2.0-rc.10",
|
|
54
54
|
"chalk": "^6.0.1",
|
|
55
55
|
"commander": "^15.0.0",
|
|
56
56
|
"dayjs": "^1.11.23",
|
package/preview/PreviewView.ts
CHANGED
|
@@ -77,18 +77,12 @@ export class PreviewView {
|
|
|
77
77
|
}: PreviewProbeOptions = {},
|
|
78
78
|
): Promise<PreviewProbeResult> {
|
|
79
79
|
const logs: string[] = [];
|
|
80
|
-
const view = PreviewView.#
|
|
80
|
+
const view = await PreviewView.#start(
|
|
81
|
+
() => PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) }),
|
|
82
|
+
engine,
|
|
83
|
+
{ startTimeoutMs, timeoutMs },
|
|
84
|
+
);
|
|
81
85
|
try {
|
|
82
|
-
//? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
|
|
83
|
-
const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
|
|
84
|
-
const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
|
|
85
|
-
await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled).then(
|
|
86
|
-
() => PreviewView.#stalled.delete(engine),
|
|
87
|
-
(error: unknown) => {
|
|
88
|
-
if (error === stalled) PreviewView.#stalled.add(engine);
|
|
89
|
-
throw error;
|
|
90
|
-
},
|
|
91
|
-
);
|
|
92
86
|
const deadline = Date.now() + timeoutMs;
|
|
93
87
|
const within = <T>(work: Promise<T>, what: string) =>
|
|
94
88
|
PreviewView.#within(
|
|
@@ -120,6 +114,33 @@ export class PreviewView {
|
|
|
120
114
|
}
|
|
121
115
|
}
|
|
122
116
|
|
|
117
|
+
static async #start(
|
|
118
|
+
open: () => WebViewLike,
|
|
119
|
+
engine: PreviewEngine,
|
|
120
|
+
{ startTimeoutMs, timeoutMs }: { startTimeoutMs: number; timeoutMs: number },
|
|
121
|
+
) {
|
|
122
|
+
for (let attempt = 1; ; attempt++) {
|
|
123
|
+
//? A browser that missed its start bound may still come up, so a later page retries it within only its own bound.
|
|
124
|
+
//? One that died or was closed instead is spawned afresh by the next view, which gets the full bound.
|
|
125
|
+
const startMs = PreviewView.#stalled.has(engine) ? Math.min(startTimeoutMs, timeoutMs) : startTimeoutMs;
|
|
126
|
+
const stalled = new Error(`Timed out starting the ${engine} browser after ${startMs / 1_000} s.`);
|
|
127
|
+
let view: WebViewLike | undefined;
|
|
128
|
+
try {
|
|
129
|
+
view = open();
|
|
130
|
+
await PreviewView.#within(view.navigate("about:blank"), Date.now() + startMs, stalled);
|
|
131
|
+
PreviewView.#stalled.delete(engine);
|
|
132
|
+
return view;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
await view?.close();
|
|
135
|
+
if (error === stalled) PreviewView.#stalled.add(engine);
|
|
136
|
+
else PreviewView.#stalled.delete(engine);
|
|
137
|
+
if (error === stalled || attempt > 1) throw error;
|
|
138
|
+
//? Bun reaps a browser that died only on a later timer turn; until then `new Bun.WebView()` throws "Failed to spawn Chrome".
|
|
139
|
+
await Bun.sleep(100);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
static async #load(
|
|
124
145
|
view: WebViewLike,
|
|
125
146
|
url: string,
|
|
@@ -14,7 +14,12 @@ const slowChromeRuns = PreviewView.isAvailable("chrome") && process.platform !==
|
|
|
14
14
|
const withSlowChrome = async (body: string, startSeconds = 3) => {
|
|
15
15
|
const dir = await mkdtemp(path.join(tmpdir(), "akan-slow-chrome-"));
|
|
16
16
|
const chrome = path.join(dir, "chrome");
|
|
17
|
-
|
|
17
|
+
const chromeLog = path.join(dir, "chrome.log");
|
|
18
|
+
//? Bun.WebView sends the browser's stderr to /dev/null, which would hide why it died.
|
|
19
|
+
await writeFile(
|
|
20
|
+
chrome,
|
|
21
|
+
`#!/bin/sh\nexec 2>>"${chromeLog}"\nsleep ${startSeconds}\nexec "${PreviewChrome.executable()}" "$@"\n`,
|
|
22
|
+
);
|
|
18
23
|
await chmod(chrome, 0o755);
|
|
19
24
|
const script = `const { PreviewView } = await import(${JSON.stringify(path.join(import.meta.dir, "PreviewView.ts"))});\n${body}`;
|
|
20
25
|
//? Chrome is spawned once per process, so only a fresh process takes the slow executable.
|
|
@@ -30,7 +35,10 @@ const withSlowChrome = async (body: string, startSeconds = 3) => {
|
|
|
30
35
|
new Response(child.stderr).text(),
|
|
31
36
|
child.exited,
|
|
32
37
|
]);
|
|
33
|
-
|
|
38
|
+
const chromeErr = await Bun.file(chromeLog)
|
|
39
|
+
.text()
|
|
40
|
+
.catch(() => "");
|
|
41
|
+
return { code, out: out.trim(), log: `${err}\nChrome's stderr:\n${chromeErr}` };
|
|
34
42
|
} finally {
|
|
35
43
|
child.kill();
|
|
36
44
|
await rm(dir, { recursive: true, force: true });
|
|
@@ -112,12 +120,12 @@ describe("PreviewView", () => {
|
|
|
112
120
|
async () => {
|
|
113
121
|
const server = serve("<!doctype html><title>Up</title><body>up</body>");
|
|
114
122
|
try {
|
|
115
|
-
const { code, out,
|
|
123
|
+
const { code, out, log } = await withSlowChrome(
|
|
116
124
|
`const probe = await PreviewView.probe("http://localhost:${server.port}/", { engine: "chrome", timeoutMs: 1_500 });
|
|
117
125
|
console.log(probe.text);`,
|
|
118
126
|
);
|
|
119
|
-
expect(out
|
|
120
|
-
expect(code).toBe(0);
|
|
127
|
+
expect(out, log).toBe("up");
|
|
128
|
+
expect(code, log).toBe(0);
|
|
121
129
|
} finally {
|
|
122
130
|
server.stop(true);
|
|
123
131
|
}
|
|
@@ -128,7 +136,7 @@ console.log(probe.text);`,
|
|
|
128
136
|
test.skipIf(!slowChromeRuns)(
|
|
129
137
|
"gives a later page only its own bound for a browser that missed its start, and recovers once it is up",
|
|
130
138
|
async () => {
|
|
131
|
-
const { code, out,
|
|
139
|
+
const { code, out, log } = await withSlowChrome(
|
|
132
140
|
`const open = () =>
|
|
133
141
|
PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
|
|
134
142
|
() => "opened",
|
|
@@ -137,7 +145,7 @@ console.log(probe.text);`,
|
|
|
137
145
|
console.log(await open());
|
|
138
146
|
console.log(await open());
|
|
139
147
|
let late = await open();
|
|
140
|
-
for (let tries = 0; late
|
|
148
|
+
for (let tries = 0; late.startsWith("Timed out starting") && tries < 100; tries++) late = await open();
|
|
141
149
|
console.log(late);
|
|
142
150
|
Bun.WebView.closeAll();
|
|
143
151
|
await Bun.sleep(500);
|
|
@@ -145,7 +153,7 @@ console.log(await open());
|
|
|
145
153
|
Bun.WebView.closeAll();`,
|
|
146
154
|
6,
|
|
147
155
|
);
|
|
148
|
-
expect(out
|
|
156
|
+
expect(out, log).toBe(
|
|
149
157
|
[
|
|
150
158
|
"Timed out starting the chrome browser after 1.5 s.",
|
|
151
159
|
"Timed out starting the chrome browser after 1 s.",
|
|
@@ -153,7 +161,66 @@ Bun.WebView.closeAll();`,
|
|
|
153
161
|
"Timed out starting the chrome browser after 1.5 s.",
|
|
154
162
|
].join("\n"),
|
|
155
163
|
);
|
|
156
|
-
expect(code).toBe(0);
|
|
164
|
+
expect(code, log).toBe(0);
|
|
165
|
+
},
|
|
166
|
+
browserTestMs,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
test.skipIf(!slowChromeRuns)(
|
|
170
|
+
"gives a browser killed after it missed its start the full bound for its next start",
|
|
171
|
+
async () => {
|
|
172
|
+
const { code, out, log } = await withSlowChrome(
|
|
173
|
+
`const open = () =>
|
|
174
|
+
PreviewView.probe("about:blank", { engine: "chrome", startTimeoutMs: 1_500, timeoutMs: 1_000 }).then(
|
|
175
|
+
() => "opened",
|
|
176
|
+
(error) => error.message,
|
|
177
|
+
);
|
|
178
|
+
console.log(await open());
|
|
179
|
+
const killed = open();
|
|
180
|
+
Bun.WebView.closeAll();
|
|
181
|
+
console.log(await killed);
|
|
182
|
+
Bun.WebView.closeAll();`,
|
|
183
|
+
6,
|
|
184
|
+
);
|
|
185
|
+
expect(out, log).toBe(
|
|
186
|
+
[
|
|
187
|
+
"Timed out starting the chrome browser after 1.5 s.",
|
|
188
|
+
"Timed out starting the chrome browser after 1.5 s.",
|
|
189
|
+
].join("\n"),
|
|
190
|
+
);
|
|
191
|
+
expect(code, log).toBe(0);
|
|
192
|
+
},
|
|
193
|
+
browserTestMs,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
test.skipIf(!slowChromeRuns)(
|
|
197
|
+
"starts a new browser for the page right after one that the browser died on",
|
|
198
|
+
async () => {
|
|
199
|
+
const { code, out, log } = await withSlowChrome(
|
|
200
|
+
`const { promise: loading, resolve } = Promise.withResolvers();
|
|
201
|
+
const server = Bun.serve({
|
|
202
|
+
port: 0,
|
|
203
|
+
fetch: (req) =>
|
|
204
|
+
new URL(req.url).pathname === "/hang"
|
|
205
|
+
? (resolve(), new Promise(() => undefined))
|
|
206
|
+
: new Response("<!doctype html><body>up</body>", { headers: { "content-type": "text/html" } }),
|
|
207
|
+
});
|
|
208
|
+
const open = (path) =>
|
|
209
|
+
PreviewView.probe(\`http://localhost:\${server.port}\${path}\`, { engine: "chrome" }).then(
|
|
210
|
+
(probe) => probe.text,
|
|
211
|
+
(error) => error.message,
|
|
212
|
+
);
|
|
213
|
+
const killed = open("/hang");
|
|
214
|
+
await loading;
|
|
215
|
+
Bun.WebView.closeAll();
|
|
216
|
+
console.error(await killed);
|
|
217
|
+
console.log(await open("/"));
|
|
218
|
+
server.stop(true);
|
|
219
|
+
Bun.WebView.closeAll();`,
|
|
220
|
+
0,
|
|
221
|
+
);
|
|
222
|
+
expect(out, log).toBe("up");
|
|
223
|
+
expect(code, log).toBe(0);
|
|
157
224
|
},
|
|
158
225
|
browserTestMs,
|
|
159
226
|
);
|