@akanjs/devkit 2.4.2-rc.1 → 2.4.2-rc.3

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.
@@ -0,0 +1,112 @@
1
+ import { Logger } from "akanjs/common";
2
+ import chalk from "chalk";
3
+ import { AbstractDoc, type AbstractKind } from "./abstractDoc";
4
+ import { AiSession } from "./aiEditor";
5
+ import type { SysExecutor } from "./executors";
6
+
7
+ export interface AbstractCompactOptions {
8
+ module?: string | null;
9
+ minLines?: number;
10
+ interactive?: boolean;
11
+ }
12
+
13
+ export interface AbstractCompactReport {
14
+ path: string;
15
+ beforeLines: number;
16
+ afterLines: number;
17
+ status: "compacted" | "unchanged" | "failed";
18
+ }
19
+
20
+ /** Rewrites bloated `*.abstract.md` files down to the invariants their source files cannot show. */
21
+ export class AbstractCompactor {
22
+ static readonly #reviewRequest = `Review the compacted file you just wrote against the original.
23
+ - Did you drop an invariant, a security reason, or a workflow step that the source files cannot show by themselves? Restore it.
24
+ - Did you keep anything that only restates code, scaffold wording, or history? Remove it.
25
+ - Is it in the same language as the original, within the line budget, and one short line per bullet?
26
+ Respond with exactly one \`\`\`markdown code block holding the corrected file and nothing else. If nothing needs to change, repeat the file unchanged.`;
27
+
28
+ #sys: SysExecutor;
29
+ #minLines: number;
30
+ #interactive: boolean;
31
+ constructor(
32
+ sys: SysExecutor,
33
+ { minLines = AbstractDoc.compactMinLines, interactive = false }: AbstractCompactOptions = {},
34
+ ) {
35
+ this.#sys = sys;
36
+ this.#minLines = minLines;
37
+ this.#interactive = interactive;
38
+ }
39
+ async compactAll({ module }: { module?: string | null } = {}) {
40
+ const docs = await AbstractDoc.findAll(this.#sys, { module });
41
+ const targets = docs.filter((doc) => doc.lineCount > this.#minLines);
42
+ const reports: AbstractCompactReport[] = [];
43
+ // One file at a time: each doc gets its own AI session, and parallel runs would interleave the
44
+ // streamed response with each other and with the interactive confirm prompt.
45
+ for (const doc of targets) reports.push(await this.compact(doc));
46
+ return { scanned: docs.length, reports };
47
+ }
48
+ async compact(doc: AbstractDoc): Promise<AbstractCompactReport> {
49
+ const session = new AiSession("compactAbstract", {
50
+ workspace: this.#sys.workspace,
51
+ cacheKey: `${this.#sys.name}-${doc.moduleName}`,
52
+ });
53
+ const approve = !this.#interactive;
54
+ const compacted = await session.editMarkdown(this.#request(doc), { approve });
55
+ const reviewed = await session.editMarkdown(AbstractCompactor.#reviewRequest, { approve });
56
+ // The review answers in prose when it finds nothing to fix, so the first compaction is the fallback.
57
+ const next = [reviewed, compacted]
58
+ .map((candidate) => candidate.trim())
59
+ .find((candidate) => doc.canReplaceWith(candidate));
60
+ const beforeLines = doc.lineCount;
61
+ if (!next) {
62
+ Logger.rawLog(chalk.yellow(`${doc.path}: the editor returned no shorter abstract, keeping the current file`));
63
+ return { path: doc.path, beforeLines, afterLines: beforeLines, status: "failed" };
64
+ }
65
+ if (next === doc.content.trim())
66
+ return { path: doc.path, beforeLines, afterLines: beforeLines, status: "unchanged" };
67
+ const content = `${next}\n`;
68
+ await this.#sys.writeFile(doc.path, content);
69
+ return { path: doc.path, beforeLines, afterLines: AbstractDoc.lineCountOf(content), status: "compacted" };
70
+ }
71
+ #targetShape(kind: AbstractKind) {
72
+ if (kind === "other")
73
+ return " - keep the headings that still carry information and drop the rest; do not impose a new structure";
74
+ return [
75
+ " - `# <name> Abstract` title line",
76
+ " - one declarative sentence naming what this module owns",
77
+ " - `## Rules` — two to five bullets, each an invariant the code cannot show by itself",
78
+ " - optional `## Workflow` — the lifecycle as an arrow chain (`draft -> signed -> active`) or a few bullets",
79
+ ].join("\n");
80
+ }
81
+ #request(doc: AbstractDoc) {
82
+ return `You are compacting an Akan.js abstract file.
83
+
84
+ An abstract is the summary a coding agent reads before changing a module. Repeated edits have made this one
85
+ bloated, and it has to shrink back to what the source files cannot show by themselves.
86
+
87
+ # Target file
88
+ ${this.#sys.type}s/${this.#sys.name}/${doc.path} (${doc.kind} module "${doc.moduleName}", ${doc.lineCount} lines)
89
+
90
+ # Source files in the same folder
91
+ ${doc.siblingFiles.join(", ") || "none"}
92
+
93
+ # Current content
94
+ \`\`\`markdown
95
+ ${doc.content}
96
+ \`\`\`
97
+
98
+ # Rules for the compacted file
99
+ - Write it in the SAME language as the current content. Never translate.
100
+ - Keep every invariant, constraint, security reason, and workflow step that the source files cannot show by
101
+ themselves. Merge duplicates instead of dropping either one.
102
+ - Delete what the code already states: field lists, types, labels, signatures, file lists, and imports.
103
+ - Delete scaffold and placeholder wording, changelog or migration history, TODOs, and any instruction about
104
+ reading or updating this file.
105
+ - Never invent a rule that the current content does not state.
106
+ - Keep every bullet to one short line, and stay under ${AbstractDoc.compactMinLines} lines in total.
107
+ - Shape:
108
+ ${this.#targetShape(doc.kind)}
109
+
110
+ Respond with exactly one \`\`\`markdown code block holding the whole new file and nothing else.`;
111
+ }
112
+ }
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { AbstractDoc } from "./abstractDoc";
6
+ import { AppExecutor, WorkspaceExecutor } from "./executors";
7
+
8
+ const tempRoots: string[] = [];
9
+
10
+ const makeApp = async (files: Record<string, string>) => {
11
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-abstract-doc-"));
12
+ tempRoots.push(root);
13
+ const appName = "abstractDemo";
14
+ for (const [filePath, content] of Object.entries(files)) {
15
+ const absolutePath = path.join(root, "apps", appName, filePath);
16
+ await mkdir(path.dirname(absolutePath), { recursive: true });
17
+ await writeFile(absolutePath, content);
18
+ }
19
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "repo" });
20
+ return AppExecutor.from(workspace, appName);
21
+ };
22
+
23
+ afterEach(async () => {
24
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
25
+ });
26
+
27
+ describe("AbstractDoc.kindOf", () => {
28
+ test("reads the module kind off the sys-relative path", () => {
29
+ expect(AbstractDoc.kindOf("lib/user/user.abstract.md")).toBe("domain");
30
+ expect(AbstractDoc.kindOf("lib/_payment/payment.abstract.md")).toBe("service");
31
+ expect(AbstractDoc.kindOf("lib/__scalar/money/money.abstract.md")).toBe("scalar");
32
+ expect(AbstractDoc.kindOf("ui/Editor/editor.abstract.md")).toBe("other");
33
+ expect(AbstractDoc.kindOf("lib/user/nested/user.abstract.md")).toBe("other");
34
+ expect(AbstractDoc.kindOf("lib/__scalar/money.abstract.md")).toBe("other");
35
+ });
36
+ });
37
+
38
+ describe("AbstractDoc.canReplaceWith", () => {
39
+ const doc = new AbstractDoc("lib/user/user.abstract.md", ["# user Abstract", "a", "b", "c", "d", ""].join("\n"), []);
40
+
41
+ test("accepts a shorter markdown file", () => {
42
+ expect(doc.canReplaceWith("# user Abstract\nowns users.\n## Rules\n- one rule")).toBe(true);
43
+ });
44
+
45
+ test("rejects prose, a longer file, and a stub", () => {
46
+ expect(doc.canReplaceWith("The abstract already meets the requirements.")).toBe(false);
47
+ expect(doc.canReplaceWith(["# user Abstract", "a", "b", "c", "d", "e"].join("\n"))).toBe(false);
48
+ expect(doc.canReplaceWith("# user Abstract\nowns users.")).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("AbstractDoc.findAll", () => {
53
+ test("finds abstracts across facet folders and filters by module", async () => {
54
+ const app = await makeApp({
55
+ "lib/user/user.abstract.md": "# user Abstract\n",
56
+ "lib/user/user.constant.ts": "export class User {}\n",
57
+ "lib/_payment/payment.abstract.md": "# payment Abstract\n",
58
+ "lib/__scalar/money/money.abstract.md": "# money Abstract\n",
59
+ "ui/Editor/editor.abstract.md": "# Editor Abstract\n",
60
+ "lib/user/user.document.ts": "export class UserModel {}\n",
61
+ });
62
+
63
+ const docs = await AbstractDoc.findAll(app);
64
+ expect(docs.map((doc) => doc.path)).toEqual([
65
+ "lib/__scalar/money/money.abstract.md",
66
+ "lib/_payment/payment.abstract.md",
67
+ "lib/user/user.abstract.md",
68
+ "ui/Editor/editor.abstract.md",
69
+ ]);
70
+ expect(docs.map((doc) => doc.kind)).toEqual(["scalar", "service", "domain", "other"]);
71
+
72
+ const [userDoc] = await AbstractDoc.findAll(app, { module: "user" });
73
+ expect(userDoc?.siblingFiles.sort()).toEqual(["user.constant.ts", "user.document.ts"]);
74
+
75
+ const [serviceDoc] = await AbstractDoc.findAll(app, { module: "_payment" });
76
+ expect(serviceDoc?.path).toBe("lib/_payment/payment.abstract.md");
77
+ });
78
+ });
package/abstractDoc.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { SysExecutor } from "./executors";
2
+
3
+ export type AbstractKind = "domain" | "service" | "scalar" | "other";
4
+
5
+ /** One `*.abstract.md` file: the agent-facing summary a module carries next to its source. */
6
+ export class AbstractDoc {
7
+ static readonly suffix = ".abstract.md";
8
+ /** `akan quality` warns above this and points at `akan compact`. */
9
+ static readonly maxLines = 300;
10
+ /** `akan compact` floor, and the line budget it compacts down to, so a compacted file is never a candidate again. */
11
+ static readonly compactMinLines = 40;
12
+ // Abstracts only live in facet folders; globbing the sys root instead would walk node_modules.
13
+ static readonly #facetRoots = "{lib,ui,webkit,srvkit,common,plugin}";
14
+
15
+ static isAbstractPath(filePath: string) {
16
+ return filePath.endsWith(AbstractDoc.suffix);
17
+ }
18
+ static lineCountOf(content: string) {
19
+ return content.split(/\r?\n/).length;
20
+ }
21
+ /** Module kind read off the sys-relative path; anything outside a `lib/` module folder is "other". */
22
+ static kindOf(filePath: string): AbstractKind {
23
+ const [root, folder, ...rest] = filePath.split("/");
24
+ if (root !== "lib" || !folder) return "other";
25
+ if (folder === "__scalar") return rest.length === 2 ? "scalar" : "other";
26
+ if (rest.length !== 1) return "other";
27
+ return folder.startsWith("_") ? "service" : "domain";
28
+ }
29
+ static async findAll(sys: SysExecutor, { module }: { module?: string | null } = {}) {
30
+ const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`)).sort();
31
+ const docs = await Promise.all(filePaths.map((filePath) => AbstractDoc.read(sys, filePath)));
32
+ if (!module) return docs;
33
+ return docs.filter((doc) => doc.moduleName === module || doc.folderName === module);
34
+ }
35
+ static async read(sys: SysExecutor, filePath: string) {
36
+ const dirPath = filePath.split("/").slice(0, -1).join("/");
37
+ const [content, folderFiles] = await Promise.all([sys.readFile(filePath), sys.readdir(dirPath)]);
38
+ return new AbstractDoc(
39
+ filePath,
40
+ content,
41
+ folderFiles.filter((fileName) => !AbstractDoc.isAbstractPath(fileName)),
42
+ );
43
+ }
44
+
45
+ constructor(
46
+ readonly path: string,
47
+ readonly content: string,
48
+ readonly siblingFiles: string[],
49
+ ) {}
50
+ get kind() {
51
+ return AbstractDoc.kindOf(this.path);
52
+ }
53
+ get folderName() {
54
+ return this.path.split("/").at(-2) ?? "";
55
+ }
56
+ get moduleName() {
57
+ return (this.path.split("/").at(-1) ?? "").slice(0, -AbstractDoc.suffix.length);
58
+ }
59
+ get lineCount() {
60
+ return AbstractDoc.lineCountOf(this.content);
61
+ }
62
+ // An AI editor answers in prose when it decides nothing needs changing, and that prose would silently
63
+ // replace the abstract — so a candidate must look like a markdown file and be shorter than what it replaces.
64
+ canReplaceWith(candidate: string) {
65
+ const next = candidate.trim();
66
+ if (!next.startsWith("#")) return false;
67
+ const lineCount = AbstractDoc.lineCountOf(next);
68
+ return lineCount >= 3 && lineCount < this.lineCount;
69
+ }
70
+ }
package/executors.ts CHANGED
@@ -1451,7 +1451,7 @@ export class AppExecutor extends SysExecutor {
1451
1451
  if (!devOnlyKeys.size) return pageKeys;
1452
1452
  const isDevOnly = (key: string) => devOnlyKeys.has(key) || devOnlyDirs.some((dir) => key.startsWith(dir));
1453
1453
  const dropped = pageKeys.filter(isDevOnly);
1454
- this.log(`[route] excluded ${dropped.length} dev-only route file(s) from the build: ${dropped.join(", ")}`);
1454
+ this.verbose(`[route] excluded ${dropped.length} dev-only route file(s) from the build: ${dropped.join(", ")}`);
1455
1455
  return pageKeys.filter((key) => !isDevOnly(key));
1456
1456
  }
1457
1457
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.2-rc.1",
3
+ "version": "2.4.2-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "2.4.2-rc.1",
47
+ "akanjs": "2.4.2-rc.3",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "daisyui": "5.5.23",
@@ -0,0 +1,46 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { AbstractDoc } from "./abstractDoc";
6
+ import { AkanQualityScanner } from "./qualityScanner";
7
+
8
+ const tempRoots: string[] = [];
9
+
10
+ const makeWorkspace = async (files: Record<string, string>) => {
11
+ const root = await mkdtemp(path.join(os.tmpdir(), "akan-quality-scanner-"));
12
+ tempRoots.push(root);
13
+ for (const [filePath, content] of Object.entries({ ".gitignore": "node_modules\n", ...files })) {
14
+ const absolutePath = path.join(root, filePath);
15
+ await mkdir(path.dirname(absolutePath), { recursive: true });
16
+ await writeFile(absolutePath, content);
17
+ }
18
+ return root;
19
+ };
20
+
21
+ const abstractOf = (lineNum: number) =>
22
+ ["# post Abstract", ...Array.from({ length: lineNum - 1 }, (_, idx) => `- rule ${idx}`)].join("\n");
23
+
24
+ afterEach(async () => {
25
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
26
+ });
27
+
28
+ describe("AkanQualityScanner abstract rule", () => {
29
+ test("warns on an abstract over the line limit and points at akan compact", async () => {
30
+ const root = await makeWorkspace({
31
+ "apps/demo/lib/post/post.abstract.md": abstractOf(AbstractDoc.maxLines + 1),
32
+ "apps/demo/lib/post/post.constant.ts": "export class Post {}\n",
33
+ "libs/shared/lib/user/user.abstract.md": abstractOf(AbstractDoc.maxLines),
34
+ "libs/shared/lib/user/user.constant.ts": "export class User {}\n",
35
+ });
36
+
37
+ const result = await new AkanQualityScanner().scan(root);
38
+ const warnings = result.warnings.filter((warning) => warning.rule === "akan.file.abstract-max-lines");
39
+
40
+ expect(result.scannedFiles).toBe(4);
41
+ expect(warnings).toHaveLength(1);
42
+ expect(warnings[0]?.file).toBe("apps/demo/lib/post/post.abstract.md");
43
+ expect(warnings[0]?.message).toContain(`${AbstractDoc.maxLines + 1} lines`);
44
+ expect(warnings[0]?.fix).toContain("akan compact");
45
+ });
46
+ });
package/qualityScanner.ts CHANGED
@@ -3,6 +3,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import ignore from "ignore";
5
5
  import ts from "typescript";
6
+ import { AbstractDoc } from "./abstractDoc";
6
7
 
7
8
  type QualitySeverity = "warning";
8
9
  type QualityScope = "global" | "file" | "convention" | "layout";
@@ -32,6 +33,11 @@ interface SourceFileInfo {
32
33
  sourceFile: ts.SourceFile;
33
34
  }
34
35
 
36
+ interface TextFileInfo {
37
+ file: string;
38
+ content: string;
39
+ }
40
+
35
41
  interface ExportedFunctionLike {
36
42
  name: string;
37
43
  kind: "class" | "function" | "function-variable";
@@ -142,6 +148,8 @@ const RULE_FIXES: Record<string, string> = {
142
148
  "akan.file.recommended-max-lines":
143
149
  "Split the file by responsibility — move Zones, Utils, or subcomponents into sibling files.",
144
150
  "akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
151
+ "akan.file.abstract-max-lines":
152
+ "Run `akan compact <app-or-lib>` to rewrite the abstract with the AI editor, keeping only the invariants and workflows the source files cannot show.",
145
153
  "akan.file.placeholder-export":
146
154
  "Remove the placeholder export; generated indexes should only re-export real modules.",
147
155
  "akan.file.dictionary-stale-text": "Replace the scaffold text with real localized copy for this dictionary entry.",
@@ -172,18 +180,28 @@ function getRuleFix(rule: string): string | undefined {
172
180
  export class AkanQualityScanner {
173
181
  async scan(workspaceRoot: string): Promise<QualityScanResult> {
174
182
  const targetFiles = await this.#collectTargetFiles(workspaceRoot);
175
- const sourceFiles = await Promise.all(targetFiles.map((file) => this.#readSourceFile(workspaceRoot, file)));
183
+ const sourceFiles = await Promise.all(
184
+ targetFiles
185
+ .filter((file) => !AbstractDoc.isAbstractPath(file))
186
+ .map((file) => this.#readSourceFile(workspaceRoot, file)),
187
+ );
188
+ const abstractFiles = await Promise.all(
189
+ targetFiles
190
+ .filter((file) => AbstractDoc.isAbstractPath(file))
191
+ .map((file) => this.#readTextFile(workspaceRoot, file)),
192
+ );
176
193
  const warnings = [
177
194
  ...this.#scanGlobalQuality(sourceFiles),
178
195
  ...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
179
196
  ...sourceFiles.flatMap((sourceFile) => this.#scanComponentQuality(sourceFile)),
180
197
  ...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
181
198
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
199
+ ...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
182
200
  ];
183
201
 
184
202
  return {
185
203
  workspaceRoot,
186
- scannedFiles: sourceFiles.length,
204
+ scannedFiles: sourceFiles.length + abstractFiles.length,
187
205
  warnings: warnings
188
206
  .map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
189
207
  .sort(compareWarnings),
@@ -226,6 +244,8 @@ export class AkanQualityScanner {
226
244
  }
227
245
  if ((relativePath.endsWith(".ts") || relativePath.endsWith(".tsx")) && !relativePath.endsWith(".d.ts")) {
228
246
  files.push(relativePath);
247
+ } else if (AbstractDoc.isAbstractPath(relativePath)) {
248
+ files.push(relativePath);
229
249
  }
230
250
  }
231
251
  }
@@ -241,6 +261,10 @@ export class AkanQualityScanner {
241
261
  };
242
262
  }
243
263
 
264
+ async #readTextFile(workspaceRoot: string, file: string): Promise<TextFileInfo> {
265
+ return { file, content: await readFile(path.join(workspaceRoot, file), "utf8") };
266
+ }
267
+
244
268
  #scanGlobalQuality(sourceFiles: SourceFileInfo[]): QualityWarning[] {
245
269
  const exportedFunctionLikes = sourceFiles.flatMap((sourceFile) => getExportedFunctionLikes(sourceFile));
246
270
  const warnings: QualityWarning[] = [];
@@ -391,6 +415,20 @@ export class AkanQualityScanner {
391
415
  return warnings;
392
416
  }
393
417
 
418
+ #scanAbstractQuality({ file, content }: TextFileInfo): QualityWarning[] {
419
+ const lineCount = AbstractDoc.lineCountOf(content);
420
+ if (lineCount <= AbstractDoc.maxLines) return [];
421
+ return [
422
+ {
423
+ rule: "akan.file.abstract-max-lines",
424
+ scope: "file",
425
+ severity: "warning",
426
+ file,
427
+ message: `Abstract has ${lineCount} lines. Keep abstracts under ${AbstractDoc.maxLines} lines and compact them periodically.`,
428
+ },
429
+ ];
430
+ }
431
+
394
432
  #scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
395
433
  const segments = sourceFile.file.split("/");
396
434
  const warnings: QualityWarning[] = [];