clckernel 1.2.6

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.
Files changed (71) hide show
  1. package/AGENTS.md +124 -0
  2. package/README.es.md +166 -0
  3. package/README.md +167 -0
  4. package/bin/cli.js +12 -0
  5. package/docs/Journal/001-adr-clckernel-governance.md +18 -0
  6. package/package.json +44 -0
  7. package/src/adapters/astro.js +67 -0
  8. package/src/adapters/base.js +109 -0
  9. package/src/adapters/django.js +70 -0
  10. package/src/adapters/fastapi.js +69 -0
  11. package/src/adapters/go.js +50 -0
  12. package/src/adapters/laravel.js +52 -0
  13. package/src/adapters/nextjs.js +72 -0
  14. package/src/adapters/rails.js +54 -0
  15. package/src/adapters/rust.js +51 -0
  16. package/src/catalog.js +217 -0
  17. package/src/config.js +250 -0
  18. package/src/detector.js +85 -0
  19. package/src/doctor.js +125 -0
  20. package/src/generator.js +473 -0
  21. package/src/index.js +52 -0
  22. package/src/technologies/detector.js +243 -0
  23. package/src/technologies/guards/_shared.js +66 -0
  24. package/src/ui.js +101 -0
  25. package/src/yaml.js +173 -0
  26. package/test/adapters/astro.test.js +102 -0
  27. package/test/adapters/base.test.js +86 -0
  28. package/test/adapters/django.test.js +95 -0
  29. package/test/adapters/fastapi.test.js +88 -0
  30. package/test/adapters/go.test.js +81 -0
  31. package/test/adapters/laravel.test.js +106 -0
  32. package/test/adapters/nextjs.test.js +113 -0
  33. package/test/adapters/rails.test.js +108 -0
  34. package/test/adapters/rust.test.js +83 -0
  35. package/test/catalog.test.js +170 -0
  36. package/test/config.test.js +512 -0
  37. package/test/detector.test.js +345 -0
  38. package/test/doctor.test.js +302 -0
  39. package/test/generator.test.js +419 -0
  40. package/test/guards_new.test.js +95 -0
  41. package/test/helpers.js +36 -0
  42. package/test/integration/cli-flow.test.js +270 -0
  43. package/test/technologies/celery_guard.test.js +185 -0
  44. package/test/technologies/detector.test.js +342 -0
  45. package/test/technologies/docker_guard.test.js +187 -0
  46. package/test/technologies/integration.test.js +123 -0
  47. package/test/technologies/postgres_guard.test.js +87 -0
  48. package/test/technologies/redis_guard.test.js +128 -0
  49. package/tools/audit.js +219 -0
  50. package/tools/audit.py +273 -0
  51. package/tools/celery_guard.py +209 -0
  52. package/tools/check_a11y.js +109 -0
  53. package/tools/check_api_contracts.js +139 -0
  54. package/tools/check_architecture.js +139 -0
  55. package/tools/check_architecture.py +264 -0
  56. package/tools/check_custom.js +163 -0
  57. package/tools/check_custom.py +395 -0
  58. package/tools/check_db_efficiency.py +190 -0
  59. package/tools/check_migrations.py +223 -0
  60. package/tools/check_performance.js +142 -0
  61. package/tools/check_responsive.js +131 -0
  62. package/tools/check_scope.py +180 -0
  63. package/tools/check_seo.js +139 -0
  64. package/tools/check_storybook.js +108 -0
  65. package/tools/check_ui_reuse.js +135 -0
  66. package/tools/docker_guard.py +210 -0
  67. package/tools/postgres_guard.py +192 -0
  68. package/tools/redis_guard.py +197 -0
  69. package/tools/scan_secrets.js +109 -0
  70. package/tools/scan_secrets.py +153 -0
  71. package/tools/verify_tdd.py +212 -0
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Scope Guard — SDD Scope Validation
4
+ Validates that changed files match the declared SDD scope by comparing
5
+ git diff output against the SDD affected files list.
6
+ Self-contained: uses only Python stdlib (pathlib, subprocess, json).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import pathlib
13
+ import re
14
+ import subprocess
15
+ import sys
16
+
17
+
18
+ def _get_changed_files(target_dir: str) -> list[str] | None:
19
+ """Return list of changed files from git diff, or None on failure."""
20
+ for ref in ("HEAD~1", "--cached"):
21
+ try:
22
+ args = ["git", "diff", "--name-only"]
23
+ if ref == "--cached":
24
+ args.append("--cached")
25
+ else:
26
+ args.append(ref)
27
+ out = subprocess.check_output(
28
+ args, cwd=target_dir,
29
+ stderr=subprocess.DEVNULL, text=True,
30
+ )
31
+ files = [f.strip() for f in out.splitlines() if f.strip()]
32
+ if files:
33
+ return files
34
+ except (subprocess.CalledProcessError, FileNotFoundError):
35
+ continue
36
+ return None
37
+
38
+
39
+ def _find_sdd_declared_files(target_dir: str) -> list[str] | None:
40
+ """
41
+ Find declared affected files from the active SDD directory.
42
+ Looks for 00-state.md, 01-proposal.md, or any .md with 'affected' or 'files'.
43
+ Returns None if no SDD context found.
44
+ """
45
+ root = pathlib.Path(target_dir)
46
+ sdds_dir = root / "sdds"
47
+ if not sdds_dir.is_dir():
48
+ return None
49
+
50
+ # Find active SDD dir (most recently modified)
51
+ candidates = sorted(
52
+ [d for d in sdds_dir.iterdir() if d.is_dir()],
53
+ key=lambda d: d.stat().st_mtime,
54
+ reverse=True,
55
+ )
56
+ if not candidates:
57
+ return None
58
+
59
+ sdd_dir = candidates[0]
60
+
61
+ # Try to extract file list from SDD markdown files
62
+ declared: list[str] = []
63
+ for md_file in sdd_dir.glob("*.md"):
64
+ try:
65
+ content = md_file.read_text(encoding="utf-8", errors="replace")
66
+ except OSError:
67
+ continue
68
+
69
+ # Look for file references in common SDD patterns
70
+ # Pattern: `- path/to/file` or `| path/to/file |`
71
+ for line in content.splitlines():
72
+ # Match bullet-point file paths
73
+ m = re.match(r"\s*[-*]\s+`?([a-zA-Z_][\w./-]+\.\w+)`?", line)
74
+ if m:
75
+ declared.append(m.group(1))
76
+ # Match table cell file paths
77
+ m = re.match(r"\s*\|[^|]*`?([a-zA-Z_][\w./-]+\.\w+)`?[^|]*\|", line)
78
+ if m:
79
+ declared.append(m.group(1))
80
+
81
+ # Deduplicate while preserving order
82
+ seen: set[str] = set()
83
+ unique: list[str] = []
84
+ for f in declared:
85
+ if f not in seen:
86
+ seen.add(f)
87
+ unique.append(f)
88
+
89
+ return unique if unique else None
90
+
91
+
92
+ def check(target_dir: str | None = None) -> dict:
93
+ """
94
+ Validate that changed files match declared SDD scope.
95
+
96
+ Returns:
97
+ dict with keys: exit_code, skipped, name, data
98
+ data.authorized, data.warnings, data.unrelated — lists of file paths
99
+ """
100
+ dir_path = target_dir or os.getcwd()
101
+
102
+ changed = _get_changed_files(dir_path)
103
+ if changed is None:
104
+ return {
105
+ "exit_code": 0, "skipped": True, "name": "check_scope",
106
+ "data": {
107
+ "authorized": [], "warnings": [], "unrelated": [],
108
+ "message": "No git diff available",
109
+ },
110
+ }
111
+
112
+ if not changed:
113
+ return {
114
+ "exit_code": 0, "skipped": False, "name": "check_scope",
115
+ "data": {
116
+ "authorized": [], "warnings": [], "unrelated": [],
117
+ "message": "No changed files",
118
+ },
119
+ }
120
+
121
+ declared = _find_sdd_declared_files(dir_path)
122
+ if declared is None:
123
+ # No SDD context — all files are "unrelated" but it's not an error
124
+ return {
125
+ "exit_code": 0, "skipped": False, "name": "check_scope",
126
+ "data": {
127
+ "authorized": [], "warnings": [], "unrelated": changed,
128
+ "message": "No SDD context found — all changed files are undeclared",
129
+ },
130
+ }
131
+
132
+ authorized: list[str] = []
133
+ warnings: list[str] = []
134
+ unrelated: list[str] = []
135
+
136
+ for f in changed:
137
+ if f in declared:
138
+ authorized.append(f)
139
+ else:
140
+ # Check if it's in the same directory as a declared file (warn)
141
+ declared_dirs = {str(pathlib.Path(d).parent) for d in declared}
142
+ if str(pathlib.Path(f).parent) in declared_dirs:
143
+ warnings.append(f)
144
+ else:
145
+ unrelated.append(f)
146
+
147
+ total = len(changed)
148
+ if unrelated:
149
+ msg = (
150
+ f"{len(unrelated)} file(s) not in declared SDD scope "
151
+ f"({len(authorized)} authorized, {len(warnings)} related warnings)"
152
+ )
153
+ exit_code = 1
154
+ elif warnings:
155
+ msg = (
156
+ f"All files related to declared scope "
157
+ f"({len(authorized)} authorized, {len(warnings)} related warnings)"
158
+ )
159
+ exit_code = 0
160
+ else:
161
+ msg = f"All {total} changed file(s) are within declared SDD scope"
162
+ exit_code = 0
163
+
164
+ return {
165
+ "exit_code": exit_code,
166
+ "skipped": False,
167
+ "name": "check_scope",
168
+ "data": {
169
+ "authorized": authorized,
170
+ "warnings": warnings,
171
+ "unrelated": unrelated,
172
+ "message": msg,
173
+ },
174
+ }
175
+
176
+
177
+ if __name__ == "__main__":
178
+ result = check(sys.argv[1] if len(sys.argv) > 1 else None)
179
+ print(json.dumps(result, indent=2))
180
+ sys.exit(result["exit_code"])
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SEO, GEO & Web Performance Guard
4
+ * Scans changed frontend files (.tsx, .jsx, .vue, .astro, .html) for:
5
+ * 1. Missing page metadata (title, description, canonical)
6
+ * 2. OpenGraph & Social Sharing tags (og:title, og:image, og:description)
7
+ * 3. Semantic HTML & Heading hierarchy (single <h1> per page, <main> landmark usage)
8
+ * 4. GEO & Localized Structured Data tags (geo.region, geo.position, ICBM, JSON-LD)
9
+ * 5. LCP & Performance: Hero image priority optimization
10
+ *
11
+ * Self-contained: uses only Node.js built-ins.
12
+ *
13
+ * @param {string} targetDir - Directory to scan (defaults to cwd)
14
+ * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
15
+ */
16
+ const check = async function checkSeo(targetDir) {
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { execSync } = require('child_process');
20
+
21
+ const dir = targetDir || process.cwd();
22
+
23
+ let files;
24
+ try {
25
+ const out = execSync('git diff --name-only HEAD~1', {
26
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
27
+ });
28
+ files = out.split('\n').filter(f => /\.(tsx|jsx|vue|astro|html)$/.test(f.trim()));
29
+ } catch {
30
+ try {
31
+ const out = execSync('git diff --name-only --cached', {
32
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
33
+ });
34
+ files = out.split('\n').filter(f => /\.(tsx|jsx|vue|astro|html)$/.test(f.trim()));
35
+ } catch {
36
+ return { exitCode: 0, skipped: true, name: 'check_seo', data: { violations: {}, message: 'No git diff available' } };
37
+ }
38
+ }
39
+
40
+ if (!files || files.length === 0) {
41
+ return { exitCode: 0, skipped: false, name: 'check_seo', data: { violations: {}, message: 'No frontend files changed' } };
42
+ }
43
+
44
+ const violations = {};
45
+
46
+ for (const file of files) {
47
+ const filePath = path.join(dir, file);
48
+ if (!fs.existsSync(filePath)) continue;
49
+
50
+ let content;
51
+ try {
52
+ content = fs.readFileSync(filePath, 'utf-8');
53
+ } catch {
54
+ continue;
55
+ }
56
+
57
+ const lines = content.split('\n');
58
+ const fileViolations = [];
59
+ const isPageOrLayout = /(?:page|layout|index|App|document)\.(?:tsx|jsx|astro|vue|html)$/i.test(file);
60
+
61
+ // 1. Heading hierarchy: Check for multiple <h1> in a single page file
62
+ const h1Matches = content.match(/<h1\b/gi) || [];
63
+ if (isPageOrLayout && h1Matches.length > 1) {
64
+ fileViolations.push({
65
+ lineNo: 1,
66
+ rule: 'seo-multiple-h1',
67
+ message: `Found ${h1Matches.length} <h1> tags in single page component. Only one <h1> is recommended for SEO hierarchy.`
68
+ });
69
+ }
70
+
71
+ // 2. Line-by-line checks
72
+ for (let i = 0; i < lines.length; i++) {
73
+ const line = lines[i];
74
+
75
+ // Check for malformed GEO tags if GEO meta is present
76
+ if (/name\s*=\s*['"]geo\.position['"]/i.test(line) && !/content\s*=\s*['"]-?\d+\.\d+;-?\d+\.\d+['"]/i.test(line)) {
77
+ fileViolations.push({
78
+ lineNo: i + 1,
79
+ rule: 'geo-position-format',
80
+ message: 'Malformed geo.position meta tag. Expected format: content="latitude;longitude"'
81
+ });
82
+ }
83
+
84
+ if (/name\s*=\s*['"]ICBM['"]/i.test(line) && !/content\s*=\s*['"]-?\d+\.\d+,\s*-?\d+\.\d+['"]/i.test(line)) {
85
+ fileViolations.push({
86
+ lineNo: i + 1,
87
+ rule: 'geo-icbm-format',
88
+ message: 'Malformed ICBM meta tag. Expected format: content="latitude, longitude"'
89
+ });
90
+ }
91
+
92
+ // Check for Hero / Banner images without priority (Core Web Vitals / LCP)
93
+ if (/(?:hero|banner|cover|hero-image|heroImage|HeroBanner)/i.test(line) && /<(?:img|Image)\b/i.test(line)) {
94
+ if (!/priority|fetchpriority|loading\s*=\s*['"]eager['"]/i.test(line)) {
95
+ fileViolations.push({
96
+ lineNo: i + 1,
97
+ rule: 'cwv-lcp-priority',
98
+ message: 'Hero/Banner image missing priority / fetchpriority="high" attribute (Core Web Vitals LCP optimization).'
99
+ });
100
+ }
101
+ }
102
+
103
+ // Check for raw <a> with target="_blank" missing rel="noopener noreferrer"
104
+ if (/<a\b[^>]*target\s*=\s*['"]_blank['"][^>]*>/i.test(line) && !/rel\s*=\s*['"][^'"]*noopener/i.test(line)) {
105
+ fileViolations.push({
106
+ lineNo: i + 1,
107
+ rule: 'seo-security-target-blank',
108
+ message: 'External link with target="_blank" is missing rel="noopener noreferrer" (SEO authority leak & security vulnerability).'
109
+ });
110
+ }
111
+ }
112
+
113
+ if (fileViolations.length > 0) {
114
+ violations[file] = fileViolations;
115
+ }
116
+ }
117
+
118
+ const totalCount = Object.values(violations).flat().length;
119
+ return {
120
+ exitCode: totalCount > 0 ? 1 : 0,
121
+ skipped: false,
122
+ name: 'check_seo',
123
+ data: {
124
+ violations,
125
+ message: totalCount > 0
126
+ ? `${totalCount} SEO / GEO / Web Performance issue(s) found in ${Object.keys(violations).length} file(s)`
127
+ : 'All SEO, GEO & Web Performance checks passed',
128
+ },
129
+ };
130
+ };
131
+
132
+ module.exports = check;
133
+
134
+ if (require.main === module) {
135
+ check(process.argv[2]).then(result => {
136
+ console.log(JSON.stringify(result, null, 2));
137
+ process.exit(result.exitCode);
138
+ });
139
+ }
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Storybook Story Coverage Guard
4
+ * Checks that UI components have corresponding .stories.tsx files.
5
+ * Self-contained: uses only Node.js built-ins.
6
+ *
7
+ * @param {string} targetDir - Directory to scan (defaults to cwd)
8
+ * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
9
+ */
10
+ const check = async function checkStorybook(targetDir) {
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { execSync } = require('child_process');
14
+
15
+ const dir = targetDir || process.cwd();
16
+
17
+ // Check if Storybook is configured
18
+ const hasStorybook = fs.existsSync(path.join(dir, '.storybook')) ||
19
+ fs.existsSync(path.join(dir, 'storybook.config.js')) ||
20
+ fs.existsSync(path.join(dir, 'storybook.config.ts'));
21
+
22
+ if (!hasStorybook) {
23
+ return { exitCode: 0, skipped: true, name: 'check_storybook', data: { violations: {}, message: 'Storybook not configured — skipping' } };
24
+ }
25
+
26
+ // Collect changed component files
27
+ let files;
28
+ try {
29
+ const out = execSync('git diff --name-only HEAD~1', {
30
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
31
+ });
32
+ files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(f.trim()));
33
+ } catch {
34
+ try {
35
+ const out = execSync('git diff --name-only --cached', {
36
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
37
+ });
38
+ files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(f.trim()));
39
+ } catch {
40
+ return { exitCode: 0, skipped: true, name: 'check_storybook', data: { violations: {}, message: 'No git diff available' } };
41
+ }
42
+ }
43
+
44
+ if (!files || files.length === 0) {
45
+ return { exitCode: 0, skipped: false, name: 'check_storybook', data: { violations: {}, message: 'No frontend files changed' } };
46
+ }
47
+
48
+ // Filter to component files (skip test files, stories, types, utils)
49
+ const componentFiles = files.filter(f => {
50
+ if (f.includes('.stories.') || f.includes('.test.') || f.includes('.spec.')) return false;
51
+ if (f.includes('.d.ts') || f.includes('.types.')) return false;
52
+ if (f.includes('utils') || f.includes('helpers') || f.includes('lib')) return false;
53
+ if (f.includes('types') || f.includes('constants')) return false;
54
+ // Only check files that export a component (capitalized name in filename)
55
+ const basename = path.basename(f, path.extname(f));
56
+ return /^[A-Z]/.test(basename);
57
+ });
58
+
59
+ if (componentFiles.length === 0) {
60
+ return { exitCode: 0, skipped: false, name: 'check_storybook', data: { violations: {}, message: 'No component files changed' } };
61
+ }
62
+
63
+ const violations = {};
64
+
65
+ for (const file of componentFiles) {
66
+ const filePath = path.join(dir, file);
67
+ if (!fs.existsSync(filePath)) continue;
68
+
69
+ // Check if a corresponding .stories.tsx file exists
70
+ const basename = path.basename(file, path.extname(file));
71
+ const ext = path.extname(file);
72
+ const storyFile = path.join(path.dirname(file), `${basename}.stories.${ext.replace('.', '')}`);
73
+
74
+ // Also check co-located stories directory pattern
75
+ const storiesDir = path.join(path.dirname(file), '__stories__', `${basename}.stories${ext}`);
76
+
77
+ const hasStory = fs.existsSync(path.join(dir, storyFile)) || fs.existsSync(path.join(dir, storiesDir));
78
+
79
+ if (!hasStory) {
80
+ violations[file] = [{
81
+ lineNo: 0,
82
+ rule: 'missing-story',
83
+ message: `Component "${basename}" has no corresponding Storybook story — create ${basename}.stories.tsx`,
84
+ }];
85
+ }
86
+ }
87
+
88
+ const totalCount = Object.values(violations).flat().length;
89
+ return {
90
+ exitCode: totalCount > 0 ? 1 : 0,
91
+ skipped: false,
92
+ name: 'check_storybook',
93
+ data: {
94
+ violations,
95
+ message: totalCount > 0
96
+ ? `${totalCount} component(s) missing Storybook stories`
97
+ : 'All Storybook coverage checks passed',
98
+ },
99
+ };
100
+ };
101
+ module.exports = check;
102
+
103
+ if (require.main === module) {
104
+ check(process.argv[2]).then(result => {
105
+ console.log(JSON.stringify(result, null, 2));
106
+ process.exit(result.exitCode);
107
+ });
108
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * UI Component Reuse Guard
4
+ * Verifies @/components/ui/ imports and semantic Tailwind token usage.
5
+ * Self-contained: uses only Node.js built-ins.
6
+ *
7
+ * @param {string} targetDir - Directory to scan (defaults to cwd)
8
+ * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
9
+ */
10
+ const check = async function checkUiReuse(targetDir) {
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { execSync } = require('child_process');
14
+
15
+ const dir = targetDir || process.cwd();
16
+
17
+ let files;
18
+ try {
19
+ const out = execSync('git diff --name-only HEAD~1', {
20
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
21
+ });
22
+ files = out.split('\n').filter(f => /\.(tsx|jsx|ts|js)$/.test(f.trim()));
23
+ } catch {
24
+ try {
25
+ const out = execSync('git diff --name-only --cached', {
26
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
27
+ });
28
+ files = out.split('\n').filter(f => /\.(tsx|jsx|ts|js)$/.test(f.trim()));
29
+ } catch {
30
+ return { exitCode: 0, skipped: true, name: 'check_ui_reuse', data: { violations: {}, message: 'No git diff available' } };
31
+ }
32
+ }
33
+
34
+ if (!files || files.length === 0) {
35
+ return { exitCode: 0, skipped: false, name: 'check_ui_reuse', data: { violations: {}, message: 'No files changed' } };
36
+ }
37
+
38
+ const violations = {};
39
+
40
+ for (const file of files) {
41
+ const filePath = path.join(dir, file);
42
+ if (!fs.existsSync(filePath)) continue;
43
+
44
+ let content;
45
+ try {
46
+ content = fs.readFileSync(filePath, 'utf-8');
47
+ } catch {
48
+ continue;
49
+ }
50
+
51
+ const fileViolations = [];
52
+
53
+ // Check: non-component files importing from @/components/ui/ directly
54
+ // (components within ui/ are allowed to reference each other)
55
+ if (!file.includes('components/ui/') && /from\s+['"]@\/components\/ui\//.test(content)) {
56
+ // This is GOOD — it means the file reuses UI primitives
57
+ // We only flag when components are defined but NOT using the shared ui/ directory
58
+ }
59
+
60
+ // Check: raw HTML elements that should be replaced with UI primitives
61
+ // <button without className referencing ui/ or a design system
62
+ const hasRawButton = /<button\b(?![^>]*className\s*=\s*['"][^'"]*(?:ui-|btn-|button))/i.test(content);
63
+ if (hasRawButton && !/from\s+['"]@\/components\/ui\//.test(content)) {
64
+ fileViolations.push({
65
+ lineNo: 0,
66
+ rule: 'raw-button',
67
+ message: 'Raw <button> without UI primitive — consider using @/components/ui/ Button',
68
+ });
69
+ }
70
+
71
+ // Check: raw <select> without UI primitive
72
+ if (/<select\b/i.test(content) && !/from\s+['"]@\/components\/ui\//.test(content)) {
73
+ fileViolations.push({
74
+ lineNo: 0,
75
+ rule: 'raw-select',
76
+ message: 'Raw <select> without UI primitive — consider using @/components/ui/ Select',
77
+ });
78
+ }
79
+
80
+ // Check: raw <input> without UI primitive (except type=hidden)
81
+ if (/<input\b(?!.*type\s*=\s*['"]hidden['"])/i.test(content) && !/from\s+['"]@\/components\/ui\//.test(content)) {
82
+ fileViolations.push({
83
+ lineNo: 0,
84
+ rule: 'raw-input',
85
+ message: 'Raw <input> without UI primitive — consider using @/components/ui/ Input',
86
+ });
87
+ }
88
+
89
+ // Check: hardcoded colors that should use semantic tokens
90
+ // Raw Tailwind color classes (bg-blue-500, text-red-600, etc.)
91
+ const rawColorRegex = /\b(?:bg|text|border|ring|shadow|outline|from|to|via)-(?:red|blue|green|yellow|orange|purple|pink|indigo|teal|cyan|emerald|lime|amber|violet|fuchsia|rose|sky|slate|gray|zinc|neutral|stone)-\d+\b/g;
92
+ const rawColors = content.match(rawColorRegex);
93
+ if (rawColors && rawColors.length > 0) {
94
+ fileViolations.push({
95
+ lineNo: 0,
96
+ rule: 'raw-colors',
97
+ message: `Raw color classes found (${rawColors.slice(0, 3).join(', ')}${rawColors.length > 3 ? '...' : ''}) — use semantic tokens (bg-primary, text-muted-foreground)`,
98
+ });
99
+ }
100
+
101
+ // Check: inline styles that bypass Tailwind
102
+ if (/style\s*=\s*\{\s*\{/.test(content)) {
103
+ fileViolations.push({
104
+ lineNo: 0,
105
+ rule: 'inline-styles',
106
+ message: 'Inline styles detected — prefer Tailwind utility classes for consistency',
107
+ });
108
+ }
109
+
110
+ if (fileViolations.length > 0) {
111
+ violations[file] = fileViolations;
112
+ }
113
+ }
114
+
115
+ const totalCount = Object.values(violations).flat().length;
116
+ return {
117
+ exitCode: totalCount > 0 ? 1 : 0,
118
+ skipped: false,
119
+ name: 'check_ui_reuse',
120
+ data: {
121
+ violations,
122
+ message: totalCount > 0
123
+ ? `${totalCount} UI reuse issue(s) found in ${Object.keys(violations).length} file(s)`
124
+ : 'All UI reuse checks passed',
125
+ },
126
+ };
127
+ };
128
+ module.exports = check;
129
+
130
+ if (require.main === module) {
131
+ check(process.argv[2]).then(result => {
132
+ console.log(JSON.stringify(result, null, 2));
133
+ process.exit(result.exitCode);
134
+ });
135
+ }