@akanjs/devkit 2.4.2-rc.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.2-rc.3",
3
+ "version": "3.0.0-alpha.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,10 +44,9 @@
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.3",
47
+ "akanjs": "3.0.0-alpha.0",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
- "daisyui": "5.5.23",
51
50
  "dayjs": "^1.11.20",
52
51
  "fontaine": "^0.8.0",
53
52
  "fonteditor-core": "^2.6.3",
@@ -0,0 +1,183 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { collectRecipeSources, findInlineRecipeDuplicates, type RecipeInfo, scanRecipes } from "./recipeScanner";
6
+
7
+ const byName = (recipes: RecipeInfo[], name: string) => recipes.find((recipe) => recipe.name === name);
8
+
9
+ // Mirrors pkgs/akanjs/ui/recipe/ (framework) — two recipes in one source, variant + size surfaces.
10
+ // The scanner is per-source, so a folder of one-recipe files and a legacy multi-recipe file both parse.
11
+ const FRAMEWORK = `
12
+ import { recipe, tv } from "./recipeFactory";
13
+ export const buttonRecipe = recipe(
14
+ tv({
15
+ base: "inline-flex items-center",
16
+ variants: {
17
+ variant: { primary: "bg-primary", ghost: "bg-transparent", link: "underline" },
18
+ size: { sm: "h-8", md: "h-10", lg: "h-12" },
19
+ },
20
+ defaultVariants: { variant: "primary", size: "md" },
21
+ }),
22
+ );
23
+ export type ButtonVariants = NonNullable<Parameters<typeof buttonRecipe>[0]>;
24
+ export const badgeRecipe = recipe(tv({ base: "rounded-full", variants: { variant: { default: "bg-muted", info: "bg-info" } } }));
25
+ `;
26
+
27
+ // Mirrors apps/minimal/ui/Recipe/ shapes — base-only (no variants) + single-variant, with per-export JSDoc.
28
+ const APP = `
29
+ import { recipe, tv } from "akanjs/ui";
30
+ /** 전체 화면 배경/전경. 페이지 루트 컨테이너. */
31
+ export const appScreen = recipe(tv({ base: "min-h-screen bg-background text-foreground" }));
32
+ /** 챗 버블 — 수신/발신 방향에 따라 정렬·색을 바꾼다. */
33
+ export const chatBubbleRecipe = recipe(
34
+ tv({ base: "max-w-[78%] rounded-3xl", variants: { side: { incoming: "bg-muted", outgoing: "ml-auto bg-primary" } }, defaultVariants: { side: "incoming" } }),
35
+ );
36
+ `;
37
+
38
+ // A docs page: the ONLY real code is a layout div; a recipe "definition" lives inside a template-literal code sample.
39
+ const DOCS_TSX = `
40
+ import { Code } from "akanjs/ui";
41
+ export default function Page() {
42
+ return (
43
+ <div>
44
+ <Code.Snippet code={\`export const fakeRecipe = recipe(tv({ base: "bg-primary", variants: { tone: { a: "x" } } }));\`} />
45
+ </div>
46
+ );
47
+ }
48
+ `;
49
+
50
+ describe("scanRecipes", () => {
51
+ test("detects framework recipes with full variant surface", () => {
52
+ const recipes = scanRecipes([{ path: "recipe.ts", content: FRAMEWORK, importFrom: "akanjs/ui" }]);
53
+ expect(recipes.map((r) => r.name).sort()).toEqual(["badgeRecipe", "buttonRecipe"]);
54
+
55
+ const button = byName(recipes, "buttonRecipe");
56
+ expect(button?.importFrom).toBe("akanjs/ui");
57
+ expect(button?.variants.variant).toEqual(["primary", "ghost", "link"]);
58
+ expect(button?.variants.size).toEqual(["sm", "md", "lg"]);
59
+ expect(button?.defaultVariants).toEqual({ variant: "primary", size: "md" });
60
+
61
+ const badge = byName(recipes, "badgeRecipe");
62
+ expect(badge?.variants.variant).toEqual(["default", "info"]);
63
+ expect(badge?.defaultVariants).toBeUndefined();
64
+ });
65
+
66
+ test("handles base-only recipes and captures the JSDoc one-liner", () => {
67
+ const recipes = scanRecipes([{ path: "Recipe.ts", content: APP, importFrom: "@apps/minimal/ui" }]);
68
+
69
+ const screen = byName(recipes, "appScreen");
70
+ expect(screen?.variants).toEqual({}); // base-only → empty variant surface
71
+ expect(screen?.doc).toBe("전체 화면 배경/전경. 페이지 루트 컨테이너.");
72
+ expect(screen?.importFrom).toBe("@apps/minimal/ui");
73
+
74
+ const bubble = byName(recipes, "chatBubbleRecipe");
75
+ expect(bubble?.variants.side).toEqual(["incoming", "outgoing"]);
76
+ expect(bubble?.doc).toBe("챗 버블 — 수신/발신 방향에 따라 정렬·색을 바꾼다.");
77
+ });
78
+
79
+ test("does NOT match recipe definitions inside string/template literals (docs code samples)", () => {
80
+ const recipes = scanRecipes([{ path: "ui-recipe.tsx", content: DOCS_TSX, importFrom: "@apps/akan/ui" }]);
81
+ expect(recipes).toEqual([]);
82
+ });
83
+
84
+ test("skips recipe() calls whose argument is not tv(...)", () => {
85
+ const src = `export const x = recipe(buildStyles());\nexport const y = recipe(tv({ base: "a" }));`;
86
+ const recipes = scanRecipes([{ path: "f.ts", content: src, importFrom: "@x" }]);
87
+ expect(recipes.map((r) => r.name)).toEqual(["y"]);
88
+ });
89
+
90
+ test("ignores non-exported recipe consts and merges multiple sources", () => {
91
+ const src = `const hidden = recipe(tv({ base: "a" }));\nexport const shown = recipe(tv({ base: "b" }));`;
92
+ const recipes = scanRecipes([
93
+ { path: "a.ts", content: src, importFrom: "@a" },
94
+ { path: "b.ts", content: `export const other = recipe(tv({ base: "c" }));`, importFrom: "@b" },
95
+ ]);
96
+ expect(recipes.map((r) => `${r.name}@${r.importFrom}`).sort()).toEqual(["other@@b", "shown@@a"]);
97
+ });
98
+ });
99
+
100
+ // Recipes moved from a flat `ui/Recipe.ts` to a `ui/Recipe/` folder. Three consumers (the AGENTS.md recipe
101
+ // index, the recipeGate lint, the MCP module context) go through collectRecipeSources, and every one of them
102
+ // degrades silently — empty list, no error — if it stops finding sources. These tests are that alarm.
103
+ // The advisory exists to catch a look being re-authored inline. Requiring every base token only matched a
104
+ // verbatim copy of the whole base — the one shape that never occurs in practice — so it reported nothing on
105
+ // the near-copies it was built for, and silently, being advisory. These tests pin the ratio behaviour.
106
+ describe("findInlineRecipeDuplicates", () => {
107
+ // 8 tokens → ceil(8 * 0.7) = 6 must be reproduced.
108
+ const EIGHT = `export const cardRecipe = recipe(tv({ base: "flex rounded-box border border-border bg-card p-4 text-card-foreground shadow-sm" }));`;
109
+ const THREE = `export const gridRecipe = recipe(tv({ base: "grid gap-3 xl:grid-cols-2" }));`;
110
+ const recipesOf = (src: string) => scanRecipes([{ path: "Recipe.ts", content: src, importFrom: "@apps/x/ui" }]);
111
+ const hits = (src: string, jsx: string) =>
112
+ findInlineRecipeDuplicates(recipesOf(src), [{ path: "Page.tsx", content: jsx }]).map((d) => d.recipe);
113
+
114
+ test("flags a verbatim re-author of the whole base", () => {
115
+ const jsx = `<div className="flex rounded-box border border-border bg-card p-4 text-card-foreground shadow-sm" />`;
116
+ expect(hits(EIGHT, jsx)).toEqual(["cardRecipe"]);
117
+ });
118
+
119
+ test("flags a near-copy that drops two tokens — the case the exact-match rule missed", () => {
120
+ const jsx = `<div className="flex rounded-box border border-border bg-card p-4" />`;
121
+ expect(hits(EIGHT, jsx)).toEqual(["cardRecipe"]);
122
+ });
123
+
124
+ test("ignores a className that merely shares a few generic utilities", () => {
125
+ expect(hits(EIGHT, `<div className="flex border p-4" />`)).toEqual([]);
126
+ });
127
+
128
+ test("still requires every token of a minimum-length fingerprint", () => {
129
+ expect(hits(THREE, `<div className="grid gap-3 xl:grid-cols-2" />`)).toEqual(["gridRecipe"]);
130
+ expect(hits(THREE, `<div className="grid gap-3" />`)).toEqual([]);
131
+ });
132
+
133
+ test("does not flag a className that consumes the recipe", () => {
134
+ expect(hits(EIGHT, `<div className={cardRecipe({}, "w-full")} />`)).toEqual([]);
135
+ });
136
+ });
137
+
138
+ describe("collectRecipeSources", () => {
139
+ const seed = async (files: Record<string, string>) => {
140
+ const root = await mkdtemp(path.join(tmpdir(), "akan-recipe-"));
141
+ for (const [rel, content] of Object.entries(files)) {
142
+ const abs = path.join(root, rel);
143
+ await Bun.write(abs, content);
144
+ }
145
+ return root;
146
+ };
147
+ const recipeSrc = (name: string) => `export const ${name} = recipe(tv({ base: "a" }));`;
148
+
149
+ test("reads every recipe file in the folder, skipping index and tests", async () => {
150
+ const root = await seed({
151
+ "ui/Recipe/index.ts": `export * from "./appCard";`,
152
+ "ui/Recipe/appCard.ts": recipeSrc("appCard"),
153
+ "ui/Recipe/appBox.ts": recipeSrc("appBox"),
154
+ "ui/Recipe/appBox.test.ts": recipeSrc("shouldBeSkipped"),
155
+ "ui/Recipe/notes.md": "ignored",
156
+ });
157
+ const sources = await collectRecipeSources(path.join(root, "ui"), "@apps/x/ui");
158
+ expect(sources).toHaveLength(2);
159
+ expect(
160
+ scanRecipes(sources)
161
+ .map((r) => r.name)
162
+ .sort(),
163
+ ).toEqual(["appBox", "appCard"]);
164
+ });
165
+
166
+ test("still reads a flat Recipe.ts so an unmigrated app keeps working", async () => {
167
+ const root = await seed({ "ui/Recipe.ts": recipeSrc("legacy") });
168
+ const sources = await collectRecipeSources(path.join(root, "ui"), "@apps/x/ui");
169
+ expect(scanRecipes(sources).map((r) => r.name)).toEqual(["legacy"]);
170
+ });
171
+
172
+ test("honours the framework's lowercase basename", async () => {
173
+ const root = await seed({ "ui/recipe/buttonRecipe.ts": recipeSrc("buttonRecipe") });
174
+ const sources = await collectRecipeSources(path.join(root, "ui"), "akanjs/ui", "recipe");
175
+ expect(scanRecipes(sources).map((r) => r.name)).toEqual(["buttonRecipe"]);
176
+ });
177
+
178
+ test("returns nothing when neither shape exists, without throwing", async () => {
179
+ const root = await mkdtemp(path.join(tmpdir(), "akan-recipe-"));
180
+ await writeFile(path.join(root, "placeholder"), "");
181
+ expect(await collectRecipeSources(path.join(root, "ui"), "@apps/x/ui")).toEqual([]);
182
+ });
183
+ });
@@ -0,0 +1,233 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import ts from "typescript";
3
+
4
+ /**
5
+ * A single Akan UI recipe discovered by scanning source. `variants` maps each variant key to its allowed option
6
+ * names (e.g. `{ variant: ["primary", "ghost"], size: ["sm", "md"] }`); a base-only recipe has `variants: {}`.
7
+ * `importFrom` is the module a consumer imports the recipe from (e.g. `@apps/minimal/ui`).
8
+ */
9
+ export interface RecipeInfo {
10
+ name: string;
11
+ importFrom: string;
12
+ variants: Record<string, string[]>;
13
+ defaultVariants?: Record<string, string>;
14
+ doc?: string;
15
+ /** The recipe's `base` class string when it is a plain string literal — the SSOT fingerprint. */
16
+ base?: string;
17
+ }
18
+
19
+ export interface RecipeSource {
20
+ path: string;
21
+ content: string;
22
+ importFrom: string;
23
+ }
24
+
25
+ /**
26
+ * Collects every recipe source under a `ui` folder. Recipes live one-per-file in a `Recipe/` folder
27
+ * (`recipe/` for the framework), so this reads the whole folder; the flat `Recipe.ts` is still read for
28
+ * apps that have not moved yet. Every consumer of `scanRecipes` must go through here — three call sites
29
+ * (AGENTS.md recipe index, `recipeGate` lint, MCP module context) hardcoded the flat path before, and each
30
+ * one fails silently (empty list, no error) when the file is absent.
31
+ */
32
+ export const collectRecipeSources = async (
33
+ uiDirPath: string,
34
+ importFrom: string,
35
+ basename = "Recipe",
36
+ ): Promise<RecipeSource[]> => {
37
+ const read = async (filePath: string): Promise<RecipeSource | null> => {
38
+ const content = await Bun.file(filePath)
39
+ .text()
40
+ .catch(() => "");
41
+ return content ? { path: filePath, content, importFrom } : null;
42
+ };
43
+ const flat = await read(`${uiDirPath}/${basename}.ts`);
44
+ const dirEntries = await readdir(`${uiDirPath}/${basename}`).catch(() => [] as string[]);
45
+ const fromDir = await Promise.all(
46
+ dirEntries
47
+ .filter((entry) => entry.endsWith(".ts") && entry !== "index.ts" && !/\.(test|spec)\.ts$/.test(entry))
48
+ .sort()
49
+ .map((entry) => read(`${uiDirPath}/${basename}/${entry}`)),
50
+ );
51
+ return [flat, ...fromDir].filter((source): source is RecipeSource => !!source);
52
+ };
53
+
54
+ /**
55
+ * Statically finds every `export const <name> = recipe(tv({ ... }))` across the given sources and extracts its
56
+ * variant surface + leading JSDoc one-liner. Detection is by the `recipe(tv(...))` call shape (not by name suffix,
57
+ * since base-only recipes like `appScreen` omit the `Recipe` suffix). Being AST-based, it never matches recipe
58
+ * definitions that appear only inside string/template literals (e.g. code examples in docs pages).
59
+ */
60
+ export const scanRecipes = (sources: RecipeSource[]): RecipeInfo[] => {
61
+ const recipes: RecipeInfo[] = [];
62
+ for (const source of sources) {
63
+ const sourceFile = ts.createSourceFile(
64
+ source.path,
65
+ source.content,
66
+ ts.ScriptTarget.Latest,
67
+ true,
68
+ ts.ScriptKind.TSX,
69
+ );
70
+ for (const statement of sourceFile.statements) {
71
+ if (!ts.isVariableStatement(statement) || !isExported(statement)) continue;
72
+ for (const declaration of statement.declarationList.declarations) {
73
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
74
+ const parsed = parseRecipeCall(declaration.initializer);
75
+ if (!parsed) continue;
76
+ recipes.push({
77
+ name: declaration.name.text,
78
+ importFrom: source.importFrom,
79
+ variants: parsed.variants,
80
+ ...(parsed.defaultVariants ? { defaultVariants: parsed.defaultVariants } : {}),
81
+ ...(getLeadingDoc(source.content, statement) ? { doc: getLeadingDoc(source.content, statement) } : {}),
82
+ ...(parsed.base ? { base: parsed.base } : {}),
83
+ });
84
+ }
85
+ }
86
+ }
87
+ return recipes;
88
+ };
89
+
90
+ const isExported = (statement: ts.VariableStatement): boolean =>
91
+ statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
92
+
93
+ /** Matches `recipe( tv( <ObjectLiteral> ) )` and returns the variant surface, or null for anything else. */
94
+ const parseRecipeCall = (
95
+ initializer: ts.Expression,
96
+ ): { variants: Record<string, string[]>; defaultVariants?: Record<string, string>; base?: string } | null => {
97
+ if (!ts.isCallExpression(initializer)) return null;
98
+ if (!ts.isIdentifier(initializer.expression) || initializer.expression.text !== "recipe") return null;
99
+ const tvCall = initializer.arguments[0];
100
+ if (!tvCall || !ts.isCallExpression(tvCall)) return null;
101
+ if (!ts.isIdentifier(tvCall.expression) || tvCall.expression.text !== "tv") return null;
102
+ const config = tvCall.arguments[0];
103
+ if (!config || !ts.isObjectLiteralExpression(config)) return null;
104
+ return extractVariants(config);
105
+ };
106
+
107
+ const extractVariants = (config: ts.ObjectLiteralExpression) => {
108
+ const variants: Record<string, string[]> = {};
109
+ let defaultVariants: Record<string, string> | undefined;
110
+ let base: string | undefined;
111
+ for (const property of config.properties) {
112
+ if (!ts.isPropertyAssignment(property) || !isNamed(property.name)) continue;
113
+ const key = propName(property.name);
114
+ if (key === "base" && ts.isStringLiteral(property.initializer)) {
115
+ base = property.initializer.text;
116
+ } else if (key === "variants" && ts.isObjectLiteralExpression(property.initializer)) {
117
+ for (const variant of property.initializer.properties) {
118
+ if (!ts.isPropertyAssignment(variant) || !isNamed(variant.name)) continue;
119
+ if (!ts.isObjectLiteralExpression(variant.initializer)) continue;
120
+ variants[propName(variant.name)] = variant.initializer.properties
121
+ .filter((option): option is ts.PropertyAssignment => ts.isPropertyAssignment(option) && isNamed(option.name))
122
+ .map((option) => propName(option.name));
123
+ }
124
+ } else if (key === "defaultVariants" && ts.isObjectLiteralExpression(property.initializer)) {
125
+ defaultVariants = {};
126
+ for (const preset of property.initializer.properties) {
127
+ if (!ts.isPropertyAssignment(preset) || !isNamed(preset.name)) continue;
128
+ defaultVariants[propName(preset.name)] = ts.isStringLiteral(preset.initializer)
129
+ ? preset.initializer.text
130
+ : preset.initializer.getText();
131
+ }
132
+ }
133
+ }
134
+ return { variants, defaultVariants, base };
135
+ };
136
+
137
+ const isNamed = (name: ts.PropertyName): name is ts.Identifier | ts.StringLiteral =>
138
+ ts.isIdentifier(name) || ts.isStringLiteral(name);
139
+ const propName = (name: ts.Identifier | ts.StringLiteral): string => name.text;
140
+
141
+ export interface RecipeDuplicate {
142
+ recipe: string;
143
+ path: string;
144
+ line: number;
145
+ className: string;
146
+ }
147
+
148
+ /**
149
+ * Fraction of a recipe's base tokens an inline className must reproduce to count as a duplicate.
150
+ *
151
+ * Requiring *every* token (the original rule) only caught a verbatim copy of the whole base, which is the one
152
+ * form of duplication that essentially never happens: someone re-authoring a look reproduces the gist, not all
153
+ * eight tokens. So the check passed on exactly the near-duplicates it existed to find, and silently — it is an
154
+ * advisory, so nothing went red. A ratio catches those; false positives are cheap here for the same reason.
155
+ */
156
+ const DUPLICATE_TOKEN_RATIO = 0.7;
157
+
158
+ /** Minimum base tokens for a recipe to be worth fingerprinting at all. */
159
+ const MIN_FINGERPRINT_TOKENS = 3;
160
+
161
+ /**
162
+ * SSOT advisory: finds JSX `className` string values that hand-rewrite a recipe's base fingerprint instead of
163
+ * consuming the recipe. Only recipes whose base has 3+ distinctive tokens are checked — shorter fingerprints
164
+ * (`grid gap-3` …) are generic utilities and would flood the report with false positives. A className counts as
165
+ * a duplicate once it reproduces {@link DUPLICATE_TOKEN_RATIO} of those tokens, so a near-copy that drops or
166
+ * swaps one still reports. AST-scoped to real `className` attributes, so class strings inside doc-example
167
+ * template literals never match.
168
+ */
169
+ export const findInlineRecipeDuplicates = (
170
+ recipes: RecipeInfo[],
171
+ files: { path: string; content: string }[],
172
+ ): RecipeDuplicate[] => {
173
+ const fingerprints = recipes
174
+ .map((recipe) => ({ recipe: recipe.name, tokens: (recipe.base ?? "").split(/\s+/).filter(Boolean) }))
175
+ .filter((fingerprint) => fingerprint.tokens.length >= MIN_FINGERPRINT_TOKENS)
176
+ // Ceil so the threshold never rounds below the minimum: a 3-token base still needs 3 of 3.
177
+ .map((fingerprint) => ({ ...fingerprint, needed: Math.ceil(fingerprint.tokens.length * DUPLICATE_TOKEN_RATIO) }));
178
+ if (fingerprints.length === 0) return [];
179
+ const duplicates: RecipeDuplicate[] = [];
180
+ for (const file of files) {
181
+ const sourceFile = ts.createSourceFile(file.path, file.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
182
+ const visit = (node: ts.Node) => {
183
+ if (ts.isJsxAttribute(node) && node.name.getText(sourceFile) === "className" && node.initializer) {
184
+ for (const value of stringValuesIn(node.initializer)) {
185
+ const classSet = new Set(value.split(/\s+/));
186
+ for (const fingerprint of fingerprints) {
187
+ const matched = fingerprint.tokens.filter((token) => classSet.has(token)).length;
188
+ if (matched >= fingerprint.needed) {
189
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
190
+ duplicates.push({ recipe: fingerprint.recipe, path: file.path, line: line + 1, className: value });
191
+ }
192
+ }
193
+ }
194
+ }
195
+ node.forEachChild(visit);
196
+ };
197
+ visit(sourceFile);
198
+ }
199
+ return duplicates;
200
+ };
201
+
202
+ const stringValuesIn = (node: ts.Node): string[] => {
203
+ const values: string[] = [];
204
+ const visit = (child: ts.Node) => {
205
+ if (
206
+ ts.isStringLiteral(child) ||
207
+ ts.isNoSubstitutionTemplateLiteral(child) ||
208
+ ts.isTemplateHead(child) ||
209
+ ts.isTemplateMiddle(child) ||
210
+ ts.isTemplateTail(child)
211
+ )
212
+ values.push(child.text);
213
+ child.forEachChild(visit);
214
+ };
215
+ visit(node);
216
+ return values;
217
+ };
218
+
219
+ /** The first non-empty line of the JSDoc/line comment immediately preceding the statement, markers stripped. */
220
+ const getLeadingDoc = (fullText: string, node: ts.Node): string | undefined => {
221
+ const ranges = ts.getLeadingCommentRanges(fullText, node.getFullStart());
222
+ if (!ranges?.length) return undefined;
223
+ const { pos, end } = ranges[ranges.length - 1];
224
+ const line = fullText
225
+ .slice(pos, end)
226
+ .replace(/^\/\*\*?/, "")
227
+ .replace(/\*\/\s*$/, "")
228
+ .replace(/^\/\/+/gm, "")
229
+ .split("\n")
230
+ .map((row) => row.replace(/^\s*\*\s?/, "").trim())
231
+ .find((row) => row.length > 0);
232
+ return line || undefined;
233
+ };
package/scanInfo.ts CHANGED
@@ -44,6 +44,9 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
44
44
  type ModuleKind = "database" | "service" | "scalar";
45
45
 
46
46
  const appRootAllowedFiles = new Set([
47
+ // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
48
+ "AGENTS.md",
49
+ "CLAUDE.md",
47
50
  "akan.app.json",
48
51
  "akan.config.ts",
49
52
  "capacitor.config.ts",
@@ -11,7 +11,7 @@ import type { App } from "../commandDecorators";
11
11
  * - `packages: "external"` externalizes every bare specifier, including
12
12
  * `@apps/*` / `@libs/*` — workspace packages that must stay bundled
13
13
  * so the `"use client"` plugin can rewrite their exports — and ordinary
14
- * npm dependencies like `dayjs` / `clsx`, which the production runtime
14
+ * npm dependencies like `dayjs` / `tailwind-merge`, which the production runtime
15
15
  * package.json does not install for the SSR pages artifact.
16
16
  * - Top-level `external: [...]` applies to macro-time module resolution
17
17
  * too; any `with { type: "macro" }` import chain that transitively
@@ -169,7 +169,7 @@ export async function createExternalizeFrameworkPlugin(options: ExternalizeFrame
169
169
  // that case directly.
170
170
  return undefined;
171
171
  }
172
- // Everything else (ordinary npm dependencies like dayjs / clsx /
172
+ // Everything else (ordinary npm dependencies like dayjs / tailwind-merge /
173
173
  // immer) is bundled into the pages artifact.
174
174
  return undefined;
175
175
  });