@akanjs/devkit 3.1.1-rc.8 → 3.1.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 (137) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +30 -0
  8. package/akanConfig/akanConfig.ts +10 -0
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/applicationBuildRunner.test.ts +17 -0
  13. package/applicationBuildRunner.ts +3 -1
  14. package/biome.base.json +4 -1
  15. package/cloud/cloudUpdates.ts +3 -2
  16. package/codeAgent/agent/CodeAgent.ts +4 -0
  17. package/codeAgent/agent/SubagentPool.ts +5 -0
  18. package/codeAgent/agent/akanSystemPrompt.ts +1 -1
  19. package/codeAgent/agent/codeAgent.test.ts +41 -2
  20. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  21. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  22. package/codeAgent/index.ts +2 -2
  23. package/codeAgent/skills/akan-agent/SKILL.md +36 -0
  24. package/codeAgent/skills/akan-code/SKILL.md +1 -0
  25. package/codeAgent/skills/akan-debug/SKILL.md +40 -6
  26. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  27. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  28. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  29. package/codeAgent/skills/akan-query/SKILL.md +38 -0
  30. package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
  31. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  32. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  33. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  34. package/codeAgent/tools/AkanCli.ts +2 -1
  35. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  36. package/codeAgent/tools/AkanEditScope.ts +8 -3
  37. package/codeAgent/tools/AkanToolPack.ts +4 -10
  38. package/codeAgent/tools/AkanVerifier.ts +13 -31
  39. package/codeAgent/tools/akanCodePlugins.test.ts +60 -58
  40. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  41. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  42. package/commentDensityScanner.test.ts +121 -0
  43. package/commentDensityScanner.ts +101 -0
  44. package/componentSizeScanner.test.ts +36 -0
  45. package/componentSizeScanner.ts +52 -0
  46. package/componentWriteChainScanner.test.ts +137 -0
  47. package/componentWriteChainScanner.ts +104 -0
  48. package/conventionSet.test.ts +73 -0
  49. package/conventionSet.ts +67 -0
  50. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  51. package/deprecatedCommentScanner.test.ts +54 -0
  52. package/deprecatedCommentScanner.ts +30 -0
  53. package/endpointReach.test.ts +58 -0
  54. package/endpointReach.ts +80 -0
  55. package/executors.test.ts +0 -4
  56. package/executors.ts +18 -2
  57. package/index.ts +1 -0
  58. package/integration/devStability.integration.test.ts +9 -5
  59. package/kindBranchScanner.test.ts +89 -0
  60. package/kindBranchScanner.ts +158 -0
  61. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  62. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  63. package/lint/no-oversized-list-limit.grit +73 -0
  64. package/linter.test.ts +12 -0
  65. package/linter.ts +35 -12
  66. package/maintainabilityBaseline.test.ts +280 -0
  67. package/maintainabilityBaseline.ts +220 -0
  68. package/maintainabilityContract.test.ts +846 -0
  69. package/maintainabilityContract.ts +404 -0
  70. package/maintainabilityExceptionScanner.test.ts +90 -0
  71. package/maintainabilityExceptionScanner.ts +85 -0
  72. package/maintainabilityLimits.ts +60 -0
  73. package/maintainabilityReport.test.ts +130 -0
  74. package/maintainabilityReport.ts +153 -0
  75. package/mobile/desktopRelease.test.ts +94 -23
  76. package/mobile/desktopRelease.ts +83 -45
  77. package/mobile/nativeApi.test.ts +4 -1
  78. package/mobile/nativeApi.ts +3 -3
  79. package/mobile/nativeApp.test.ts +2 -2
  80. package/mobile/nativeApp.ts +18 -11
  81. package/mobile/nativeConfig.test.ts +16 -8
  82. package/mobile/nativeConfig.ts +1 -1
  83. package/mobile/updateUploader.test.ts +34 -0
  84. package/mobile/updateUploader.ts +9 -6
  85. package/package.json +3 -2
  86. package/platformTest/LinuxDockerTarget.ts +3 -0
  87. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  88. package/preview/PreviewView.ts +219 -0
  89. package/preview/index.ts +2 -0
  90. package/preview/previewView.test.ts +76 -0
  91. package/prompter.ts +7 -0
  92. package/qualityScanner.test.ts +75 -0
  93. package/qualityScanner.ts +17 -1
  94. package/routeSourceValidator.test.ts +11 -2
  95. package/routeSourceValidator.ts +14 -2
  96. package/serviceLoopCallScanner.test.ts +56 -0
  97. package/serviceLoopCallScanner.ts +122 -0
  98. package/signalMutationIndex.test.ts +15 -0
  99. package/signalMutationIndex.ts +1 -1
  100. package/sourceComments.test.ts +45 -0
  101. package/sourceComments.ts +70 -0
  102. package/storeAccessScanner.test.ts +84 -0
  103. package/storeAccessScanner.ts +71 -0
  104. package/storeSource.ts +159 -0
  105. package/storeWriteScanner.test.ts +292 -0
  106. package/storeWriteScanner.ts +50 -0
  107. package/testOnlyExportScanner.test.ts +83 -0
  108. package/testOnlyExportScanner.ts +124 -0
  109. package/typeChecker.ts +32 -0
  110. package/typecheck/projectTypecheck.test.ts +18 -0
  111. package/typecheck/projectTypecheck.ts +29 -5
  112. package/typecheck/typecheck.proc.ts +6 -0
  113. package/typecheck/typecheckFailure.ts +13 -0
  114. package/types.ts +1 -0
  115. package/workflow/artifacts.ts +16 -2
  116. package/workflow/executor.ts +22 -7
  117. package/workflow/guardCatalog.test.ts +59 -0
  118. package/workflow/guardCatalog.ts +108 -0
  119. package/workflow/index.ts +4 -0
  120. package/workflow/interactionScaffold.ts +122 -0
  121. package/workflow/mutationScaffold.ts +519 -0
  122. package/workflow/mutationSource.test.ts +108 -0
  123. package/workflow/mutationSource.ts +219 -0
  124. package/workflow/plan.ts +54 -0
  125. package/workflow/render.ts +19 -4
  126. package/workflow/source.test.ts +185 -0
  127. package/workflow/source.ts +151 -24
  128. package/workflow/types.ts +18 -1
  129. package/workspaceLayout.ts +2 -0
  130. package/writePath.test.ts +109 -0
  131. package/writePath.ts +219 -0
  132. package/writePlacementScanner.test.ts +77 -0
  133. package/writePlacementScanner.ts +50 -0
  134. package/writingActionIndex.test.ts +99 -0
  135. package/writingActionIndex.ts +63 -0
  136. package/codeAgent/feedback/PreviewView.ts +0 -95
  137. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -0,0 +1,67 @@
