@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,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* themeValidator — 시맨틱 토큰 페어의 WCAG 콘트라스트 자동 검사 (의존성 0).
|
|
3
|
+
*
|
|
4
|
+
* 토큰 구조(`--x` ↔ `--x-foreground`)라서 가능해진 검사. AI 가 생성한 팔레트가 "안 읽히는 사이트"가
|
|
5
|
+
* 되는 것을 원천 차단한다. akanjs 런타임을 import 하지 않는다 — 순수 함수만.
|
|
6
|
+
*
|
|
7
|
+
* 임계값(WCAG 2.1):
|
|
8
|
+
* - 본문/주요 표면 페어(background·primary·secondary·accent·neutral·card·popover): 4.5:1 (AA normal text)
|
|
9
|
+
* - 상태/보조 페어(info·success·warning·destructive·open·muted): 3:1 (UI 컴포넌트 / 큰 텍스트 / 보조 표면)
|
|
10
|
+
* 현행 styles.css 기본 팔레트(light/dark)는 이 임계값을 모두 통과한다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ThemeContrastViolation {
|
|
14
|
+
scope: string;
|
|
15
|
+
pair: string;
|
|
16
|
+
background: string;
|
|
17
|
+
foreground: string;
|
|
18
|
+
ratio: number;
|
|
19
|
+
threshold: number;
|
|
20
|
+
suggestion: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PairDef {
|
|
24
|
+
base: string;
|
|
25
|
+
fg: string;
|
|
26
|
+
threshold: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PAIRS: PairDef[] = [
|
|
30
|
+
{ base: "background", fg: "foreground", threshold: 4.5 },
|
|
31
|
+
{ base: "primary", fg: "primary-foreground", threshold: 4.5 },
|
|
32
|
+
{ base: "secondary", fg: "secondary-foreground", threshold: 4.5 },
|
|
33
|
+
{ base: "accent", fg: "accent-foreground", threshold: 4.5 },
|
|
34
|
+
{ base: "neutral", fg: "neutral-foreground", threshold: 4.5 },
|
|
35
|
+
{ base: "card", fg: "card-foreground", threshold: 4.5 },
|
|
36
|
+
{ base: "popover", fg: "popover-foreground", threshold: 4.5 },
|
|
37
|
+
{ base: "muted", fg: "muted-foreground", threshold: 3 },
|
|
38
|
+
{ base: "info", fg: "info-foreground", threshold: 3 },
|
|
39
|
+
{ base: "success", fg: "success-foreground", threshold: 3 },
|
|
40
|
+
{ base: "warning", fg: "warning-foreground", threshold: 3 },
|
|
41
|
+
{ base: "destructive", fg: "destructive-foreground", threshold: 3 },
|
|
42
|
+
{ base: "open", fg: "open-foreground", threshold: 3 },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// 검사 대상 스코프. 그 외 셀렉터(.campaign-x 스코프 토큰 등)는 페어 검사에서 제외.
|
|
46
|
+
const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
|
|
47
|
+
|
|
48
|
+
export type ThemeTokensByScope = Record<string, Record<string, string>>;
|
|
49
|
+
|
|
50
|
+
export class ThemeValidator {
|
|
51
|
+
/** CSS 문자열에서 토큰을 추출해 알려진 테마 스코프 전체를 검사한다. */
|
|
52
|
+
validate(css: string): ThemeContrastViolation[] {
|
|
53
|
+
const tokensByScope = ThemeValidator.parseThemeTokens(css);
|
|
54
|
+
const violations: ThemeContrastViolation[] = [];
|
|
55
|
+
for (const [scope, tokens] of Object.entries(tokensByScope)) {
|
|
56
|
+
if (!THEME_SCOPES.has(scope)) continue;
|
|
57
|
+
violations.push(...this.validateScope(tokens, scope));
|
|
58
|
+
}
|
|
59
|
+
return violations;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
validateScope(tokens: Record<string, string>, scope: string): ThemeContrastViolation[] {
|
|
63
|
+
const violations: ThemeContrastViolation[] = [];
|
|
64
|
+
for (const { base, fg, threshold } of PAIRS) {
|
|
65
|
+
const bg = tokens[base];
|
|
66
|
+
const front = tokens[fg];
|
|
67
|
+
if (!bg || !front) continue;
|
|
68
|
+
const bgRgb = ThemeValidator.parseHex(bg);
|
|
69
|
+
const fgRgb = ThemeValidator.parseHex(front);
|
|
70
|
+
if (!bgRgb || !fgRgb) continue; // var()/비-hex 값은 검사 불가 → 건너뜀
|
|
71
|
+
const ratio = ThemeValidator.contrastRatio(bgRgb, fgRgb);
|
|
72
|
+
if (ratio >= threshold) continue;
|
|
73
|
+
violations.push({
|
|
74
|
+
scope,
|
|
75
|
+
pair: `${base} / ${fg}`,
|
|
76
|
+
background: bg,
|
|
77
|
+
foreground: front,
|
|
78
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
79
|
+
threshold,
|
|
80
|
+
suggestion: `${scope}의 --${base}(${bg})와 --${fg}(${front}) 대비가 ${ratio.toFixed(2)}:1 로 최소 ${threshold}:1 미만입니다. 한쪽을 더 밝게/어둡게 조정해 대비를 확보하세요.`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return violations;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `:root` / `[data-theme="…"]` 블록에서 `--token: value` 를 파싱한다. 그룹 셀렉터
|
|
88
|
+
* (`:root, [data-theme="dark"] { … }`)는 각 셀렉터에 동일 토큰을 분배. 동일 스코프 재등장 시 나중 값이 이긴다
|
|
89
|
+
* (프레임워크-먼저 / 앱-나중 순서로 넘기면 앱 override 가 반영됨).
|
|
90
|
+
*/
|
|
91
|
+
static parseThemeTokens(css: string): ThemeTokensByScope {
|
|
92
|
+
const result: ThemeTokensByScope = {};
|
|
93
|
+
// 중첩 없는 단순 규칙 블록만 매칭(@theme/@keyframes 등 at-rule 은 셀렉터에 @ 포함이라 제외).
|
|
94
|
+
const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
|
|
95
|
+
for (const block of css.matchAll(blockRe)) {
|
|
96
|
+
const selectors = block[1].split(",").map((s) => s.trim());
|
|
97
|
+
const relevant = selectors.filter((s) => THEME_SCOPES.has(ThemeValidator.#normalizeScope(s)));
|
|
98
|
+
if (relevant.length === 0) continue;
|
|
99
|
+
const decls: Record<string, string> = {};
|
|
100
|
+
for (const decl of block[2].matchAll(/--([\w-]+)\s*:\s*([^;]+?)\s*(?:;|$)/g)) {
|
|
101
|
+
decls[decl[1]] = decl[2].trim();
|
|
102
|
+
}
|
|
103
|
+
for (const selector of relevant) {
|
|
104
|
+
const scope = ThemeValidator.#normalizeScope(selector);
|
|
105
|
+
result[scope] = { ...(result[scope] ?? {}), ...decls };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static #normalizeScope(selector: string): string {
|
|
112
|
+
// 따옴표 정규화: [data-theme=dark] / [data-theme='dark'] → [data-theme="dark"]
|
|
113
|
+
return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** #rgb / #rgba / #rrggbb / #rrggbbaa → [r,g,b] (alpha 무시). 비-hex 는 null. */
|
|
117
|
+
static parseHex(value: string): [number, number, number] | null {
|
|
118
|
+
const v = value.trim();
|
|
119
|
+
if (!v.startsWith("#")) return null;
|
|
120
|
+
const hex = v.slice(1);
|
|
121
|
+
let full: string;
|
|
122
|
+
if (hex.length === 3 || hex.length === 4)
|
|
123
|
+
full = hex
|
|
124
|
+
.slice(0, 3)
|
|
125
|
+
.split("")
|
|
126
|
+
.map((c) => c + c)
|
|
127
|
+
.join("");
|
|
128
|
+
else if (hex.length === 6 || hex.length === 8) full = hex.slice(0, 6);
|
|
129
|
+
else return null;
|
|
130
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
|
|
131
|
+
const n = Number.parseInt(full, 16);
|
|
132
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static #relativeLuminance([r, g, b]: [number, number, number]): number {
|
|
136
|
+
const channel = (c: number) => {
|
|
137
|
+
const s = c / 255;
|
|
138
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
139
|
+
};
|
|
140
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static contrastRatio(a: [number, number, number], b: [number, number, number]): number {
|
|
144
|
+
const la = ThemeValidator.#relativeLuminance(a);
|
|
145
|
+
const lb = ThemeValidator.#relativeLuminance(b);
|
|
146
|
+
const hi = Math.max(la, lb);
|
|
147
|
+
const lo = Math.min(la, lb);
|
|
148
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type * from "./mobile";
|
|
|
42
42
|
export type * from "./packageExportsMap";
|
|
43
43
|
export type * from "./prompter";
|
|
44
44
|
export type * from "./qualityScanner";
|
|
45
|
+
export type * from "./recipeScanner";
|
|
45
46
|
export type * from "./scanInfo";
|
|
46
47
|
export type * from "./selectModel";
|
|
47
48
|
export type * from "./spinner";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Arbitrary-value color literals (`bg-[#3b82f6]`, `text-[rgb(0,0,0)]`) bypass the semantic
|
|
5
|
+
// token layer, so they ignore data-theme switching and theme contrast validation. Grit port
|
|
6
|
+
// of styleGuard's `arbitrary-color` rule (frontendBuild/styleGuard.ts) — keep in sync.
|
|
7
|
+
// Variable references (`bg-[--brand]`, `text-[var(--fg)]`) do not match, by design.
|
|
8
|
+
or {
|
|
9
|
+
JsxString() as $s,
|
|
10
|
+
JsStringLiteralExpression() as $s,
|
|
11
|
+
JsTemplateChunkElement() as $s
|
|
12
|
+
} where {
|
|
13
|
+
$s <: r"[\s\S]*\[(?:#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb|color)\([^\]]*\))\][\s\S]*",
|
|
14
|
+
register_diagnostic(
|
|
15
|
+
span = $s,
|
|
16
|
+
message = "Arbitrary color value bypasses the semantic token layer (no theme switching, no contrast validation). Use a semantic token, or reference a variable (bg-[--brand]) declared in styles.css.",
|
|
17
|
+
severity = "error"
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// daisyUI was removed from the UI system; its component classes (`btn-primary`, `card-body`,
|
|
5
|
+
// `mockup-code`, ...) no longer have any CSS behind them and silently render unstyled. Grit
|
|
6
|
+
// port of styleGuard's `daisyui-legacy` rule (frontendBuild/styleGuard.ts) — keep in sync.
|
|
7
|
+
// Only high-signal compound names are matched; bare ambiguous words that collide with
|
|
8
|
+
// Tailwind or app classes (`card`, `input`, `badge`, `btn`) are deliberately not flagged.
|
|
9
|
+
or {
|
|
10
|
+
JsxString() as $s,
|
|
11
|
+
JsStringLiteralExpression() as $s,
|
|
12
|
+
JsTemplateChunkElement() as $s
|
|
13
|
+
} where {
|
|
14
|
+
$s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
|
|
15
|
+
register_diagnostic(
|
|
16
|
+
span = $s,
|
|
17
|
+
message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled. Use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
|
|
18
|
+
severity = "error"
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Color literals inside `style={{ ... }}` bypass the class scanner, the semantic token layer,
|
|
5
|
+
// and theme switching entirely. Grit port of styleGuard's `inline-color` rule
|
|
6
|
+
// (frontendBuild/styleGuard.ts) — keep in sync. `var(--token)` references do not match.
|
|
7
|
+
//
|
|
8
|
+
// Known delta vs the TS guard: color literals inside a `<style>` tag's template body are not
|
|
9
|
+
// matched here (template chunks are not scoped by this pattern); the build-gate styleGuard
|
|
10
|
+
// still covers that case.
|
|
11
|
+
JsStringLiteralExpression() as $s where {
|
|
12
|
+
$s <: within `style={$obj}`,
|
|
13
|
+
$s <: r"[\s\S]*(?:#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb)\()[\s\S]*",
|
|
14
|
+
register_diagnostic(
|
|
15
|
+
span = $s,
|
|
16
|
+
message = "Inline color literal in a style object bypasses semantic tokens and theme switching. Use a token class, or style={{ color: 'var(--primary)' }} when a runtime value is unavoidable.",
|
|
17
|
+
severity = "error"
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Tailwind extracts arbitrary values from raw source *text*, so a bracket whose contents are built at
|
|
5
|
+
// runtime (`min-h-[${minHeight}px]`, `bg-[${color}]`) compiles to NO css. The class name exists in the DOM
|
|
6
|
+
// and the prop looks wired, but nothing applies — the failure is completely silent, and it gets worse when a
|
|
7
|
+
// literal of the same shape happens to exist elsewhere in the codebase: the default value works by accident
|
|
8
|
+
// while every override dies quietly (that is exactly how `Empty`'s minHeight went unnoticed).
|
|
9
|
+
//
|
|
10
|
+
// Grit port of styleGuard's `interpolated-arbitrary` rule (frontendBuild/styleGuard.ts) — keep the two
|
|
11
|
+
// regexes in sync. Matching template *chunks* is what makes this detectable: the chunk before `${` still
|
|
12
|
+
// carries the unterminated `-[`.
|
|
13
|
+
//
|
|
14
|
+
// The fix is a style prop for the runtime value (`style={{ minHeight }}`) — dimensions are not colors, so
|
|
15
|
+
// no-inline-color does not apply — or a fixed set of literal classes when the value comes from an enum.
|
|
16
|
+
// Escape hatch: `// biome-ignore lint/plugin: <reason>`.
|
|
17
|
+
JsTemplateChunkElement() as $chunk where {
|
|
18
|
+
$chunk <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])[a-z][a-z0-9-]*-\[[^\]]*$",
|
|
19
|
+
register_diagnostic(
|
|
20
|
+
span = $chunk,
|
|
21
|
+
message = "Interpolated Tailwind arbitrary value (e.g. `min-h-[${n}px]`) - the scanner reads source text, so this compiles to no CSS and is silently ignored. Use a style prop for the runtime value, or a fixed set of literal classes.",
|
|
22
|
+
severity = "error"
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Vocabulary closure (`@theme { --color-*: initial }` in akanjs/ui/styles.css) strips the raw
|
|
5
|
+
// Tailwind palette, so classes like `bg-blue-500` generate NO css and silently render as
|
|
6
|
+
// currentColor/transparent. This rule surfaces that silent breakage at lint time. It is the
|
|
7
|
+
// grit port of styleGuard's `raw-palette` rule (frontendBuild/styleGuard.ts) — keep the two
|
|
8
|
+
// regexes in sync. Matching string/template nodes (not raw text) skips comments for free.
|
|
9
|
+
//
|
|
10
|
+
// Bare `bg-neutral` is a semantic token and allowed; the numeric suffix (`bg-neutral-500`)
|
|
11
|
+
// is what marks a raw palette class. black/white stay in the vocabulary for overlays.
|
|
12
|
+
// Escape hatch for legitimate fixed colors: `// biome-ignore lint/plugin: <reason>`
|
|
13
|
+
// (file-wide: `// biome-ignore-all lint/plugin: <reason>`).
|
|
14
|
+
or {
|
|
15
|
+
JsxString() as $s,
|
|
16
|
+
JsStringLiteralExpression() as $s,
|
|
17
|
+
JsTemplateChunkElement() as $s
|
|
18
|
+
} where {
|
|
19
|
+
$s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
|
|
20
|
+
register_diagnostic(
|
|
21
|
+
span = $s,
|
|
22
|
+
message = "Raw Tailwind palette class (e.g. bg-blue-500) - vocabulary closure strips these, so they render as no CSS. Use a semantic token (bg-primary, text-foreground/70, ...) or add a biome-ignore with a reason.",
|
|
23
|
+
severity = "error"
|
|
24
|
+
)
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "
|
|
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": "
|
|
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,46 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AbstractDoc } from "./abstractDoc";
|
|
6
|
+
import { AkanQualityScanner } from "./qualityScanner";
|
|
7
|
+
|
|
8
|
+
const tempRoots: string[] = [];
|
|
9
|
+
|
|
10
|
+
const makeWorkspace = async (files: Record<string, string>) => {
|
|
11
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-quality-scanner-"));
|
|
12
|
+
tempRoots.push(root);
|
|
13
|
+
for (const [filePath, content] of Object.entries({ ".gitignore": "node_modules\n", ...files })) {
|
|
14
|
+
const absolutePath = path.join(root, filePath);
|
|
15
|
+
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
16
|
+
await writeFile(absolutePath, content);
|
|
17
|
+
}
|
|
18
|
+
return root;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const abstractOf = (lineNum: number) =>
|
|
22
|
+
["# post Abstract", ...Array.from({ length: lineNum - 1 }, (_, idx) => `- rule ${idx}`)].join("\n");
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("AkanQualityScanner abstract rule", () => {
|
|
29
|
+
test("warns on an abstract over the line limit and points at akan compact", async () => {
|
|
30
|
+
const root = await makeWorkspace({
|
|
31
|
+
"apps/demo/lib/post/post.abstract.md": abstractOf(AbstractDoc.maxLines + 1),
|
|
32
|
+
"apps/demo/lib/post/post.constant.ts": "export class Post {}\n",
|
|
33
|
+
"libs/shared/lib/user/user.abstract.md": abstractOf(AbstractDoc.maxLines),
|
|
34
|
+
"libs/shared/lib/user/user.constant.ts": "export class User {}\n",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const result = await new AkanQualityScanner().scan(root);
|
|
38
|
+
const warnings = result.warnings.filter((warning) => warning.rule === "akan.file.abstract-max-lines");
|
|
39
|
+
|
|
40
|
+
expect(result.scannedFiles).toBe(4);
|
|
41
|
+
expect(warnings).toHaveLength(1);
|
|
42
|
+
expect(warnings[0]?.file).toBe("apps/demo/lib/post/post.abstract.md");
|
|
43
|
+
expect(warnings[0]?.message).toContain(`${AbstractDoc.maxLines + 1} lines`);
|
|
44
|
+
expect(warnings[0]?.fix).toContain("akan compact");
|
|
45
|
+
});
|
|
46
|
+
});
|
package/qualityScanner.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import ignore from "ignore";
|
|
5
5
|
import ts from "typescript";
|
|
6
|
+
import { AbstractDoc } from "./abstractDoc";
|
|
6
7
|
|
|
7
8
|
type QualitySeverity = "warning";
|
|
8
9
|
type QualityScope = "global" | "file" | "convention" | "layout";
|
|
@@ -32,6 +33,11 @@ interface SourceFileInfo {
|
|
|
32
33
|
sourceFile: ts.SourceFile;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
interface TextFileInfo {
|
|
37
|
+
file: string;
|
|
38
|
+
content: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
interface ExportedFunctionLike {
|
|
36
42
|
name: string;
|
|
37
43
|
kind: "class" | "function" | "function-variable";
|
|
@@ -142,6 +148,8 @@ const RULE_FIXES: Record<string, string> = {
|
|
|
142
148
|
"akan.file.recommended-max-lines":
|
|
143
149
|
"Split the file by responsibility — move Zones, Utils, or subcomponents into sibling files.",
|
|
144
150
|
"akan.file.max-lines": "Break the file into smaller focused modules; keep one primary responsibility per file.",
|
|
151
|
+
"akan.file.abstract-max-lines":
|
|
152
|
+
"Run `akan compact <app-or-lib>` to rewrite the abstract with the AI editor, keeping only the invariants and workflows the source files cannot show.",
|
|
145
153
|
"akan.file.placeholder-export":
|
|
146
154
|
"Remove the placeholder export; generated indexes should only re-export real modules.",
|
|
147
155
|
"akan.file.dictionary-stale-text": "Replace the scaffold text with real localized copy for this dictionary entry.",
|
|
@@ -172,18 +180,28 @@ function getRuleFix(rule: string): string | undefined {
|
|
|
172
180
|
export class AkanQualityScanner {
|
|
173
181
|
async scan(workspaceRoot: string): Promise<QualityScanResult> {
|
|
174
182
|
const targetFiles = await this.#collectTargetFiles(workspaceRoot);
|
|
175
|
-
const sourceFiles = await Promise.all(
|
|
183
|
+
const sourceFiles = await Promise.all(
|
|
184
|
+
targetFiles
|
|
185
|
+
.filter((file) => !AbstractDoc.isAbstractPath(file))
|
|
186
|
+
.map((file) => this.#readSourceFile(workspaceRoot, file)),
|
|
187
|
+
);
|
|
188
|
+
const abstractFiles = await Promise.all(
|
|
189
|
+
targetFiles
|
|
190
|
+
.filter((file) => AbstractDoc.isAbstractPath(file))
|
|
191
|
+
.map((file) => this.#readTextFile(workspaceRoot, file)),
|
|
192
|
+
);
|
|
176
193
|
const warnings = [
|
|
177
194
|
...this.#scanGlobalQuality(sourceFiles),
|
|
178
195
|
...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
|
|
179
196
|
...sourceFiles.flatMap((sourceFile) => this.#scanComponentQuality(sourceFile)),
|
|
180
197
|
...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
|
|
181
198
|
...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
|
|
199
|
+
...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
|
|
182
200
|
];
|
|
183
201
|
|
|
184
202
|
return {
|
|
185
203
|
workspaceRoot,
|
|
186
|
-
scannedFiles: sourceFiles.length,
|
|
204
|
+
scannedFiles: sourceFiles.length + abstractFiles.length,
|
|
187
205
|
warnings: warnings
|
|
188
206
|
.map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
|
|
189
207
|
.sort(compareWarnings),
|
|
@@ -226,6 +244,8 @@ export class AkanQualityScanner {
|
|
|
226
244
|
}
|
|
227
245
|
if ((relativePath.endsWith(".ts") || relativePath.endsWith(".tsx")) && !relativePath.endsWith(".d.ts")) {
|
|
228
246
|
files.push(relativePath);
|
|
247
|
+
} else if (AbstractDoc.isAbstractPath(relativePath)) {
|
|
248
|
+
files.push(relativePath);
|
|
229
249
|
}
|
|
230
250
|
}
|
|
231
251
|
}
|
|
@@ -241,6 +261,10 @@ export class AkanQualityScanner {
|
|
|
241
261
|
};
|
|
242
262
|
}
|
|
243
263
|
|
|
264
|
+
async #readTextFile(workspaceRoot: string, file: string): Promise<TextFileInfo> {
|
|
265
|
+
return { file, content: await readFile(path.join(workspaceRoot, file), "utf8") };
|
|
266
|
+
}
|
|
267
|
+
|
|
244
268
|
#scanGlobalQuality(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
245
269
|
const exportedFunctionLikes = sourceFiles.flatMap((sourceFile) => getExportedFunctionLikes(sourceFile));
|
|
246
270
|
const warnings: QualityWarning[] = [];
|
|
@@ -391,6 +415,20 @@ export class AkanQualityScanner {
|
|
|
391
415
|
return warnings;
|
|
392
416
|
}
|
|
393
417
|
|
|
418
|
+
#scanAbstractQuality({ file, content }: TextFileInfo): QualityWarning[] {
|
|
419
|
+
const lineCount = AbstractDoc.lineCountOf(content);
|
|
420
|
+
if (lineCount <= AbstractDoc.maxLines) return [];
|
|
421
|
+
return [
|
|
422
|
+
{
|
|
423
|
+
rule: "akan.file.abstract-max-lines",
|
|
424
|
+
scope: "file",
|
|
425
|
+
severity: "warning",
|
|
426
|
+
file,
|
|
427
|
+
message: `Abstract has ${lineCount} lines. Keep abstracts under ${AbstractDoc.maxLines} lines and compact them periodically.`,
|
|
428
|
+
},
|
|
429
|
+
];
|
|
430
|
+
}
|
|
431
|
+
|
|
394
432
|
#scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
|
|
395
433
|
const segments = sourceFile.file.split("/");
|
|
396
434
|
const warnings: QualityWarning[] = [];
|
|
@@ -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
|
+
});
|