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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
package/executors.ts CHANGED
@@ -31,6 +31,13 @@ import {
31
31
  } from "akanjs/common";
32
32
  import { $ } from "bun";
33
33
  import chalk from "chalk";
34
+ import {
35
+ renderScopeAgentBlock,
36
+ renderScopeAgentsMd,
37
+ renderScopeClaudeMd,
38
+ scanScopeRecipes,
39
+ upsertAgentBlock,
40
+ } from "./agentsIndex";
34
41
  import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
35
42
  import { FileSys } from "./fileSys";
36
43
  import { getDirname } from "./getDirname";
@@ -1080,10 +1087,29 @@ export class SysExecutor extends Executor {
1080
1087
  await this.#updateDependencies(scanInfo);
1081
1088
  await Promise.all(libInfos.flatMap((libInfo) => libInfo.exec.#getScanTemplateTasks(libInfo)));
1082
1089
  }
1090
+ await this.syncAgentsIndex(scanInfo);
1083
1091
  }
1084
1092
  this.#scanInfo = scanInfo;
1085
1093
  return scanInfo;
1086
1094
  }
1095
+ /**
1096
+ * 스코프 에이전트 색인(apps|libs/<name>/AGENTS.md) 재생성 — own + 의존 lib 레시피만 싣는다(프레임워크
1097
+ * 레시피는 루트 AGENTS.md 소관). scan(write) 경로에 물려 있어 sync/build/start 어디를 지나도 갱신되고,
1098
+ * `akan lint` 가 같은 렌더 결과와 비교해 신선도를 강제한다. 마커 밖 내용은 사용자 소유라 보존한다.
1099
+ */
1100
+ async syncAgentsIndex(scanInfo?: AppInfo | LibInfo) {
1101
+ const info = scanInfo ?? (await this.scan({ write: false }));
1102
+ const scope = { type: this.type, name: this.name };
1103
+ const recipes = await scanScopeRecipes(this.workspace.workspaceRoot, scope, info.getScanResult().libDeps);
1104
+ const block = renderScopeAgentBlock(scope, recipes);
1105
+ const existing = (await this.exists("AGENTS.md")) ? await this.readFile("AGENTS.md") : null;
1106
+ await this.writeFile(
1107
+ "AGENTS.md",
1108
+ existing?.trim() ? upsertAgentBlock(existing, block) : renderScopeAgentsMd(scope, block),
1109
+ );
1110
+ // CLAUDE.md 는 얇은 포인터라 최초 1회만 깔아준다 — 사용자가 지우거나 고친 것을 되살리지 않는다.
1111
+ if (!(await this.exists("CLAUDE.md"))) await this.writeFile("CLAUDE.md", renderScopeClaudeMd(scope));
1112
+ }
1087
1113
  async #updateDependencies(scanInfo: AppInfo | LibInfo) {
1088
1114
  const rootPackageJson = await this.workspace.getPackageJson();
1089
1115
  const libPackageJson = await this.getPackageJson();
