@akanjs/devkit 3.2.0-rc.0 → 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 +238 -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/createTunnel.test.ts +12 -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/PreviewChrome.ts +3 -3
- package/preview/PreviewView.ts +48 -9
- package/preview/previewView.test.ts +154 -3
- 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/PreviewChrome.ts
CHANGED
|
@@ -12,12 +12,12 @@ export class PreviewChrome {
|
|
|
12
12
|
/** Set this to drive Chrome on macOS too, e.g. to reproduce what a pod sees. */
|
|
13
13
|
static readonly backendEnvKey = "AKAN_CODE_WEBVIEW_BACKEND";
|
|
14
14
|
|
|
15
|
-
/** The executables Bun looks for on PATH before giving up. */
|
|
15
|
+
/** The executables Bun looks for on PATH before giving up, in its order: the first found is the one it starts. */
|
|
16
16
|
static readonly executables = [
|
|
17
|
-
"chromium",
|
|
18
|
-
"chromium-browser",
|
|
19
17
|
"google-chrome-stable",
|
|
20
18
|
"google-chrome",
|
|
19
|
+
"chromium-browser",
|
|
20
|
+
"chromium",
|
|
21
21
|
"brave-browser",
|
|
22
22
|
"microsoft-edge",
|
|
23
23
|
"chrome",
|
package/preview/PreviewView.ts
CHANGED
|
@@ -5,7 +5,8 @@ export interface PreviewProbeOptions {
|
|
|
5
5
|
width?: number;
|
|
6
6
|
height?: number;
|
|
7
7
|
engine?: PreviewEngine;
|
|
8
|
-
|
|
8
|
+
startTimeoutMs?: number;
|
|
9
|
+
/** Bounds the probe once the browser is up: a dev server that is recovering takes the connection and never answers. */
|
|
9
10
|
timeoutMs?: number;
|
|
10
11
|
/** Waited after the load, so the errors effects throw land before the page is read. */
|
|
11
12
|
settleMs?: number;
|
|
@@ -45,6 +46,10 @@ interface PageRead {
|
|
|
45
46
|
// Bun.WebView rather than a downloaded browser: on macOS its webkit backend needs no Chromium at all.
|
|
46
47
|
// It is experimental, so its whole surface stays behind this adapter; a Bun minor that changes it breaks one file.
|
|
47
48
|
export class PreviewView {
|
|
49
|
+
/** A view's first navigation includes the browser's start, which took ~35 s on a cold CI runner. */
|
|
50
|
+
static readonly startTimeoutMs = 120_000;
|
|
51
|
+
static readonly #stalled = new Set<PreviewEngine>();
|
|
52
|
+
|
|
48
53
|
static get available() {
|
|
49
54
|
return PreviewView.isAvailable();
|
|
50
55
|
}
|
|
@@ -66,15 +71,25 @@ export class PreviewView {
|
|
|
66
71
|
width = 1280,
|
|
67
72
|
height = 800,
|
|
68
73
|
engine = PreviewChrome.backend,
|
|
74
|
+
startTimeoutMs = PreviewView.startTimeoutMs,
|
|
69
75
|
timeoutMs = 30_000,
|
|
70
76
|
settleMs = 0,
|
|
71
77
|
}: PreviewProbeOptions = {},
|
|
72
78
|
): Promise<PreviewProbeResult> {
|
|
73
79
|
const logs: string[] = [];
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
const view = await PreviewView.#start(
|
|
81
|
+
() => PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) }),
|
|
82
|
+
engine,
|
|
83
|
+
{ startTimeoutMs, timeoutMs },
|
|
84
|
+
);
|
|
77
85
|
try {
|
|
86
|
+
const deadline = Date.now() + timeoutMs;
|
|
87
|
+
const within = <T>(work: Promise<T>, what: string) =>
|
|
88
|
+
PreviewView.#within(
|
|
89
|
+
work,
|
|
90
|
+
deadline,
|
|
91
|
+
new Error(`Timed out ${what} ${url}; the dev server may be rebuilding or stuck.`),
|
|
92
|
+
);
|
|
78
93
|
let page = await PreviewView.#load(view, url, settleMs, within);
|
|
79
94
|
//? Right after a save the first load can render nothing (`[rscClient] module not loaded`); the next one has it.
|
|
80
95
|
if (page.empty) {
|
|
@@ -99,6 +114,33 @@ export class PreviewView {
|
|
|
99
114
|
}
|
|
100
115
|
}
|
|
101
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
|
+
|
|
102
144
|
static async #load(
|
|
103
145
|
view: WebViewLike,
|
|
104
146
|
url: string,
|
|
@@ -126,13 +168,10 @@ export class PreviewView {
|
|
|
126
168
|
return response?.status ?? null;
|
|
127
169
|
}
|
|
128
170
|
|
|
129
|
-
static async #within<T>(work: Promise<T>, deadline: number,
|
|
171
|
+
static async #within<T>(work: Promise<T>, deadline: number, expiry: Error): Promise<T> {
|
|
130
172
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
131
173
|
const expired = new Promise<never>((_, reject) => {
|
|
132
|
-
timer = setTimeout(
|
|
133
|
-
() => reject(new Error(`Timed out ${what}; the dev server may be rebuilding or stuck.`)),
|
|
134
|
-
Math.max(0, deadline - Date.now()),
|
|
135
|
-
);
|
|
174
|
+
timer = setTimeout(() => reject(expiry), Math.max(0, deadline - Date.now()));
|
|
136
175
|
});
|
|
137
176
|
try {
|
|
138
177
|
return await Promise.race([work, expired]);
|