@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.
- package/abstractCompactor.ts +112 -0
- package/abstractDoc.test.ts +78 -0
- package/abstractDoc.ts +70 -0
- package/executors.ts +1 -1
- package/package.json +2 -2
- package/qualityScanner.test.ts +46 -0
- package/qualityScanner.ts +40 -2
|
@@ -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.
|
|
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.
|
|
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.
|
|
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(
|
|
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[] = [];
|