@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/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
+ });
@@ -0,0 +1,322 @@
1
+ /**
2
+ * styleGuard — 색 어휘 폐쇄를 강제하는 순수 정적 검사기 (의존성 0).
3
+ *
4
+ * 어휘 폐쇄(styles.css `@theme { --color-*: initial }`)로 raw 팔레트가 CSS를 미생성하게 되면
5
+ * 위반은 "조용한 시각 깨짐"이 된다. styleGuard 는 소스 문자열을 스캔해 그 위반을 명시적 진단으로
6
+ * 끌어올리고, 에이전트가 읽고 수리할 `suggestion` 을 붙인다.
7
+ *
8
+ * severity 는 규칙 고유값(판별력)만 담는다. warn/error 를 막을지 여부는 배선(호출자)이 mode 로 결정한다:
9
+ * - dev : 절대 막지 않음 (모두 경고 로그)
10
+ * - build/CI/lint : severity==="error" 위반이 하나라도 있으면 실패
11
+ *
12
+ * akanjs 런타임을 import 하지 않는다 — node 표준 라이브러리만 사용.
13
+ */
14
+
15
+ export type StyleGuardRule =
16
+ | "raw-palette"
17
+ | "arbitrary-color"
18
+ | "inline-color"
19
+ | "daisyui-legacy"
20
+ | "interpolated-arbitrary";
21
+ export type StyleGuardSeverity = "error" | "warn";
22
+
23
+ export interface StyleGuardViolation {
24
+ rule: StyleGuardRule;
25
+ severity: StyleGuardSeverity;
26
+ path: string;
27
+ line: number;
28
+ snippet: string;
29
+ suggestion: string;
30
+ }
31
+
32
+ export interface StyleGuardFile {
33
+ path: string;
34
+ content: string;
35
+ }
36
+
37
+ export interface NamedComponentClassMetric {
38
+ count: number;
39
+ names: string[];
40
+ }
41
+
42
+ // 색을 받는 유틸 접두사. daisyuiTokenRename.ts 의 PREFIX 와 동일 계보(방향성 border 포함).
43
+ const PREFIX =
44
+ "(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)";
45
+
46
+ // Tailwind 기본 팔레트 이름. `neutral` 은 시맨틱 토큰이기도 하므로 숫자 suffix 를 요구해 구분한다
47
+ // (`bg-neutral` 은 허용, `bg-neutral-500` 은 raw 팔레트).
48
+ const PALETTE =
49
+ "(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)";
50
+
51
+ // 클래스 경계: 앞은 공백/따옴표/백틱/여는 괄호/콜론(변형)/!(important), 뒤는 종결 문자(+ 슬래시=opacity, !).
52
+ const LEAD = "(^|[\\s\"'`{(\\[:!])";
53
+ const TAIL = "(?=[\\s\"'`})\\]:/!,]|$)";
54
+
55
+ const RAW_PALETTE_RE = new RegExp(`${LEAD}((?:${PREFIX})-)${PALETTE}-\\d{2,3}${TAIL}`, "g");
56
+
57
+ // 임의값 대괄호 안의 색 리터럴. `[--var]` 같은 변수 참조는 매치되지 않는다(의도).
58
+ const ARBITRARY_COLOR_RE = /\[(#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb|color)\([^\]]*\))\]/g;
59
+
60
+ // 런타임 값으로 조립한 임의값(`min-h-[${n}px]`, `bg-[${color}]`). Tailwind 는 소스 **텍스트**에서
61
+ // 임의값을 추출하므로 CSS 가 아예 생성되지 않는다 — 클래스는 DOM 에 있고 prop 은 연결된 듯 보이지만
62
+ // 아무것도 적용되지 않는 무증상 실패다. 같은 모양의 리터럴이 코드베이스 어딘가에 있으면 기본값만
63
+ // 우연히 동작하고 override 만 조용히 죽어 더 찾기 어려워진다.
64
+ const INTERPOLATED_ARBITRARY_RE = new RegExp(`${LEAD}[a-z][a-z0-9-]*-\\[[^\\]\`]*\\$\\{`, "g");
65
+
66
+ // style 객체 / <style> 블록. 색 리터럴이 이 안에 있으면 클래스 스캐너를 우회한 것.
67
+ const STYLE_OBJECT_RE = /style=\{\{([\s\S]*?)\}\}/g;
68
+ const STYLE_TAG_RE = /<style[^>]*>([\s\S]*?)<\/style>/g;
69
+ // var(...) 는 허용이므로 매치 대상에서 자연히 빠진다(hex/색함수 리터럴만 탐지).
70
+ const INLINE_COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb)\(/g;
71
+
72
+ // daisyUI 잔재. 판별력 높은 compound(변형 suffix 포함) 패턴만 error 로 잡아 오탐을 억제한다.
73
+ const DAISYUI_LEGACY_RE = new RegExp(
74
+ `${LEAD}(?:` +
75
+ // 버튼/배지/알림/입력 등 variant compound
76
+ "btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|" +
77
+ "badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|" +
78
+ "alert-(?:info|success|warning|error)|" +
79
+ "input-(?:bordered|primary|secondary|accent|ghost|error)|" +
80
+ "select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|" +
81
+ "checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|" +
82
+ "loading-(?:spinner|dots|ring|ball|bars|infinity)|" +
83
+ // 구조 클래스(daisyUI 전용 조각)
84
+ "card-(?:body|title|actions)|modal-(?:box|action|backdrop)|" +
85
+ "collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|" +
86
+ "stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|" +
87
+ "menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|" +
88
+ "mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle)" +
89
+ `)${TAIL}`,
90
+ "g",
91
+ );
92
+
93
+ const ALL_RULES: StyleGuardRule[] = [
94
+ "raw-palette",
95
+ "arbitrary-color",
96
+ "inline-color",
97
+ "daisyui-legacy",
98
+ "interpolated-arbitrary",
99
+ ];
100
+
101
+ /**
102
+ * 위반 억제 지시어(escape hatch). 정당한 팔레트 요청(에디터 신택스 하이라이트, 데이터-viz, 의도적 브랜드
103
+ * 데모)은 거절이 아니라 "명시적 옵트아웃"으로 라우팅한다 — 천장은 제약하지 않되 흔적을 남긴다.
104
+ * styleguard-disable <rule?> → 파일 전체
105
+ * styleguard-disable-next-line <rule?> → 바로 다음 줄
106
+ * rule 을 생략하면 모든 규칙을 억제한다.
107
+ */
108
+ interface Directives {
109
+ file: Set<StyleGuardRule> | "all" | null;
110
+ nextLine: Map<number, Set<StyleGuardRule> | "all">;
111
+ }
112
+
113
+ export class StyleGuard {
114
+ run(files: StyleGuardFile[]): StyleGuardViolation[] {
115
+ const out: StyleGuardViolation[] = [];
116
+ for (const file of files) {
117
+ const directives = this.#parseDirectives(file.content);
118
+ // 주석 내용을 공백으로 치환(offset·줄번호 보존). 주석에 남은 예전 클래스명이 오탐되지 않게.
119
+ // directives 는 원본에서 파싱한다 — 지시어 자체가 주석이기 때문.
120
+ const scan = this.#stripComments(file.content);
121
+ const local: StyleGuardViolation[] = [];
122
+ this.#scanClassLiterals(file, scan, local);
123
+ this.#scanInlineColors(file, scan, local);
124
+ for (const v of local) if (!this.#isSuppressed(v, directives)) out.push(v);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** 라인/블록 주석을 같은 길이의 공백으로 치환. 문자열/템플릿 리터럴 안의 `//`·`/*` 는 보존. */
130
+ #stripComments(src: string): string {
131
+ const out = src.split("");
132
+ const blank = (i: number) => {
133
+ if (src[i] !== "\n") out[i] = " ";
134
+ };
135
+ let state: "code" | "line" | "block" | "squote" | "dquote" | "template" = "code";
136
+ let i = 0;
137
+ while (i < src.length) {
138
+ const c = src[i];
139
+ const d = src[i + 1];
140
+ if (state === "code") {
141
+ if (c === "/" && d === "/") {
142
+ blank(i);
143
+ blank(i + 1);
144
+ state = "line";
145
+ i += 2;
146
+ } else if (c === "/" && d === "*") {
147
+ blank(i);
148
+ blank(i + 1);
149
+ state = "block";
150
+ i += 2;
151
+ } else {
152
+ if (c === "'") state = "squote";
153
+ else if (c === '"') state = "dquote";
154
+ else if (c === "`") state = "template";
155
+ i++;
156
+ }
157
+ } else if (state === "line") {
158
+ if (c === "\n") state = "code";
159
+ else blank(i);
160
+ i++;
161
+ } else if (state === "block") {
162
+ if (c === "*" && d === "/") {
163
+ blank(i);
164
+ blank(i + 1);
165
+ state = "code";
166
+ i += 2;
167
+ } else {
168
+ blank(i);
169
+ i++;
170
+ }
171
+ } else {
172
+ // 문자열/템플릿: 내용 보존, escape 건너뛰고 종결 문자에서 code 로 복귀.
173
+ const close = state === "squote" ? "'" : state === "dquote" ? '"' : "`";
174
+ if (c === "\\") i += 2;
175
+ else {
176
+ if (c === close) state = "code";
177
+ i++;
178
+ }
179
+ }
180
+ }
181
+ return out.join("");
182
+ }
183
+
184
+ #parseDirectives(content: string): Directives {
185
+ const directives: Directives = { file: null, nextLine: new Map() };
186
+ const lines = content.split("\n");
187
+ for (let i = 0; i < lines.length; i++) {
188
+ const line = lines[i];
189
+ const nextLineMatch = line.match(/styleguard-disable-next-line\b(.*)$/);
190
+ if (nextLineMatch) {
191
+ directives.nextLine.set(i + 2, this.#extractRules(nextLineMatch[1]));
192
+ continue;
193
+ }
194
+ const fileMatch = line.match(/styleguard-disable\b(.*)$/);
195
+ if (fileMatch) directives.file = this.#mergeFileScope(directives.file, this.#extractRules(fileMatch[1]));
196
+ }
197
+ return directives;
198
+ }
199
+
200
+ #extractRules(tail: string): Set<StyleGuardRule> | "all" {
201
+ const found = ALL_RULES.filter((rule) => tail.includes(rule));
202
+ return found.length === 0 ? "all" : new Set(found);
203
+ }
204
+
205
+ #mergeFileScope(
206
+ prev: Set<StyleGuardRule> | "all" | null,
207
+ next: Set<StyleGuardRule> | "all",
208
+ ): Set<StyleGuardRule> | "all" {
209
+ if (prev === "all" || next === "all") return "all";
210
+ return new Set([...(prev ?? []), ...next]);
211
+ }
212
+
213
+ #isSuppressed(v: StyleGuardViolation, directives: Directives): boolean {
214
+ if (directives.file === "all" || directives.file?.has(v.rule)) return true;
215
+ const nextLine = directives.nextLine.get(v.line);
216
+ return nextLine === "all" || (nextLine?.has(v.rule) ?? false);
217
+ }
218
+
219
+ #scanClassLiterals(file: StyleGuardFile, scan: string, out: StyleGuardViolation[]): void {
220
+ for (const m of scan.matchAll(RAW_PALETTE_RE)) {
221
+ out.push(
222
+ this.#violation(file, m.index ?? 0, {
223
+ rule: "raw-palette",
224
+ severity: "error",
225
+ suggestion:
226
+ "시맨틱 토큰으로 교체하세요 — 예: text-gray-500→text-muted-foreground, bg-red-500→bg-destructive, bg-blue-500→bg-info. 브랜드 색은 page/styles.css 토큰에서 조정합니다.",
227
+ }),
228
+ );
229
+ }
230
+ for (const m of scan.matchAll(ARBITRARY_COLOR_RE)) {
231
+ out.push(
232
+ this.#violation(file, m.index ?? 0, {
233
+ rule: "arbitrary-color",
234
+ severity: "error",
235
+ suggestion:
236
+ "임의 색 리터럴 대신 토큰을 쓰세요 — bg-[#3b82f6]→bg-info, text-[rgb(...)]→시맨틱 토큰. 꼭 필요하면 스코프 토큰(.campaign-x { --primary: … })을 정의하세요.",
237
+ }),
238
+ );
239
+ }
240
+ for (const m of scan.matchAll(DAISYUI_LEGACY_RE)) {
241
+ out.push(
242
+ this.#violation(file, m.index ?? 0, {
243
+ rule: "daisyui-legacy",
244
+ severity: "error",
245
+ suggestion:
246
+ "daisyUI 클래스는 제거됐습니다. akanjs/ui 프리미티브(Button/Badge 등)나 buttonRecipe()/badgeRecipe() + 시맨틱 토큰으로 교체하세요.",
247
+ }),
248
+ );
249
+ }
250
+ for (const m of scan.matchAll(INTERPOLATED_ARBITRARY_RE)) {
251
+ out.push(
252
+ this.#violation(file, m.index ?? 0, {
253
+ rule: "interpolated-arbitrary",
254
+ severity: "error",
255
+ suggestion:
256
+ "런타임 값으로 임의값 클래스를 조립하면 CSS 가 생성되지 않습니다(스캐너는 소스 텍스트를 읽습니다). 크기/위치는 style prop 으로 넘기세요 — style={{ minHeight }}. 값이 enum 이면 리터럴 클래스 맵으로 두세요.",
257
+ }),
258
+ );
259
+ }
260
+ }
261
+
262
+ #scanInlineColors(file: StyleGuardFile, scan: string, out: StyleGuardViolation[]): void {
263
+ const push = (index: number) =>
264
+ out.push(
265
+ this.#violation(file, index, {
266
+ rule: "inline-color",
267
+ severity: "error",
268
+ suggestion:
269
+ "인라인 색 리터럴 대신 CSS 변수/토큰을 참조하세요 — style={{ color: 'var(--primary)' }} 또는 시맨틱 클래스(text-primary). 하드코딩 hex/rgb 는 테마 전환을 깨뜨립니다.",
270
+ }),
271
+ );
272
+ for (const region of [STYLE_OBJECT_RE, STYLE_TAG_RE]) {
273
+ for (const m of scan.matchAll(region)) {
274
+ const inner = m[1] ?? "";
275
+ const innerStart = (m.index ?? 0) + m[0].indexOf(inner);
276
+ for (const lit of inner.matchAll(INLINE_COLOR_LITERAL_RE)) {
277
+ push(innerStart + (lit.index ?? 0));
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ #violation(
284
+ file: StyleGuardFile,
285
+ index: number,
286
+ base: Pick<StyleGuardViolation, "rule" | "severity" | "suggestion">,
287
+ ): StyleGuardViolation {
288
+ const before = file.content.slice(0, index);
289
+ const line = before.length === 0 ? 1 : before.split("\n").length;
290
+ const lineStart = before.lastIndexOf("\n") + 1;
291
+ const lineEnd = file.content.indexOf("\n", index);
292
+ const snippet = file.content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
293
+ return { ...base, path: file.path, line, snippet };
294
+ }
295
+
296
+ /**
297
+ * `@layer components` 안에서 선언된 명명 클래스 개수 — 그림자 디자인 시스템 증식의 warn 지표(차단 X).
298
+ */
299
+ static countNamedComponentClasses(css: string): NamedComponentClassMetric {
300
+ const names = new Set<string>();
301
+ const layerRe = /@layer\s+components\s*\{/g;
302
+ for (const layer of css.matchAll(layerRe)) {
303
+ const bodyStart = (layer.index ?? 0) + layer[0].length;
304
+ const body = StyleGuard.#extractBalanced(css, bodyStart);
305
+ for (const sel of body.matchAll(/(^|[\s}])\.(-?[a-zA-Z_][\w-]*)/g)) names.add(sel[2]);
306
+ }
307
+ return { count: names.size, names: [...names].sort() };
308
+ }
309
+
310
+ static #extractBalanced(css: string, start: number): string {
311
+ let depth = 1;
312
+ for (let i = start; i < css.length; i++) {
313
+ const ch = css[i];
314
+ if (ch === "{") depth++;
315
+ else if (ch === "}") {
316
+ depth--;
317
+ if (depth === 0) return css.slice(start, i);
318
+ }
319
+ }
320
+ return css.slice(start);
321
+ }
322
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { ThemeValidator } from "./themeValidator";
5
+
6
+ const validator = new ThemeValidator();
7
+
8
+ describe("ThemeValidator.parseHex", () => {
9
+ test("parses shorthand, full, and alpha hex", () => {
10
+ expect(ThemeValidator.parseHex("#fff")).toEqual([255, 255, 255]);
11
+ expect(ThemeValidator.parseHex("#0a0a0a")).toEqual([10, 10, 10]);
12
+ expect(ThemeValidator.parseHex("#ffffff80")).toEqual([255, 255, 255]);
13
+ });
14
+
15
+ test("returns null for non-hex values", () => {
16
+ expect(ThemeValidator.parseHex("var(--primary)")).toBeNull();
17
+ expect(ThemeValidator.parseHex("rgb(0,0,0)")).toBeNull();
18
+ });
19
+ });
20
+
21
+ describe("ThemeValidator.contrastRatio", () => {
22
+ test("black/white is 21:1", () => {
23
+ expect(ThemeValidator.contrastRatio([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 0);
24
+ });
25
+ });
26
+
27
+ describe("ThemeValidator.parseThemeTokens", () => {
28
+ test("distributes grouped selectors and normalizes quotes", () => {
29
+ const css = `:root, [data-theme=dark] { --background: #0a0a0a; --foreground: #fafafa; }`;
30
+ const tokens = ThemeValidator.parseThemeTokens(css);
31
+ expect(tokens[":root"].background).toBe("#0a0a0a");
32
+ expect(tokens['[data-theme="dark"]'].foreground).toBe("#fafafa");
33
+ });
34
+
35
+ test("ignores at-rule blocks like @theme", () => {
36
+ const css = `@theme inline { --color-primary: var(--primary); } :root { --background: #fff; }`;
37
+ const tokens = ThemeValidator.parseThemeTokens(css);
38
+ expect(Object.keys(tokens)).toEqual([":root"]);
39
+ });
40
+ });
41
+
42
+ describe("ThemeValidator.validate", () => {
43
+ test("flags a low-contrast pair", () => {
44
+ const css = `:root { --background: #ffffff; --foreground: #eeeeee; }`;
45
+ const violations = validator.validate(css);
46
+ expect(violations).toHaveLength(1);
47
+ expect(violations[0].pair).toBe("background / foreground");
48
+ expect(violations[0].ratio).toBeLessThan(4.5);
49
+ });
50
+
51
+ test("passes when status pairs meet the relaxed 3:1 threshold", () => {
52
+ // info #3b82f6 on white ≈ 3.68 — below 4.5 but above the 3:1 UI threshold.
53
+ const css = `:root { --info: #3b82f6; --info-foreground: #ffffff; }`;
54
+ expect(validator.validate(css)).toHaveLength(0);
55
+ });
56
+
57
+ test("skips pairs whose token is a var() reference", () => {
58
+ const css = `:root { --primary: var(--brand); --primary-foreground: #000000; }`;
59
+ expect(validator.validate(css)).toHaveLength(0);
60
+ });
61
+ });
62
+
63
+ describe("shipped framework palette", () => {
64
+ test("pkgs/akanjs/ui/styles.css passes WCAG contrast for all scopes", () => {
65
+ const stylesPath = path.resolve(import.meta.dir, "../../../akanjs/ui/styles.css");
66
+ const css = readFileSync(stylesPath, "utf8");
67
+ const violations = validator.validate(css);
68
+ expect(violations).toEqual([]);
69
+ });
70
+ });