@hasna/hooks 0.2.3 → 0.2.4
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/README.md +302 -107
- package/bin/index.js +316 -51
- package/dist/index.js +216 -14
- package/hooks/hook-stylescheck/LICENSE +191 -0
- package/hooks/hook-stylescheck/README.md +41 -0
- package/hooks/hook-stylescheck/package.json +52 -0
- package/hooks/hook-stylescheck/src/hook.ts +213 -0
- package/hooks/hook-stylescheck/tsconfig.json +25 -0
- package/package.json +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code Hook: stylescheck
|
|
5
|
+
*
|
|
6
|
+
* PreToolUse hook that intercepts Write/Edit calls on frontend files
|
|
7
|
+
* (.tsx, .jsx, .css, .html, .scss) and warns on design anti-patterns:
|
|
8
|
+
* - Hardcoded hex/rgb colors outside of design tokens
|
|
9
|
+
* - Inline style objects with hardcoded values
|
|
10
|
+
* - Magic number font sizes and spacing values
|
|
11
|
+
* - Non-design-system z-index values
|
|
12
|
+
*
|
|
13
|
+
* If an open-styles profile is found at ~/.hooks/styles.json, it injects
|
|
14
|
+
* project-specific design context to remind the agent of the design system.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, existsSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
|
|
21
|
+
interface HookInput {
|
|
22
|
+
session_id?: string;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
tool_name: string;
|
|
25
|
+
tool_input: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HookOutput {
|
|
29
|
+
decision?: "approve" | "block";
|
|
30
|
+
reason?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StylesProfile {
|
|
34
|
+
design_system?: string;
|
|
35
|
+
color_tokens?: string[];
|
|
36
|
+
banned_patterns?: string[];
|
|
37
|
+
notes?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FRONTEND_EXTENSIONS = /\.(tsx|jsx|css|scss|html|svelte)$/i;
|
|
41
|
+
|
|
42
|
+
const BANNED_PATTERNS: Array<{ pattern: RegExp; message: string; severity: "warn" | "block" }> = [
|
|
43
|
+
// Hardcoded hex colors
|
|
44
|
+
{
|
|
45
|
+
pattern: /(?<![a-zA-Z0-9_-])#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/,
|
|
46
|
+
message: "Hardcoded hex color found. Use design tokens or CSS variables instead (e.g. var(--color-primary) or a Tailwind color class).",
|
|
47
|
+
severity: "warn",
|
|
48
|
+
},
|
|
49
|
+
// Hardcoded rgb/rgba colors
|
|
50
|
+
{
|
|
51
|
+
pattern: /rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+/,
|
|
52
|
+
message: "Hardcoded rgb/rgba color found. Use design tokens or CSS variables instead.",
|
|
53
|
+
severity: "warn",
|
|
54
|
+
},
|
|
55
|
+
// Inline style objects with color/background
|
|
56
|
+
{
|
|
57
|
+
pattern: /style\s*=\s*\{\s*\{[^}]*(?:color|background|backgroundColor)\s*:/,
|
|
58
|
+
message: "Inline style with color/background detected. Prefer Tailwind classes or CSS variables.",
|
|
59
|
+
severity: "warn",
|
|
60
|
+
},
|
|
61
|
+
// Magic font sizes in px
|
|
62
|
+
{
|
|
63
|
+
pattern: /fontSize\s*:\s*['"]?\d+px['"]?/,
|
|
64
|
+
message: "Magic pixel font size detected. Use design-system type scale (e.g. text-sm, text-base, text-lg).",
|
|
65
|
+
severity: "warn",
|
|
66
|
+
},
|
|
67
|
+
// Magic z-index values (not 0, 10, 20, 30... multiples of 10 are ok)
|
|
68
|
+
{
|
|
69
|
+
pattern: /z-index\s*:\s*(?!(?:0|10|20|30|40|50|100|200|999|9999)\b)\d+/,
|
|
70
|
+
message: "Non-standard z-index value. Prefer multiples of 10 or named z-index tokens.",
|
|
71
|
+
severity: "warn",
|
|
72
|
+
},
|
|
73
|
+
// !important in CSS (outside of resets)
|
|
74
|
+
{
|
|
75
|
+
pattern: /!important/,
|
|
76
|
+
message: "!important usage detected. This indicates specificity issues — refactor the CSS selector instead.",
|
|
77
|
+
severity: "warn",
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
function readStdinJson(): HookInput | null {
|
|
82
|
+
try {
|
|
83
|
+
const input = readFileSync(0, "utf-8").trim();
|
|
84
|
+
if (!input) return null;
|
|
85
|
+
return JSON.parse(input);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadStylesProfile(): StylesProfile | null {
|
|
92
|
+
const profilePath = join(homedir(), ".hooks", "styles.json");
|
|
93
|
+
if (!existsSync(profilePath)) return null;
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(profilePath, "utf-8"));
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getFilePath(input: HookInput): string | null {
|
|
102
|
+
const toolInput = input.tool_input;
|
|
103
|
+
// Write tool uses 'file_path', Edit tool uses 'file_path'
|
|
104
|
+
const filePath = toolInput?.file_path as string | undefined;
|
|
105
|
+
return filePath || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getContent(input: HookInput): string {
|
|
109
|
+
const toolInput = input.tool_input;
|
|
110
|
+
// Write: 'content', Edit: 'new_string'
|
|
111
|
+
const content = (toolInput?.content || toolInput?.new_string || "") as string;
|
|
112
|
+
return typeof content === "string" ? content : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkPatterns(content: string, profile: StylesProfile | null): string[] {
|
|
116
|
+
const violations: string[] = [];
|
|
117
|
+
|
|
118
|
+
// Check banned patterns from profile
|
|
119
|
+
if (profile?.banned_patterns) {
|
|
120
|
+
for (const bp of profile.banned_patterns) {
|
|
121
|
+
try {
|
|
122
|
+
const re = new RegExp(bp, "i");
|
|
123
|
+
if (re.test(content)) {
|
|
124
|
+
violations.push(`Project-specific banned pattern matched: ${bp}`);
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check built-in patterns
|
|
131
|
+
for (const { pattern, message } of BANNED_PATTERNS) {
|
|
132
|
+
if (pattern.test(content)) {
|
|
133
|
+
violations.push(message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return violations;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildReason(filePath: string, violations: string[], profile: StylesProfile | null): string {
|
|
141
|
+
const lines: string[] = [
|
|
142
|
+
`[stylescheck] Design pattern issues in ${filePath}:`,
|
|
143
|
+
"",
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
for (const v of violations) {
|
|
147
|
+
lines.push(` ⚠ ${v}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (profile?.design_system) {
|
|
151
|
+
lines.push("");
|
|
152
|
+
lines.push(` Design system: ${profile.design_system}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (profile?.color_tokens && profile.color_tokens.length > 0) {
|
|
156
|
+
lines.push(` Color tokens: ${profile.color_tokens.slice(0, 8).join(", ")}${profile.color_tokens.length > 8 ? "..." : ""}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (profile?.notes) {
|
|
160
|
+
lines.push(` Notes: ${profile.notes}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push("");
|
|
164
|
+
lines.push(" Please fix the issues above before proceeding. If intentional, you can proceed anyway.");
|
|
165
|
+
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function respond(output: HookOutput): void {
|
|
170
|
+
console.log(JSON.stringify(output));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function run(): void {
|
|
174
|
+
const input = readStdinJson();
|
|
175
|
+
|
|
176
|
+
if (!input) {
|
|
177
|
+
respond({ decision: "approve" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!["Write", "Edit"].includes(input.tool_name)) {
|
|
182
|
+
respond({ decision: "approve" });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const filePath = getFilePath(input);
|
|
187
|
+
if (!filePath || !FRONTEND_EXTENSIONS.test(filePath)) {
|
|
188
|
+
respond({ decision: "approve" });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const content = getContent(input);
|
|
193
|
+
if (!content) {
|
|
194
|
+
respond({ decision: "approve" });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const profile = loadStylesProfile();
|
|
199
|
+
const violations = checkPatterns(content, profile);
|
|
200
|
+
|
|
201
|
+
if (violations.length === 0) {
|
|
202
|
+
respond({ decision: "approve" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const reason = buildReason(filePath, violations, profile);
|
|
207
|
+
console.error(`[hook-stylescheck] ${violations.length} design issue(s) in ${filePath}`);
|
|
208
|
+
respond({ decision: "block", reason });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (import.meta.main) {
|
|
212
|
+
run();
|
|
213
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ESNext"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"allowImportingTsExtensions": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"declaration": true,
|
|
18
|
+
"declarationMap": true,
|
|
19
|
+
"outDir": "./dist",
|
|
20
|
+
"rootDir": "./src",
|
|
21
|
+
"types": ["bun-types"]
|
|
22
|
+
},
|
|
23
|
+
"include": ["src/**/*"],
|
|
24
|
+
"exclude": ["node_modules", "dist"]
|
|
25
|
+
}
|
package/package.json
CHANGED