@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +238 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/createTunnel.test.ts +12 -2
  44. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  45. package/errorCodeReference.test.ts +282 -0
  46. package/errorCodeReference.ts +71 -0
  47. package/errorCodeScan.ts +551 -0
  48. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  49. package/frontendBuild/allRoutesBuilder.ts +24 -6
  50. package/frontendBuild/buildRouteClient.test.ts +43 -0
  51. package/frontendBuild/bundleDefine.ts +2 -0
  52. package/frontendBuild/clientBuildTypes.ts +5 -2
  53. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  54. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  55. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  56. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  57. package/frontendBuild/csrDevBundler.ts +12 -2
  58. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  59. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  60. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  61. package/frontendBuild/csrDevPatcher.ts +47 -7
  62. package/frontendBuild/csrDevTypes.ts +16 -0
  63. package/frontendBuild/cssCompiler.test.ts +63 -1
  64. package/frontendBuild/devBarrelImports.ts +132 -0
  65. package/frontendBuild/devRegistryBundler.ts +19 -4
  66. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  67. package/frontendBuild/nativeCore.test.ts +148 -0
  68. package/frontendBuild/precompressArtifacts.ts +1 -1
  69. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  70. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  71. package/frontendBuild/ssrDevBundler.ts +13 -2
  72. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  73. package/frontendBuild/vendorImportScanner.ts +25 -10
  74. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  75. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  76. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  77. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  78. package/lint/no-import-common-err.grit +31 -0
  79. package/lint/no-init-fetch-in-client.grit +1 -1
  80. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  81. package/maintainabilityBaseline.test.ts +49 -0
  82. package/maintainabilityBaseline.ts +73 -8
  83. package/maintainabilityContract.test.ts +302 -1
  84. package/maintainabilityContract.ts +87 -22
  85. package/maintainabilityExceptionScanner.test.ts +14 -0
  86. package/maintainabilityExceptionScanner.ts +5 -1
  87. package/maintainabilityLimits.ts +12 -0
  88. package/maintainabilityReport.ts +20 -3
  89. package/moduleFieldNames.test.ts +56 -0
  90. package/moduleFieldNames.ts +25 -0
  91. package/package.json +3 -3
  92. package/preview/PreviewChrome.ts +3 -3
  93. package/preview/PreviewView.ts +48 -9
  94. package/preview/previewView.test.ts +154 -3
  95. package/routeJsBudget.test.ts +47 -11
  96. package/routeJsBudget.ts +88 -25
  97. package/transforms/barrelAnalyzer.ts +46 -1
  98. package/transforms/barrelImportsPlugin.ts +61 -45
  99. package/transforms/transforms.test.ts +67 -1
  100. package/typecheck/projectTypecheck.ts +5 -1
  101. package/typecheck/typecheck.proc.ts +3 -2
  102. package/typecheck/typecheckFailure.ts +6 -1
  103. package/workflow/artifacts.ts +25 -7
  104. package/workflow/fieldType.ts +92 -0
  105. package/workflow/index.ts +2 -0
  106. package/workflow/plainName.ts +28 -0
  107. package/workflow/plan.ts +67 -22
  108. package/workflow/source.ts +0 -5
  109. package/workspaceGraph.test.ts +200 -3
  110. 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
- export type MaintainabilityFailure = MaintainabilityViolation | BaselineGrowth;
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
- ...(reference.text === null ? { measured: true } : {}),
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(file, this.#sourceOf(workspaceFile, content).sourceFile);
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
- static #measurePackageFile(file: string, sourceFile: ts.SourceFile) {
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, 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.
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
- : MaintainabilityExceptionScanner.#correctnessFix,
86
+ : rule === "akan.error.bare-error"
87
+ ? MaintainabilityExceptionScanner.#bareErrorFix
88
+ : MaintainabilityExceptionScanner.#correctnessFix,
85
89
  };
86
90
  }
87
91
 
@@ -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[];
@@ -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 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`;
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.0",
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.0",
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.0",
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",
@@ -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",
@@ -5,7 +5,8 @@ export interface PreviewProbeOptions {
5
5
  width?: number;
6
6
  height?: number;
7
7
  engine?: PreviewEngine;
8
- /** Bounds the whole probe: a dev server that is recovering takes the connection and never answers. */
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 deadline = Date.now() + timeoutMs;
75
- const within = <T>(work: Promise<T>, what: string) => PreviewView.#within(work, deadline, `${what} ${url}`);
76
- const view = PreviewView.#open({ width, height, engine, onConsole: (line) => logs.push(line) });
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, what: string): Promise<T> {
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]);