1
+ import fsPromise from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface ConventionRule {
5
+ name: string;
6
+ description: string;
7
+ paths: string[];
8
+ body: string;
9
+ }
10
+
11
+ export class ConventionSet {
12
+ constructor(
13
+ readonly core: string,
14
+ readonly rules: ConventionRule[],
15
+ ) {}
16
+
17
+ static async load(dir: string) {
18
+ const core = await fsPromise.readFile(path.join(dir, "core.md"), "utf-8");
19
+ const files = (await fsPromise.readdir(path.join(dir, "rules"))).filter((file) => file.endsWith(".md")).sort();
20
+ const rules = await Promise.all(
21
+ files.map(async (file) =>
22
+ ConventionSet.parseRule(file.slice(0, -3), await fsPromise.readFile(path.join(dir, "rules", file), "utf-8")),
23
+ ),
24
+ );
25
+ return new ConventionSet(core.trim(), rules);
26
+ }
27
+
28
+ static parseRule(name: string, text: string): ConventionRule {
29
+ const match: RegExpExecArray | null = /^---\n([\s\S]*?)\n---\n/.exec(text);
30
+ if (!match) throw new Error(`Convention rule ${name} has no frontmatter`);
31
+ const front = match[1] ?? "";
32
+ const described: RegExpExecArray | null = /^description:\s*(.+)$/m.exec(front);
33
+ const description = described?.[1]?.trim() ?? "";
34
+ const paths = [...front.matchAll(/^\s+-\s+"([^"]+)"$/gm)].map((entry) => entry[1] ?? "");
35
+ if (!description || paths.length === 0) throw new Error(`Convention rule ${name} needs a description and paths`);
36
+ return { name, description, paths, body: text.slice(match[0].length).trim() };
37
+ }
38
+
39
+ //? Cursor splits `globs:` on commas, so a `{a,b}` brace list has to be expanded before it is written there.
40
+ static expand(glob: string): string[] {
41
+ const open = glob.indexOf("{");
42
+ if (open === -1) return [glob];
43
+ const close = glob.indexOf("}", open);
44
+ if (close === -1) return [glob];
45
+ const head = glob.slice(0, open);
46
+ const tail = glob.slice(close + 1);
47
+ return glob
48
+ .slice(open + 1, close)
49
+ .split(",")
50
+ .flatMap((choice) => ConventionSet.expand(`${head}${choice}${tail}`));
51
+ }
52
+
53
+ static demote(markdown: string) {
54
+ let fenced = false;
55
+ return markdown
56
+ .split("\n")
57
+ .map((line) => {
58
+ if (line.startsWith("```")) fenced = !fenced;
59
+ return !fenced && /^#{1,5} /.test(line) ? `#${line}` : line;
60
+ })
61
+ .join("\n");
62
+ }
63
+
64
+ get instruction() {
65
+ return `${[this.core, ...this.rules.map((rule) => ConventionSet.demote(rule.body))].join("\n\n")}\n`;
66
+ }
67
+ }
@@ -61,7 +61,12 @@ describe.skipIf(!CsrE2eHarness.enabled)("SSR dev registry (minimal)", () => {
61
61
  let ssr: CsrE2eHarness;
62
62
 
63
63
  beforeAll(async () => {
64
- ssr = await CsrE2eHarness.start({ app: "minimal", port, env: { AKAN_DEV_SSR_ARM_DELAY_MS: "3000" } });
64
+ ssr = await CsrE2eHarness.start({
65
+ app: "minimal",
66
+ port,
67
+ // The server-log assertions match verbose-level lines, which a workspace `.env` may or may not turn on.
68
+ env: { AKAN_DEV_SSR_ARM_DELAY_MS: "3000", AKAN_PUBLIC_LOG_LEVEL: "verbose" },
69
+ });
65
70
  }, 240_000);
66
71
 
67
72
  afterAll(async () => {
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { DeprecatedCommentScanner } from "./deprecatedCommentScanner";
4
+ import type { SourceFileInfo } from "./qualityScanner";
5
+
6
+ const fileOf = (file: string, content: string): SourceFileInfo => ({
7
+ file,
8
+ absolutePath: `/repo/${file}`,
9
+ content,
10
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
11
+ });
12
+
13
+ const scan = (file: string, lines: string[]) =>
14
+ new DeprecatedCommentScanner().scan([fileOf(file, `${lines.join("\n")}\n`)]);
15
+
16
+ describe("DeprecatedCommentScanner", () => {
17
+ test("flags a trailing, a leading and a block deprecated comment", () => {
18
+ const warnings = scan("apps/demo/lib/court/court.constant.ts", [
19
+ "export class CourtInput extends via((field) => ({",
20
+ " legacyVenue: field(String).optional(), // deprecated",
21
+ " // Deprecated: use courtId",
22
+ " legacyCourt: field(String).optional(),",
23
+ " /* DEPRECATED */ oldName: field(String).optional(),",
24
+ " /** deprecated, read venueId */ oldVenue: field(String).optional(),",
25
+ "})) {}",
26
+ ]);
27
+
28
+ expect(warnings.map((warning) => warning.line)).toEqual([2, 3, 5, 6]);
29
+ expect(warnings[0]).toMatchObject({ rule: "akan.comment.deprecated-marker", scope: "comment" });
30
+ expect(warnings[0]?.message).toContain('"// deprecated"');
31
+ });
32
+
33
+ test("leaves a JSDoc @deprecated tag and prose that only mentions deprecation", () => {
34
+ expect(
35
+ scan("libs/demo/ui/field.type.ts", [
36
+ "export interface FieldProps {",
37
+ " /** @deprecated Ignored: the field renders `value`. */",
38
+ " defaultValue?: string;",
39
+ " /**",
40
+ " * Old name.",
41
+ " * @deprecated use `value`",
42
+ " */",
43
+ " initial?: string;",
44
+ "}",
45
+ "// Replaces the deprecated upstream plugin.",
46
+ "export const notice = '// deprecated';",
47
+ ]),
48
+ ).toEqual([]);
49
+ });
50
+
51
+ test("stays out of tests", () => {
52
+ expect(scan("apps/demo/lib/court/court.signal.test.ts", ["const venue = 1; // deprecated"])).toEqual([]);
53
+ });
54
+ });
@@ -0,0 +1,30 @@
1
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
2
+ import { type SourceComment, SourceComments } from "./sourceComments";
3
+
4
+ export class DeprecatedCommentScanner {
5
+ static readonly rule = "akan.comment.deprecated-marker";
6
+ static #marker = /^[!?*\s]*(?:(?:TODO|FIXME|XXX|NOTE)\b\s*:?\s*)?@?deprecated\b/i;
7
+
8
+ scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
9
+ return sourceFiles
10
+ .filter(({ file }) => /^(apps|libs)\//.test(file) && !/\.(test|spec)\.tsx?$/.test(file))
11
+ .flatMap(({ file, sourceFile }) =>
12
+ SourceComments.of(sourceFile)
13
+ .filter((comment) => DeprecatedCommentScanner.#isLooseMarker(comment))
14
+ .map((comment) => ({
15
+ rule: DeprecatedCommentScanner.rule,
16
+ scope: "comment" as const,
17
+ severity: "warning" as const,
18
+ file,
19
+ line: comment.line,
20
+ message: `"${comment.raw.split(/\r?\n/)[0]?.trim().slice(0, 80)}" marks nothing at the use sites; editors strike through only a JSDoc @deprecated tag.`,
21
+ })),
22
+ );
23
+ }
24
+
25
+ static #isLooseMarker({ isJsDoc, body }: SourceComment) {
26
+ const text = body.filter((line) => line !== "").join(" ");
27
+ if (isJsDoc && /(^|\s)@deprecated\b/.test(text)) return false;
28
+ return DeprecatedCommentScanner.#marker.test(text);
29
+ }
30
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { EndpointReach } from "./endpointReach";
4
+
5
+ const fileOf = (file: string, content: string) => ({
6
+ file,
7
+ sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true),
8
+ });
9
+
10
+ describe("EndpointReach", () => {
11
+ test("follows an endpoint or a slice through its service's own and other services' methods, not a job", () => {
12
+ const reach = new EndpointReach(
13
+ [
14
+ fileOf(
15
+ "apps/demo/lib/court/court.signal.ts",
16
+ `export class CourtInternal extends internal(srv.court, ({ cron }) => ({
17
+ sweep: cron("0 * * * *").exec(function () { return this.courtService.sweep(); }),
18
+ })) {}
19
+ export class CourtSlice extends slice(srv.court, { guards: { root: Admin } }, (init) => ({
20
+ inOrg: init().exec(function (orgId) { return this.courtService.queryInOrg(orgId); }),
21
+ })) {}
22
+ export class CourtEndpoint extends endpoint(srv.court, ({ mutation }) => ({
23
+ assign: mutation(Boolean).exec(function (id) { return this.courtService.assign(id); }),
24
+ })) {}
25
+ `,
26
+ ),
27
+ ],
28
+ [
29
+ fileOf(
30
+ "apps/demo/lib/court/court.service.ts",
31
+ `export class CourtService extends serve(db.court, () => ({})) {
32
+ queryInOrg(orgId: string) { return { orgId }; }
33
+ async assign(id: string) { return await this.#pick(id); }
34
+ async #pick(id: string) { return await this.pcService.claim(id); }
35
+ async sweep() { return await this.#pick("all"); }
36
+ }
37
+ `,
38
+ ),
39
+ fileOf(
40
+ "libs/shared/lib/_pc/pc.service.ts",
41
+ `export class PcService extends serve("pc", () => ({})) {
42
+ async claim(id: string) { return id; }
43
+ async release(id: string) { return id; }
44
+ }
45
+ `,
46
+ ),
47
+ ],
48
+ );
49
+
50
+ expect(EndpointReach.serviceOf("libs/shared/lib/_pc/pc.service.ts")).toBe("pc");
51
+ expect(
52
+ ["court.queryInOrg", "court.assign", "court.#pick", "pc.claim", "court.sweep", "pc.release"].map((key) => {
53
+ const [service = "", method = ""] = key.split(".");
54
+ return reach.reaches(service, method);
55
+ }),
56
+ ).toEqual([true, true, true, true, false, false]);
57
+ });
58
+ });
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import type { SourceFileInfo } from "./qualityScanner";
4
+
5
+ type ServiceMethod = [service: string, method: string];
6
+
7
+ //? Only `endpoint(…)` and `slice(…)` answer a caller who waits; an `internal(…)` job (cron, interval, queue) works
8
+ //? per item on purpose, so that one failing item leaves the rest done.
9
+ export class EndpointReach {
10
+ static readonly #requestBuilders = new Set(["endpoint", "slice"]);
11
+ readonly #reached = new Set<string>();
12
+
13
+ constructor(
14
+ signalFiles: Pick<SourceFileInfo, "sourceFile">[],
15
+ serviceFiles: Pick<SourceFileInfo, "file" | "sourceFile">[],
16
+ ) {
17
+ const methodsByService = new Map<string, Map<string, ts.MethodDeclaration>>();
18
+ for (const { file, sourceFile } of serviceFiles) {
19
+ const methods = methodsByService.get(EndpointReach.serviceOf(file)) ?? new Map<string, ts.MethodDeclaration>();
20
+ for (const cls of sourceFile.statements.filter(ts.isClassDeclaration))
21
+ for (const member of cls.members)
22
+ if (
23
+ ts.isMethodDeclaration(member) &&
24
+ member.body &&
25
+ (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name))
26
+ )
27
+ methods.set(member.name.text, member);
28
+ methodsByService.set(EndpointReach.serviceOf(file), methods);
29
+ }
30
+ const pending: ServiceMethod[] = [];
31
+ const reach = ([service, method]: ServiceMethod) => {
32
+ const key = `${service}.${method}`;
33
+ if (this.#reached.has(key) || !methodsByService.get(service)?.has(method)) return;
34
+ this.#reached.add(key);
35
+ pending.push([service, method]);
36
+ };
37
+ for (const { sourceFile } of signalFiles)
38
+ for (const cls of sourceFile.statements.filter(ts.isClassDeclaration)) {
39
+ const base = cls.heritageClauses?.[0]?.types[0]?.expression;
40
+ if (
41
+ base &&
42
+ ts.isCallExpression(base) &&
43
+ ts.isIdentifier(base.expression) &&
44
+ EndpointReach.#requestBuilders.has(base.expression.text)
45
+ )
46
+ EndpointReach.#serviceCallsIn(base, null).forEach(reach);
47
+ }
48
+ for (let next = pending.pop(); next; next = pending.pop()) {
49
+ const [service, method] = next;
50
+ const declaration = methodsByService.get(service)?.get(method);
51
+ if (declaration) EndpointReach.#serviceCallsIn(declaration, service).forEach(reach);
52
+ }
53
+ }
54
+
55
+ static serviceOf(file: string) {
56
+ return path.posix.basename(file).replace(/\.service\.ts$/, "");
57
+ }
58
+
59
+ reaches(service: string, method: string) {
60
+ return this.#reached.has(`${service}.${method}`);
61
+ }
62
+
63
+ static #serviceCallsIn(node: ts.Node, ownService: string | null) {
64
+ const calls: ServiceMethod[] = [];
65
+ const visit = (child: ts.Node) => {
66
+ if (ts.isCallExpression(child) && ts.isPropertyAccessExpression(child.expression)) {
67
+ const { expression: owner, name } = child.expression;
68
+ if (owner.kind === ts.SyntaxKind.ThisKeyword && ownService) calls.push([ownService, name.text]);
69
+ else if (ts.isPropertyAccessExpression(owner) && owner.expression.kind === ts.SyntaxKind.ThisKeyword) {
70
+ const field = owner.name.text;
71
+ if (field.length > "Service".length && field.endsWith("Service"))
72
+ calls.push([field.slice(0, -"Service".length), name.text]);
73
+ }
74
+ }
75
+ ts.forEachChild(child, visit);
76
+ };
77
+ visit(node);
78
+ return calls;
79
+ }
80
+ }
package/executors.test.ts CHANGED
@@ -275,10 +275,6 @@ describe("Executor filesystem helpers", () => {
275
275
  "Akan workspace agent guide",
276
276
  );
277
277
  expect(await readFile(path.join(root, "workspace/AGENTS.md"), "utf8")).toContain("sample Agent Guide");
278
- expect(await readFile(path.join(root, "workspace/docs/AI-DEVELOPMENT.md"), "utf8")).toContain(
279
- "AI Development Guide",
280
- );
281
- expect(await readFile(path.join(root, "workspace/docs/GENERATED.md"), "utf8")).toContain("Generated Akan Files");
282
278
  // Rules live in the package's base config, so a framework release reaches an existing workspace on `bun update`.
283
279
  expect(await readFile(path.join(root, "workspace/biome.json"), "utf8")).toContain(
284
280
  '"extends": ["@akanjs/devkit/biome.base.json"]',
package/executors.ts CHANGED
@@ -49,6 +49,7 @@ import { JsonFormatter } from "./jsonFormatter";
49
49
  import { Linter } from "./linter";
50
50
  import { ManifestDependencies } from "./manifestDependencies";
51
51
  import { resolveRepoName } from "./repoIdentity";
52
+ import type { RouteSourceInfo } from "./routeSourceValidator";
52
53
  import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
53
54
  import { Spinner } from "./spinner";
54
55
  // Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
@@ -66,6 +67,12 @@ export interface PageRoot {
66
67
  keyPrefix: string;
67
68
  }
68
69
 
70
+ export interface AppRouteSource {
71
+ key: string;
72
+ absPath: string;
73
+ info: RouteSourceInfo;
74
+ }
75
+
69
76
  export interface WrittenFileContent extends FileContent {
70
77
  written: boolean;
71
78
  }
@@ -557,8 +564,8 @@ export class Executor {
557
564
  return fileContents;
558
565
  }
559
566
 
560
- // A template cannot sort the identifiers it emits (`Task` vs `Zoo`). Best-effort: `create-akan-workspace`
561
- // scaffolds before `bun install`, with no local Biome binary yet.
567
+ // Best-effort: `create-akan-workspace` scaffolds before `bun install`, with no local Biome binary yet, so a
568
+ // template emits Biome's form itself (`templates/biomeForm.ts`).
562
569
  async #formatAppliedTemplate(fileContents: FileContent[]) {
563
570
  const filePaths = fileContents
564
571
  .map((fileContent) => fileContent.filePath)
@@ -1278,6 +1285,7 @@ export class AppExecutor extends SysExecutor {
1278
1285
  }
1279
1286
 
1280
1287
  #pageKeys: string[] | null = null;
1288
+ #routeSources: AppRouteSource[] = [];
1281
1289
  #excludeDevOnlyPages = false;
1282
1290
  async getPageKeys({ refresh }: { refresh?: boolean } = {}): Promise<string[]> {
1283
1291
  if (this.#pageKeys && !refresh) return this.#pageKeys;
@@ -1288,6 +1296,7 @@ export class AppExecutor extends SysExecutor {
1288
1296
  const devOnlyKeys = new Set<string>();
1289
1297
  const devOnlyDirs: string[] = [];
1290
1298
  const pageRoots = await this.getPageRoots();
1299
+ const routeSources: AppRouteSource[] = [];
1291
1300
  const libKeyPrefixes = pageRoots.map((root) => root.keyPrefix).filter(Boolean);
1292
1301
  for (const root of pageRoots) {
1293
1302
  if (!(await FileSys.dirExists(root.dir))) continue;
@@ -1333,6 +1342,7 @@ export class AppExecutor extends SysExecutor {
1333
1342
  rootLayout: isRootLayout,
1334
1343
  pattern: parsed.pattern,
1335
1344
  });
1345
+ routeSources.push({ key, absPath, info });
1336
1346
  if (info.devOnly) {
1337
1347
  devOnlyKeys.add(key);
1338
1348
  //* A dev-only layout takes its whole subtree, or its pages would ship without their chrome.
@@ -1343,9 +1353,15 @@ export class AppExecutor extends SysExecutor {
1343
1353
  }
1344
1354
  }
1345
1355
  pageKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1356
+ this.#routeSources = routeSources.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
1346
1357
  this.#pageKeys = this.#excludeDevOnlyPages ? this.#dropDevOnlyPages(pageKeys, devOnlyKeys, devOnlyDirs) : pageKeys;
1347
1358
  return this.#pageKeys;
1348
1359
  }
1360
+ /** Each page and layout file `getPageKeys` read, with what its chain declares; overrides files are not listed. */
1361
+ async getRouteSources({ refresh }: { refresh?: boolean } = {}): Promise<AppRouteSource[]> {
1362
+ await this.getPageKeys({ refresh });
1363
+ return this.#routeSources;
1364
+ }
1349
1365
  #dropDevOnlyPages(pageKeys: string[], devOnlyKeys: Set<string>, devOnlyDirs: string[]): string[] {
1350
1366
  if (!devOnlyKeys.size) return pageKeys;
1351
1367
  const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
package/index.ts CHANGED
@@ -19,6 +19,7 @@ export type * from "./fileSys";
19
19
  export type * from "./frontendBuild";
20
20
  export type * from "./getRelatedCnsts";
21
21
  export type * from "./incrementalBuilder";
22
+ export type * from "./maintainabilityContract";
22
23
  export type * from "./mobile";
23
24
  export type * from "./packageExportsMap";
24
25
  export type * from "./prompter";
@@ -1,4 +1,5 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
+ import { AkanAppHost } from "../akanApp";
2
3
  import { DevGeneratedIndexSync } from "../frontendBuild";
3
4
  import { DevStabilityHarness, type DevStabilityHmrProbe } from "./devStabilityHarness";
4
5
 
@@ -467,7 +468,8 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
467
468
  const harness = await createHarness();
468
469
  const port = await harness.resolvePort();
469
470
  // Child 0's deterministic ws port, held the way an orphaned replica from a killed run would hold it.
470
- const blocker = Bun.serve({ port: port + 10_000, fetch: () => new Response("occupied") });
471
+ //? On the child's own 127.0.0.1: macOS lets a loopback bind share a port a wildcard listener holds.
472
+ const blocker = Bun.serve({ port: port + 10_000, hostname: "127.0.0.1", fetch: () => new Response("occupied") });
471
473
  try {
472
474
  const host = await harness.startHost();
473
475
  await host.waitForLog(/falling back to an ephemeral port/);
@@ -583,9 +585,9 @@ describe("dev resource budgets", () => {
583
585
  expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
584
586
  });
585
587
 
586
- //? Between a fresh builder and one that has built a route, or the host stops enforcing it or never recycles.
587
- //? macOS keeps the bundler arenas (~380MiB built); Linux measured 101-115MiB fresh, 157-176MiB built and settled.
588
- const CEILING_MB = process.platform === "linux" ? 135 : 200;
588
+ //? Between a fresh builder and one that has built a route, or the host stops enforcing it or never recycles. Linux
589
+ //? measured 101-115MiB fresh, 157-176MiB built and settled; macOS's footprint (Bun 1.4.4) 65MiB fresh, 109MiB built.
590
+ const CEILING_MB = process.platform === "darwin" ? 87 : 135;
589
591
 
590
592
  budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
591
593
  const harness = await createHarness();
@@ -635,9 +637,11 @@ describe("dev resource budgets", () => {
635
637
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
636
638
  }
637
639
  const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
640
+ //? Read as the host reads it: on macOS `ps` also counts the pages the builder freed.
641
+ const settledRss = settled ? await AkanAppHost.readProcessRssBytes(settled.pid) : null;
638
642
  const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
639
643
  //? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
640
- if ((settled?.rssBytes ?? 0) >= CEILING_MB * MB && !warned)
644
+ if ((settledRss ?? 0) >= CEILING_MB * MB && !warned)
641
645
  await host.waitForLogSince(
642
646
  settledFrom,
643
647
  /ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import ts from "typescript";
3
+ import { KindBranchScanner } from "./kindBranchScanner";
4
+ import type { SourceFileInfo } from "./qualityScanner";
5
+
6
+ const filesOf = (files: Record<string, string>): SourceFileInfo[] =>
7
+ Object.entries(files).map(([file, content]) => ({
8
+ file,
9
+ absolutePath: `/repo/${file}`,
10
+ content,
11
+ sourceFile: ts.createSourceFile(
12
+ file,
13
+ content,
14
+ ts.ScriptTarget.Latest,
15
+ true,
16
+ file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
17
+ ),
18
+ }));
19
+
20
+ const pinConstant = `import { enumOf } from "akanjs/base";
21
+ export class PinKind extends enumOf("pinKind", ["videoScene", "videoTake", "memo"] as const) {}
22
+ export class PinStatus extends enumOf("pinStatus", ["active", "videoTake"] as const) {}
23
+ `;
24
+ const comparing = (expression: string) => `export const isIt = (pin: { kind: string }) => ${expression};\n`;
25
+
26
+ describe("KindBranchScanner", () => {
27
+ test("warns on a kind value compared in three files outside its module, naming them and the table to declare", () => {
28
+ const warnings = new KindBranchScanner().scan(
29
+ filesOf({
30
+ "apps/demo/lib/pin/pin.constant.ts": pinConstant,
31
+ "apps/demo/lib/pin/Pin.Zone.tsx": comparing('pin.kind === "videoScene"'),
32
+ "apps/demo/lib/board/Board.Zone.tsx": comparing('pin.kind === "videoScene"'),
33
+ "apps/demo/lib/take/take.store.ts": comparing('"videoScene" !== pin.kind'),
34
+ "apps/demo/ui/PinCard.tsx": `export const label = (kind: string) => {\n switch (kind) {\n case "videoScene":\n return 1;\n }\n};\n`,
35
+ "apps/demo/ui/PinCard.test.tsx": comparing('pin.kind === "videoScene"'),
36
+ }),
37
+ );
38
+
39
+ expect(warnings).toHaveLength(1);
40
+ expect(warnings[0]).toMatchObject({
41
+ rule: "akan.global.kind-branch",
42
+ severity: "warning",
43
+ file: "apps/demo/lib/pin/pin.constant.ts",
44
+ line: 2,
45
+ message: '"videoScene" of cnst.PinKind is branched on in 3 files outside the enum\'s module.',
46
+ locations: [
47
+ { file: "apps/demo/lib/board/Board.Zone.tsx", line: 1 },
48
+ { file: "apps/demo/lib/take/take.store.ts", line: 1 },
49
+ { file: "apps/demo/ui/PinCard.tsx", line: 3 },
50
+ ],
51
+ });
52
+ expect(warnings[0]?.fix).toContain('{ [kind in cnst.PinKind["value"]]: … }');
53
+ });
54
+
55
+ test("reads only a subject named for a kind, and only enums named for one", () => {
56
+ const warnings = new KindBranchScanner().scan(
57
+ filesOf({
58
+ "apps/demo/lib/pin/pin.constant.ts": pinConstant,
59
+ "apps/demo/lib/a/A.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
60
+ "apps/demo/lib/b/B.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
61
+ "apps/demo/lib/c/C.Zone.tsx": comparing('pin.status === "active" || label === "videoScene"'),
62
+ }),
63
+ );
64
+
65
+ expect(warnings).toEqual([]);
66
+ });
67
+
68
+ test("groups the enums sharing a value, reached from the app's own lib or a lib, never another app or lib", () => {
69
+ const warnings = new KindBranchScanner().scan(
70
+ filesOf({
71
+ "apps/demo/lib/pin/pin.constant.ts": pinConstant,
72
+ "libs/media/lib/clip/clip.constant.ts": `export class ClipType extends enumOf("clipType", ["videoTake"] as const) {}\n`,
73
+ "apps/demo/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
74
+ "apps/demo/lib/b/B.Zone.tsx": comparing('pin.kind === "videoTake"'),
75
+ "apps/demo/lib/c/C.Zone.tsx": comparing('pin.kind === "videoTake"'),
76
+ "apps/other/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
77
+ "apps/other/lib/b/B.Zone.tsx": comparing('pin.kind === "videoTake"'),
78
+ "libs/shared/lib/a/A.Zone.tsx": comparing('pin.kind === "videoTake"'),
79
+ }),
80
+ );
81
+
82
+ expect(warnings.map(({ file, message }) => ({ file, message }))).toEqual([
83
+ {
84
+ file: "apps/demo/lib/pin/pin.constant.ts",
85
+ message: '"videoTake" of cnst.PinKind / cnst.ClipType is branched on in 3 files outside the enum\'s module.',
86
+ },
87
+ ]);
88
+ });
89
+ });