@akanjs/devkit 3.1.2-rc.4 → 3.2.0-rc.1

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