@akanjs/devkit 2.4.2-rc.2 → 3.0.0-alpha.0
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/agentsIndex.test.ts +84 -0
- package/agentsIndex.ts +146 -0
- package/akanContext.ts +122 -0
- package/executors.ts +26 -0
- package/frontendBuild/frontendBuild.test.ts +3 -1
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -0
- package/frontendBuild/styleContract.ts +29 -0
- package/frontendBuild/styleGuard.test.ts +165 -0
- package/frontendBuild/styleGuard.ts +322 -0
- package/frontendBuild/themeValidator.test.ts +70 -0
- package/frontendBuild/themeValidator.ts +150 -0
- package/index.ts +1 -0
- package/lint/no-arbitrary-color.grit +19 -0
- package/lint/no-daisyui-legacy-class.grit +20 -0
- package/lint/no-inline-color.grit +19 -0
- package/lint/no-interpolated-arbitrary-class.grit +24 -0
- package/lint/no-raw-palette-class.grit +25 -0
- package/package.json +2 -3
- package/qualityScanner.test.ts +46 -0
- package/qualityScanner.ts +40 -2
- package/recipeScanner.test.ts +183 -0
- package/recipeScanner.ts +233 -0
- package/scanInfo.ts +3 -0
- package/transforms/externalizeFrameworkPlugin.ts +2 -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
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
AGENT_BLOCK_END,
|
|
7
|
+
AGENT_BLOCK_START,
|
|
8
|
+
collectScopeRecipeSources,
|
|
9
|
+
extractAgentBlock,
|
|
10
|
+
renderRecipeEntries,
|
|
11
|
+
renderScopeAgentBlock,
|
|
12
|
+
renderScopeAgentsMd,
|
|
13
|
+
upsertAgentBlock,
|
|
14
|
+
} from "./agentsIndex";
|
|
15
|
+
import type { RecipeInfo } from "./recipeScanner";
|
|
16
|
+
|
|
17
|
+
const button: RecipeInfo = {
|
|
18
|
+
name: "buttonRecipe",
|
|
19
|
+
importFrom: "akanjs/ui",
|
|
20
|
+
variants: { variant: ["primary", "ghost"], size: ["sm", "md"] },
|
|
21
|
+
defaultVariants: { variant: "primary", size: "md" },
|
|
22
|
+
doc: "버튼 look",
|
|
23
|
+
};
|
|
24
|
+
const appCard: RecipeInfo = { name: "appCard", importFrom: "@apps/minimal/ui", variants: { tone: ["muted", "glass"] } };
|
|
25
|
+
|
|
26
|
+
describe("renderRecipeEntries", () => {
|
|
27
|
+
test("groups by import path and marks defaults", () => {
|
|
28
|
+
const entries = renderRecipeEntries([button, appCard]);
|
|
29
|
+
expect(entries).toContain("Import from `akanjs/ui`:");
|
|
30
|
+
expect(entries).toContain("Import from `@apps/minimal/ui`:");
|
|
31
|
+
expect(entries).toContain("`buttonRecipe`(variant: primary*|ghost · size: sm|md*) — 버튼 look");
|
|
32
|
+
expect(entries).toContain("`appCard`(tone: muted|glass)");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("renderScopeAgentBlock", () => {
|
|
37
|
+
test("carries the sync/lint contract and the scope's entries", () => {
|
|
38
|
+
const block = renderScopeAgentBlock({ type: "app", name: "minimal" }, [appCard]);
|
|
39
|
+
expect(block).toContain("## Recipes In Scope");
|
|
40
|
+
expect(block).toContain("akan sync minimal");
|
|
41
|
+
expect(block).toContain("akan lint minimal");
|
|
42
|
+
expect(block).toContain("`appCard`");
|
|
43
|
+
});
|
|
44
|
+
test("empty scope points to authoring instead of listing nothing", () => {
|
|
45
|
+
const block = renderScopeAgentBlock({ type: "lib", name: "util" }, []);
|
|
46
|
+
expect(block).toContain("No scope recipes yet");
|
|
47
|
+
expect(block).toContain("libs/util/ui/Recipe/<name>.ts");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("upsertAgentBlock / extractAgentBlock", () => {
|
|
52
|
+
test("round-trips: fresh file → replace block → extract equals block", () => {
|
|
53
|
+
const fresh = renderScopeAgentsMd({ type: "app", name: "minimal" }, "OLD");
|
|
54
|
+
expect(extractAgentBlock(fresh)).toBe("OLD");
|
|
55
|
+
const updated = upsertAgentBlock(
|
|
56
|
+
fresh.replace("markers freely.", "markers freely.\n\nMy hand-written note."),
|
|
57
|
+
"NEW",
|
|
58
|
+
);
|
|
59
|
+
expect(extractAgentBlock(updated)).toBe("NEW");
|
|
60
|
+
expect(updated).toContain("My hand-written note.");
|
|
61
|
+
expect(updated).not.toContain("OLD");
|
|
62
|
+
});
|
|
63
|
+
test("appends markers to a file that has none", () => {
|
|
64
|
+
const updated = upsertAgentBlock("# hand-written\n\ncontent\n", "BLOCK");
|
|
65
|
+
expect(updated).toContain("# hand-written");
|
|
66
|
+
expect(updated.indexOf(AGENT_BLOCK_START)).toBeLessThan(updated.indexOf("BLOCK"));
|
|
67
|
+
expect(updated.trimEnd().endsWith(AGENT_BLOCK_END)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("collectScopeRecipeSources", () => {
|
|
72
|
+
test("collects own + dependency lib recipes, never the framework's", async () => {
|
|
73
|
+
const root = await mkdtemp(path.join(tmpdir(), "agents-index-"));
|
|
74
|
+
const write = async (rel: string, content: string) => {
|
|
75
|
+
await mkdir(path.dirname(path.join(root, rel)), { recursive: true });
|
|
76
|
+
await writeFile(path.join(root, rel), content);
|
|
77
|
+
};
|
|
78
|
+
await write("apps/minimal/ui/Recipe/appCard.ts", `export const appCard = recipe(tv({ base: "x" }));`);
|
|
79
|
+
await write("libs/shared/ui/Recipe/panel.ts", `export const panelRecipe = recipe(tv({ base: "y" }));`);
|
|
80
|
+
await write("pkgs/akanjs/ui/recipe/buttonRecipe.ts", `export const buttonRecipe = recipe(tv({ base: "z" }));`);
|
|
81
|
+
const sources = await collectScopeRecipeSources(root, { type: "app", name: "minimal" }, ["shared"]);
|
|
82
|
+
expect(sources.map((source) => source.importFrom).sort()).toEqual(["@apps/minimal/ui", "@libs/shared/ui"]);
|
|
83
|
+
});
|
|
84
|
+
});
|
package/agentsIndex.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { RecipeInfo, RecipeSource } from "./recipeScanner";
|
|
2
|
+
|
|
3
|
+
// recipeScanner 는 상단에서 typescript(~65MB)를 끌어온다. 이 모듈은 executors(CLI 엔트리 그래프)에
|
|
4
|
+
// 상주하므로 스캔 스택은 첫 사용 시점에 지연 로드한다 — 정적 import 로 되돌리면 entryModuleGraph 테스트가 깨진다.
|
|
5
|
+
let recipeScannerLoad: Promise<typeof import("./recipeScanner")> | null = null;
|
|
6
|
+
const loadRecipeScanner = () => (recipeScannerLoad ??= import("./recipeScanner"));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* agentsIndex — 스코프별 에이전트 색인의 단일 렌더러.
|
|
10
|
+
*
|
|
11
|
+
* 색인은 소유 경계로 쪼갠다: 루트 AGENTS.md 는 프레임워크(akanjs/ui) 레시피만 싣고, 각 앱/lib 은
|
|
12
|
+
* 자기 스코프에서 import 가능한 레시피(own + 의존 lib)를 자기 AGENTS.md 에 싣는다. 항상 로드되는
|
|
13
|
+
* 컨텍스트가 앱 수에 비례해 커지는 것과, import 불가능한 이웃 앱 레시피가 환상을 유발하는 것을 막는다.
|
|
14
|
+
*
|
|
15
|
+
* 신선도는 두 지점이 보장한다: `SysExecutor.scan(write)` 가 재생성하고(akan sync/build/start 가 전부
|
|
16
|
+
* 지나가는 길목), `akan lint` 가 스캔 결과와 커밋된 블록을 비교해 stale 이면 실패시킨다.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const AGENT_BLOCK_START = "<!-- akan:agent:start -->";
|
|
20
|
+
export const AGENT_BLOCK_END = "<!-- akan:agent:end -->";
|
|
21
|
+
|
|
22
|
+
export interface AgentsIndexScope {
|
|
23
|
+
type: "app" | "lib";
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Replace the content between the akan:agent markers, preserving everything else; append when absent. */
|
|
28
|
+
export const upsertAgentBlock = (existing: string, block: string): string => {
|
|
29
|
+
const managed = `${AGENT_BLOCK_START}\n${block}\n${AGENT_BLOCK_END}`;
|
|
30
|
+
const startIndex = existing.indexOf(AGENT_BLOCK_START);
|
|
31
|
+
const endIndex = existing.indexOf(AGENT_BLOCK_END);
|
|
32
|
+
if (startIndex >= 0 && endIndex > startIndex) {
|
|
33
|
+
return `${existing.slice(0, startIndex)}${managed}${existing.slice(endIndex + AGENT_BLOCK_END.length)}`;
|
|
34
|
+
}
|
|
35
|
+
return `${existing.replace(/\s*$/, "")}\n\n${managed}\n`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The content between the akan:agent markers, or null when the file carries no managed block. */
|
|
39
|
+
export const extractAgentBlock = (content: string): string | null => {
|
|
40
|
+
const startIndex = content.indexOf(AGENT_BLOCK_START);
|
|
41
|
+
const endIndex = content.indexOf(AGENT_BLOCK_END);
|
|
42
|
+
if (startIndex < 0 || endIndex <= startIndex) return null;
|
|
43
|
+
return content.slice(startIndex + AGENT_BLOCK_START.length, endIndex).trim();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Variant signature — the full consumption contract, so an agent never has to open the recipe
|
|
47
|
+
// file (and pull its css bodies into context) just to call one. `*` = default, `key?` = boolean flag.
|
|
48
|
+
const signatureOf = (recipe: RecipeInfo): string => {
|
|
49
|
+
const entries = Object.entries(recipe.variants);
|
|
50
|
+
if (entries.length === 0) return "";
|
|
51
|
+
const parts = entries.map(([key, values]) => {
|
|
52
|
+
if (values.length === 1 && values[0] === "true") return `${key}?`;
|
|
53
|
+
const def = recipe.defaultVariants?.[key];
|
|
54
|
+
return `${key}: ${values.map((value) => (value === def ? `${value}*` : value)).join("|")}`;
|
|
55
|
+
});
|
|
56
|
+
return `(${parts.join(" · ")})`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Recipes grouped by import path as markdown list blocks — the shared body of every recipe index. */
|
|
60
|
+
export const renderRecipeEntries = (recipes: RecipeInfo[]): string => {
|
|
61
|
+
const groups = new Map<string, RecipeInfo[]>();
|
|
62
|
+
for (const recipe of recipes) groups.set(recipe.importFrom, [...(groups.get(recipe.importFrom) ?? []), recipe]);
|
|
63
|
+
return [...groups.entries()]
|
|
64
|
+
.map(([importFrom, list]) => {
|
|
65
|
+
const items = list
|
|
66
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
67
|
+
.map((recipe) => `- \`${recipe.name}\`${signatureOf(recipe)}${recipe.doc ? ` — ${recipe.doc}` : ""}`)
|
|
68
|
+
.join("\n");
|
|
69
|
+
return `Import from \`${importFrom}\`:\n${items}`;
|
|
70
|
+
})
|
|
71
|
+
.join("\n\n");
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every recipe source importable from the scope: its own `ui/Recipe/` plus each dependency lib's.
|
|
76
|
+
* Framework recipes are excluded on purpose — they live in the root AGENTS.md, valid for every scope.
|
|
77
|
+
*/
|
|
78
|
+
export const collectScopeRecipeSources = async (
|
|
79
|
+
workspaceRoot: string,
|
|
80
|
+
scope: AgentsIndexScope,
|
|
81
|
+
libDeps: string[],
|
|
82
|
+
): Promise<RecipeSource[]> => {
|
|
83
|
+
const { collectRecipeSources } = await loadRecipeScanner();
|
|
84
|
+
const sources: RecipeSource[] = [
|
|
85
|
+
...(await collectRecipeSources(
|
|
86
|
+
`${workspaceRoot}/${scope.type}s/${scope.name}/ui`,
|
|
87
|
+
`@${scope.type}s/${scope.name}/ui`,
|
|
88
|
+
)),
|
|
89
|
+
];
|
|
90
|
+
for (const lib of [...new Set(libDeps)].sort()) {
|
|
91
|
+
if (scope.type === "lib" && lib === scope.name) continue;
|
|
92
|
+
sources.push(...(await collectRecipeSources(`${workspaceRoot}/libs/${lib}/ui`, `@libs/${lib}/ui`)));
|
|
93
|
+
}
|
|
94
|
+
return sources;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Collect + scan in one call, so consumers need no value import of the scanner stack. */
|
|
98
|
+
export const scanScopeRecipes = async (
|
|
99
|
+
workspaceRoot: string,
|
|
100
|
+
scope: AgentsIndexScope,
|
|
101
|
+
libDeps: string[],
|
|
102
|
+
): Promise<RecipeInfo[]> => {
|
|
103
|
+
const { scanRecipes } = await loadRecipeScanner();
|
|
104
|
+
return scanRecipes(await collectScopeRecipeSources(workspaceRoot, scope, libDeps));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The managed block of a scope AGENTS.md — deterministic, so lint can compare it against a re-scan. */
|
|
108
|
+
export const renderScopeAgentBlock = (scope: AgentsIndexScope, recipes: RecipeInfo[]): string => {
|
|
109
|
+
const scopePath = `${scope.type}s/${scope.name}`;
|
|
110
|
+
const intro = `## Recipes In Scope
|
|
111
|
+
|
|
112
|
+
UI recipes importable from \`${scopePath}\` code, **in addition to** the framework recipes indexed in the root
|
|
113
|
+
\`AGENTS.md\` \`## Recipes\`. Same contract: import by exact name, then \`<name>(variants?, className?)\` — the second
|
|
114
|
+
arg merges internally and takes an array too, so never wrap it in \`cn()\`. \`*\` marks the default, \`key?\` is a
|
|
115
|
+
boolean flag. Do not guess recipe names or import paths; this index is regenerated by \`akan sync ${scope.name}\`
|
|
116
|
+
and verified by \`akan lint ${scope.name}\`.`;
|
|
117
|
+
if (recipes.length === 0) {
|
|
118
|
+
return `${intro}
|
|
119
|
+
|
|
120
|
+
No scope recipes yet. Before inlining a repeated surface (card, box, tile, …), reuse a framework recipe from the
|
|
121
|
+
root \`AGENTS.md\` or author one as \`${scopePath}/ui/Recipe/<name>.ts\` (one recipe per file, re-exported from that
|
|
122
|
+
folder's \`index.ts\`) — see the \`recipeRule\` guideline.`;
|
|
123
|
+
}
|
|
124
|
+
return `${intro}
|
|
125
|
+
|
|
126
|
+
${renderRecipeEntries(recipes)}`;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** A fresh scope AGENTS.md: a short hand-editable header around the managed block. */
|
|
130
|
+
export const renderScopeAgentsMd = (scope: AgentsIndexScope, block: string): string => `# ${scope.name} — Agent Guide
|
|
131
|
+
|
|
132
|
+
Scoped guide for coding agents working in \`${scope.type}s/${scope.name}\`. Workspace-wide conventions live in the
|
|
133
|
+
root \`AGENTS.md\`; this file carries what is importable from this ${scope.type === "app" ? "app" : "library"}. The
|
|
134
|
+
section between the \`akan:agent\` markers is regenerated by \`akan sync ${scope.name}\`; edit anything outside the
|
|
135
|
+
markers freely.
|
|
136
|
+
|
|
137
|
+
${AGENT_BLOCK_START}
|
|
138
|
+
${block}
|
|
139
|
+
${AGENT_BLOCK_END}
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
/** Claude Code reads nested CLAUDE.md files as it works under a directory; keep it a thin pointer. */
|
|
143
|
+
export const renderScopeClaudeMd = (scope: AgentsIndexScope): string => `# ${scope.name} — Claude Code Guide
|
|
144
|
+
|
|
145
|
+
@AGENTS.md
|
|
146
|
+
`;
|
package/akanContext.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { capitalize } from "akanjs/common";
|
|
4
4
|
import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
|
|
5
5
|
import { FileSys } from "./fileSys";
|
|
6
|
+
import { collectRecipeSources, findInlineRecipeDuplicates, scanRecipes } from "./recipeScanner";
|
|
6
7
|
import type { PackageJson } from "./types";
|
|
7
8
|
import {
|
|
8
9
|
type GeneratedSyncState,
|
|
@@ -313,6 +314,9 @@ const constantFieldNames = (content: string) =>
|
|
|
313
314
|
[...content.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*field\(/g)].map((match) => match[1]).filter(Boolean);
|
|
314
315
|
|
|
315
316
|
const appRootAllowFiles = new Set([
|
|
317
|
+
// 스코프 에이전트 가이드 — scan(write) 이 유지 (agentsIndex.ts); scanInfo.ts 의 appRootAllowedFiles 와 동기
|
|
318
|
+
"AGENTS.md",
|
|
319
|
+
"CLAUDE.md",
|
|
316
320
|
"akan.app.json",
|
|
317
321
|
"akan.config.ts",
|
|
318
322
|
"capacitor.config.ts",
|
|
@@ -752,6 +756,124 @@ export class AkanContextAnalyzer {
|
|
|
752
756
|
}
|
|
753
757
|
}
|
|
754
758
|
|
|
759
|
+
// Recipe SSOT advisory (항상 warning — 차단하지 않음): recipe 지문이 인라인 className 으로 재작성된
|
|
760
|
+
// 곳의 추이를 보이게 한다. 유입이 실제로 재발하면 그때 lint 승격을 검토한다 — 증거 기반 에스컬레이션.
|
|
761
|
+
for (const sys of [...context.apps, ...context.libs]) {
|
|
762
|
+
const sources = await collectRecipeSources(path.join(workspace.workspaceRoot, sys.path, "ui"), "ui");
|
|
763
|
+
if (sources.length === 0) continue;
|
|
764
|
+
const recipes = scanRecipes(sources);
|
|
765
|
+
const files: { path: string; content: string }[] = [];
|
|
766
|
+
const glob = new Bun.Glob("**/*.tsx");
|
|
767
|
+
for await (const abs of glob.scan({ cwd: path.join(workspace.workspaceRoot, sys.path), absolute: true })) {
|
|
768
|
+
if (/[\\/](node_modules|\.akan|dist)[\\/]|[\\/]v1[\\/]/.test(abs) || /\.(test|spec)\.tsx$/.test(abs)) continue;
|
|
769
|
+
files.push({
|
|
770
|
+
path: abs,
|
|
771
|
+
content: await Bun.file(abs)
|
|
772
|
+
.text()
|
|
773
|
+
.catch(() => ""),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
const duplicates = findInlineRecipeDuplicates(recipes, files);
|
|
777
|
+
if (duplicates.length === 0) continue;
|
|
778
|
+
const preview = duplicates
|
|
779
|
+
.slice(0, 3)
|
|
780
|
+
.map((duplicate) => `${path.relative(workspace.workspaceRoot, duplicate.path)}:${duplicate.line}`)
|
|
781
|
+
.join(", ");
|
|
782
|
+
diagnostics.push({
|
|
783
|
+
severity: "warning",
|
|
784
|
+
code: "recipe-inline-duplicate",
|
|
785
|
+
path: path.join(sys.path, "ui/Recipe"),
|
|
786
|
+
message: `${sys.name}: ${duplicates.length} inline className(s) re-author a recipe fingerprint (${preview}${duplicates.length > 3 ? ", …" : ""}) — consume the recipe instead.`,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Recipe index freshness. The recipe indexes are generated and read as authoritative — a recipe missing
|
|
791
|
+
// from its index gets re-invented inline, and a name lingering in it gets imported and fails. The index is
|
|
792
|
+
// split by ownership: the root AGENTS.md `## Recipes` lists framework recipes only, and every app/lib lists
|
|
793
|
+
// what it may additionally import in its own AGENTS.md `## Recipes In Scope`. Doctor never writes, so this
|
|
794
|
+
// is the check that catches a *committed* stale index (lint/sync self-heal the working tree instead).
|
|
795
|
+
const scanNames = async (uiDirPath: string, basename?: string) =>
|
|
796
|
+
new Set(scanRecipes(await collectRecipeSources(uiDirPath, "ui", basename)).map((info) => info.name));
|
|
797
|
+
const declaredByImport = new Map<string, Set<string>>();
|
|
798
|
+
declaredByImport.set("akanjs/ui", await scanNames(path.join(workspace.workspaceRoot, "pkgs/akanjs/ui"), "recipe"));
|
|
799
|
+
for (const sys of [...context.apps, ...context.libs])
|
|
800
|
+
declaredByImport.set(`@${sys.path}/ui`, await scanNames(path.join(workspace.workspaceRoot, sys.path, "ui")));
|
|
801
|
+
// Section slice anchors the heading to a full line — the same string appears back-ticked in prose.
|
|
802
|
+
const sectionOf = (content: string, heading: string) => {
|
|
803
|
+
const match = new RegExp(`^${heading}$`, "m").exec(content);
|
|
804
|
+
if (!match) return "";
|
|
805
|
+
const section = content.slice(match.index);
|
|
806
|
+
const sectionEnd = section.indexOf("\n## ", 1);
|
|
807
|
+
return sectionEnd === -1 ? section : section.slice(0, sectionEnd);
|
|
808
|
+
};
|
|
809
|
+
// `Import from \`<path>\`:` groups with their `- \`name\`` items, so each name checks against its owner.
|
|
810
|
+
const listedByImport = (body: string) => {
|
|
811
|
+
const groups = new Map<string, Set<string>>();
|
|
812
|
+
let current: Set<string> | null = null;
|
|
813
|
+
for (const line of body.split("\n")) {
|
|
814
|
+
const group = /^Import from `([^`]+)`:/.exec(line);
|
|
815
|
+
if (group) {
|
|
816
|
+
current = groups.get(group[1]) ?? new Set();
|
|
817
|
+
groups.set(group[1], current);
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const item = /^- `([A-Za-z0-9_$]+)`/.exec(line);
|
|
821
|
+
if (item && current) current.add(item[1]);
|
|
822
|
+
else if (!item) current = null;
|
|
823
|
+
}
|
|
824
|
+
return groups;
|
|
825
|
+
};
|
|
826
|
+
const pushIndexDiagnostic = (indexPath: string, missing: string[], stale: string[], repairCommand: string) => {
|
|
827
|
+
if (missing.length === 0 && stale.length === 0) return;
|
|
828
|
+
const action = repairAction(
|
|
829
|
+
"generated",
|
|
830
|
+
repairCommand,
|
|
831
|
+
"Regenerate the recipe index from the scanned recipes.",
|
|
832
|
+
true,
|
|
833
|
+
);
|
|
834
|
+
const parts = [
|
|
835
|
+
missing.length > 0 ? `${missing.length} declared but unlisted (${missing.slice(0, 5).join(", ")})` : "",
|
|
836
|
+
stale.length > 0 ? `${stale.length} listed but gone (${stale.slice(0, 5).join(", ")})` : "",
|
|
837
|
+
].filter(Boolean);
|
|
838
|
+
diagnostics.push({
|
|
839
|
+
severity: "error",
|
|
840
|
+
code: "recipe-index-stale",
|
|
841
|
+
path: indexPath,
|
|
842
|
+
message: `${indexPath} recipe index is out of date — ${parts.join("; ")}. Agents read this list as authoritative.`,
|
|
843
|
+
repairActions: [action],
|
|
844
|
+
});
|
|
845
|
+
repairActions.push(action);
|
|
846
|
+
};
|
|
847
|
+
const frameworkDeclared = declaredByImport.get("akanjs/ui") ?? new Set<string>();
|
|
848
|
+
if (frameworkDeclared.size > 0) {
|
|
849
|
+
const agentsMd = await Bun.file(path.join(workspace.workspaceRoot, "AGENTS.md"))
|
|
850
|
+
.text()
|
|
851
|
+
.catch(() => "");
|
|
852
|
+
const listed = listedByImport(sectionOf(agentsMd, "## Recipes")).get("akanjs/ui") ?? new Set<string>();
|
|
853
|
+
pushIndexDiagnostic(
|
|
854
|
+
"AGENTS.md",
|
|
855
|
+
[...frameworkDeclared].filter((name) => !listed.has(name)).sort(),
|
|
856
|
+
[...listed].filter((name) => !frameworkDeclared.has(name)).sort(),
|
|
857
|
+
"akan agent install agents-md",
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
for (const sys of [...context.apps, ...context.libs]) {
|
|
861
|
+
const own = declaredByImport.get(`@${sys.path}/ui`) ?? new Set<string>();
|
|
862
|
+
const scopeMd = await Bun.file(path.join(workspace.workspaceRoot, sys.path, "AGENTS.md"))
|
|
863
|
+
.text()
|
|
864
|
+
.catch(() => "");
|
|
865
|
+
const groups = listedByImport(sectionOf(scopeMd, "## Recipes In Scope"));
|
|
866
|
+
const ownListed = groups.get(`@${sys.path}/ui`) ?? new Set<string>();
|
|
867
|
+
const missing = [...own].filter((name) => !ownListed.has(name)).sort();
|
|
868
|
+
// Every listed name — the scope's own and its dependency libs' — must still exist at its owner.
|
|
869
|
+
const stale = [...groups.entries()]
|
|
870
|
+
.flatMap(([importFrom, names]) =>
|
|
871
|
+
[...names].filter((name) => !(declaredByImport.get(importFrom) ?? new Set()).has(name)),
|
|
872
|
+
)
|
|
873
|
+
.sort();
|
|
874
|
+
pushIndexDiagnostic(`${sys.path}/AGENTS.md`, missing, stale, `akan sync ${sys.name}`);
|
|
875
|
+
}
|
|
876
|
+
|
|
755
877
|
const scopedDiagnostics = diagnostics.map((diagnostic) => ({
|
|
756
878
|
...diagnostic,
|
|
757
879
|
scope: workflowPaths.length
|