@@ -113,7 +113,9 @@ describe("PagesBundleBuilder", () => {
113
113
  await write(entry, ['import "./styles.css";', "export const marker = 1;", ""].join("\n"));
114
114
  await write(
115
115
  css,
116
- ['@plugin "daisyui" {', " themes: false;", "}", "@theme {", " --color-primary: red;", "}", ""].join("\n"),
116
+ ['@plugin "tailwind-scrollbar" {', " themes: false;", "}", "@theme {", " --color-primary: red;", "}", ""].join(
117
+ "\n",
118
+ ),
117
119
  );
118
120
 
119
121
  const result = await Bun.build({
@@ -18,5 +18,8 @@ export * from "./routeClientBuilder";
18
18
  export * from "./routesManifestArtifactSerializer";
19
19
  export * from "./sourceMtimeIndex";
20
20
  export * from "./ssrBaseArtifactBuilder";
21
+ export * from "./styleContract";
22
+ export * from "./styleGuard";
23
+ export * from "./themeValidator";
21
24
  export * from "./vendorSpecifiers";
22
25
  export * from "./watchRootResolver";
@@ -184,6 +184,8 @@ export class SsrBaseArtifactBuilder {
184
184
  }> {
185
185
  const cssCompiler = new CssCompiler(this.#app);
186
186
  const cssByBasePath = await cssCompiler.getCssByBasePath();
187
+ // 스타일 계약(어휘 폐쇄 + WCAG)은 빌드가 아니라 lint 가 강제한다: 어휘 폐쇄는 biome grit 플러그인
188
+ // (devkit/lint/no-raw-palette-class.grit 외 3종), 콘트라스트는 `akan lint` 의 themeValidator.
187
189
  const optimizedFonts = await new FontOptimizer(this.#app, this.#command).optimize();
188
190
  const cssAssets = Object.fromEntries(
189
191
  await Promise.all(
@@ -0,0 +1,29 @@
1
+ /**
2
+ * styleGuard + themeValidator 결과를 배선(build/dev/lint)이 공유하는 포맷으로 정리한다.
3
+ * severity 사다리: style 위반은 severity==="error" 인 것만, theme 위반은 전부 차단(build/CI) 대상.
4
+ * dev 는 이 결과를 경고로만 출력한다.
5
+ */
6
+ import type { StyleGuardViolation } from "./styleGuard";
7
+ import type { ThemeContrastViolation } from "./themeValidator";
8
+
9
+ export interface StyleContractViolations {
10
+ style: StyleGuardViolation[];
11
+ theme: ThemeContrastViolation[];
12
+ }
13
+
14
+ export const countBlocking = (v: StyleContractViolations): number =>
15
+ v.style.filter((s) => s.severity === "error").length + v.theme.length;
16
+
17
+ export const formatStyleContract = (v: StyleContractViolations): string => {
18
+ const lines: string[] = [];
19
+ for (const s of v.style) {
20
+ lines.push(` [${s.severity}] ${s.rule} ${s.path}:${s.line}`);
21
+ lines.push(` ${s.snippet}`);
22
+ lines.push(` → ${s.suggestion}`);
23
+ }
24
+ for (const t of v.theme) {
25
+ lines.push(` [error] contrast ${t.scope} ${t.pair} = ${t.ratio}:1 (min ${t.threshold}:1)`);
26
+ lines.push(` → ${t.suggestion}`);
27
+ }
28
+ return lines.join("\n");
29
+ };
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { StyleGuard, type StyleGuardRule } from "./styleGuard";
3
+
4
+ const guard = new StyleGuard();
5
+ const scan = (content: string, path = "Demo.tsx") => guard.run([{ path, content }]);
6
+ const rules = (content: string): StyleGuardRule[] => scan(content).map((v) => v.rule);
7
+
8
+ describe("StyleGuard raw-palette", () => {
9
+ test("flags raw Tailwind palette utilities", () => {
10
+ expect(rules('<div className="bg-blue-500 text-gray-700" />')).toEqual(["raw-palette", "raw-palette"]);
11
+ });
12
+
13
+ test("flags palette with variant prefix and opacity", () => {
14
+ expect(rules('<div className="hover:bg-red-500/50" />')).toContain("raw-palette");
15
+ });
16
+
17
+ test("flags numeric neutral but allows bare semantic neutral", () => {
18
+ expect(rules('<div className="bg-neutral-500" />')).toEqual(["raw-palette"]);
19
+ expect(scan('<div className="bg-neutral text-neutral-foreground" />')).toHaveLength(0);
20
+ });
21
+
22
+ test("does not flag semantic tokens or black/white", () => {
23
+ expect(scan('<div className="bg-primary text-muted-foreground border-border" />')).toHaveLength(0);
24
+ expect(scan('<div className="bg-black text-white bg-white/30 bg-black/50" />')).toHaveLength(0);
25
+ });
26
+
27
+ test("does not flag non-color numeric utilities", () => {
28
+ expect(scan('<div className="gap-4 mt-2 grid-cols-3 w-500" />')).toHaveLength(0);
29
+ });
30
+ });
31
+
32
+ describe("StyleGuard arbitrary-color", () => {
33
+ test("flags hex and color-function arbitrary values", () => {
34
+ expect(rules('<div className="bg-[#3b82f6]" />')).toEqual(["arbitrary-color"]);
35
+ expect(rules('<div className="text-[rgb(0,0,0)]" />')).toEqual(["arbitrary-color"]);
36
+ });
37
+
38
+ test("allows arbitrary CSS variable references", () => {
39
+ expect(scan('<div className="bg-[--brand] text-[var(--fg)]" />')).toHaveLength(0);
40
+ });
41
+ });
42
+
43
+ describe("StyleGuard inline-color", () => {
44
+ test("flags hardcoded color in style object", () => {
45
+ expect(rules("<div style={{ color: '#fff', background: 'rgb(0,0,0)' }} />")).toEqual([
46
+ "inline-color",
47
+ "inline-color",
48
+ ]);
49
+ });
50
+
51
+ test("flags color literal inside <style> tag", () => {
52
+ expect(rules("<style>{`.x { color: #abcdef; }`}</style>")).toEqual(["inline-color"]);
53
+ });
54
+
55
+ test("allows var() references in style object", () => {
56
+ expect(scan("<div style={{ color: 'var(--primary)', width: '100%' }} />")).toHaveLength(0);
57
+ });
58
+ });
59
+
60
+ describe("StyleGuard daisyui-legacy", () => {
61
+ test("flags high-signal daisyUI compound classes", () => {
62
+ expect(rules('<button className="btn-primary" />')).toEqual(["daisyui-legacy"]);
63
+ expect(rules('<span className="badge-success" />')).toEqual(["daisyui-legacy"]);
64
+ expect(rules('<div className="modal-box card-body" />')).toEqual(["daisyui-legacy", "daisyui-legacy"]);
65
+ });
66
+
67
+ test("does not flag bare ambiguous names that collide with Tailwind", () => {
68
+ expect(scan('<div className="card input badge btn" />')).toHaveLength(0);
69
+ });
70
+ });
71
+
72
+ // The fixtures below must contain a literal `${`. Writing it inside a plain string trips biome's
73
+ // noTemplateCurlyInString, so the placeholder is assembled from `D` — that keeps the rule on for real code
74
+ // instead of scattering suppressions through the fixtures.
75
+ const D = "$";
76
+ const INTERPOLATED = {
77
+ size: `<div className={\`min-h-[${D}{minHeight}px] flex\`} />`,
78
+ color: `<div className={\`bg-[${D}{color}] w-full\`} />`,
79
+ brokenBracket: `<div className={\`min-h-[ w-full${D}{minHeight}px] flex\`} />`,
80
+ outsideBrackets: `<div className={\`flex gap-2 ${D}{isOpen ? "opacity-50" : ""}\`} />`,
81
+ styleProp: `<div style={{ minHeight }} className={\`flex ${D}{extra}\`} />`,
82
+ };
83
+
84
+ describe("StyleGuard interpolated-arbitrary", () => {
85
+ test("flags an arbitrary value assembled from a runtime expression", () => {
86
+ expect(rules(INTERPOLATED.size)).toEqual(["interpolated-arbitrary"]);
87
+ expect(rules(INTERPOLATED.color)).toEqual(["interpolated-arbitrary"]);
88
+ });
89
+
90
+ test("flags the broken-bracket typo that swallows the next class", () => {
91
+ expect(rules(INTERPOLATED.brokenBracket)).toEqual(["interpolated-arbitrary"]);
92
+ });
93
+
94
+ test("allows a literal arbitrary value, and interpolation outside brackets", () => {
95
+ expect(scan('<div className="min-h-[300px] flex" />')).toHaveLength(0);
96
+ expect(scan(INTERPOLATED.outsideBrackets)).toHaveLength(0);
97
+ expect(scan(INTERPOLATED.styleProp)).toHaveLength(0);
98
+ });
99
+ });
100
+
101
+ describe("StyleGuard violation shape", () => {
102
+ test("reports 1-based line and trimmed snippet with a suggestion", () => {
103
+ const content = ['<div className="ok" />', ' <div className="bg-blue-500" />'].join("\n");
104
+ const [v] = scan(content);
105
+ expect(v.line).toBe(2);
106
+ expect(v.snippet).toBe('<div className="bg-blue-500" />');
107
+ expect(v.severity).toBe("error");
108
+ expect(v.suggestion.length).toBeGreaterThan(0);
109
+ });
110
+ });
111
+
112
+ describe("StyleGuard comment handling", () => {
113
+ test("does not flag class names inside line or block comments", () => {
114
+ expect(scan('// iconClassName="btn-primary bg-blue-500"')).toHaveLength(0);
115
+ expect(scan("/** legacy: toggle-accent / bg-red-500 */")).toHaveLength(0);
116
+ expect(scan("{/* <div className='bg-blue-500' /> */}")).toHaveLength(0);
117
+ });
118
+
119
+ test("still flags real code on a line that also contains a string with //", () => {
120
+ expect(rules('<a href="https://x.io" className="bg-blue-500" />')).toEqual(["raw-palette"]);
121
+ });
122
+ });
123
+
124
+ describe("StyleGuard escape hatch", () => {
125
+ test("styleguard-disable-next-line suppresses the following line only", () => {
126
+ const content = [
127
+ "// styleguard-disable-next-line raw-palette",
128
+ '<div className="bg-blue-500" />',
129
+ '<div className="bg-red-500" />',
130
+ ].join("\n");
131
+ const found = scan(content);
132
+ expect(found).toHaveLength(1);
133
+ expect(found[0].line).toBe(3);
134
+ });
135
+
136
+ test("file-level styleguard-disable suppresses the named rule everywhere", () => {
137
+ const content = ["// styleguard-disable raw-palette", '<div className="bg-blue-500 bg-[#fff]" />'].join("\n");
138
+ // raw-palette suppressed, arbitrary-color still reported.
139
+ expect(rules(content)).toEqual(["arbitrary-color"]);
140
+ });
141
+
142
+ test("bare styleguard-disable suppresses all rules in the file", () => {
143
+ const content = ["/* styleguard-disable */", '<div className="bg-blue-500 btn-primary bg-[#fff]" />'].join("\n");
144
+ expect(scan(content)).toHaveLength(0);
145
+ });
146
+ });
147
+
148
+ describe("StyleGuard.countNamedComponentClasses", () => {
149
+ test("counts named classes declared inside @layer components", () => {
150
+ const css = `
151
+ @layer components {
152
+ .foo { color: var(--primary); }
153
+ .bar-baz { padding: 1rem; }
154
+ }
155
+ .outside { color: red; }
156
+ `;
157
+ const metric = StyleGuard.countNamedComponentClasses(css);
158
+ expect(metric.count).toBe(2);
159
+ expect(metric.names).toEqual(["bar-baz", "foo"]);
160
+ });
161
+
162
+ test("returns zero when no component layer exists", () => {
163
+ expect(StyleGuard.countNamedComponentClasses(".a { color: red; }").count).toBe(0);
164
+ });
165
+ });