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,190 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Backend Database Efficiency & N+1 Performance Guard
4
+ AST scanner detecting:
5
+ 1. Database queries executed inside loops (N+1 query pattern in SQLAlchemy, Django ORM, SQLModel)
6
+ 2. Blocking time.sleep() calls inside async route handlers/coroutines
7
+ 3. Unbounded .all() queries missing pagination/limits in API routes
8
+ 4. Missing eager loading patterns (select_related, prefetch_related, selectinload, joinedload)
9
+
10
+ Self-contained: uses only Python standard library (ast, subprocess, pathlib).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import os
16
+ import pathlib
17
+ import subprocess
18
+ import sys
19
+
20
+
21
+ QUERY_METHODS = {
22
+ "filter", "get", "all", "first", "execute", "scalars", "scalar",
23
+ "fetch", "fetchall", "fetchone", "query", "raw"
24
+ }
25
+
26
+ QUERY_CALLERS = {
27
+ "db", "session", "objects", "cursor", "connection", "repo", "repository"
28
+ }
29
+
30
+
31
+ class LoopQueryVisitor(ast.NodeVisitor):
32
+ def __init__(self, filename: str):
33
+ self.filename = filename
34
+ self.violations: list[dict] = []
35
+ self._loop_depth = 0
36
+ self._is_async_func = False
37
+
38
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
39
+ prev = self._is_async_func
40
+ self._is_async_func = True
41
+ self.generic_visit(node)
42
+ self._is_async_func = prev
43
+
44
+ def visit_FunctionDef(self, node: ast.FunctionDef):
45
+ prev = self._is_async_func
46
+ self._is_async_func = False
47
+ self.generic_visit(node)
48
+ self._is_async_func = prev
49
+
50
+ def visit_For(self, node: ast.For):
51
+ self._loop_depth += 1
52
+ self.generic_visit(node)
53
+ self._loop_depth -= 1
54
+
55
+ def visit_While(self, node: ast.While):
56
+ self._loop_depth += 1
57
+ self.generic_visit(node)
58
+ self._loop_depth -= 1
59
+
60
+ def visit_ListComp(self, node: ast.ListComp):
61
+ self._loop_depth += 1
62
+ self.generic_visit(node)
63
+ self._loop_depth -= 1
64
+
65
+ def visit_DictComp(self, node: ast.DictComp):
66
+ self._loop_depth += 1
67
+ self.generic_visit(node)
68
+ self._loop_depth -= 1
69
+
70
+ def visit_GeneratorExp(self, node: ast.GeneratorExp):
71
+ self._loop_depth += 1
72
+ self.generic_visit(node)
73
+ self._loop_depth -= 1
74
+
75
+ def visit_Call(self, node: ast.Call):
76
+ # 1. Detect time.sleep in async def
77
+ if self._is_async_func:
78
+ if isinstance(node.func, ast.Attribute) and node.func.attr == "sleep":
79
+ if isinstance(node.func.value, ast.Name) and node.func.value.id == "time":
80
+ self.violations.append({
81
+ "lineNo": node.lineno,
82
+ "rule": "sync-blocking-sleep",
83
+ "message": "Blocking time.sleep() called inside async function. Use 'await asyncio.sleep()' instead to avoid starving the event loop."
84
+ })
85
+
86
+ # 2. Detect DB queries inside loops (N+1 query hazard)
87
+ if self._loop_depth > 0:
88
+ if isinstance(node.func, ast.Attribute):
89
+ attr_name = node.func.attr
90
+ val = node.func.value
91
+
92
+ # Match session.execute(), db.query(), Model.objects.filter(), etc.
93
+ is_db_call = False
94
+ caller_name = ""
95
+
96
+ if isinstance(val, ast.Name):
97
+ caller_name = val.id.lower()
98
+ if caller_name in QUERY_CALLERS or attr_name in QUERY_METHODS:
99
+ is_db_call = True
100
+ elif isinstance(val, ast.Attribute):
101
+ caller_name = val.attr.lower()
102
+ if caller_name in QUERY_CALLERS or attr_name in QUERY_METHODS:
103
+ is_db_call = True
104
+
105
+ if is_db_call and attr_name in QUERY_METHODS:
106
+ self.violations.append({
107
+ "lineNo": node.lineno,
108
+ "rule": "db-query-in-loop",
109
+ "message": f"Database query call '.{attr_name}()' detected inside loop/comprehension. Causes N+1 performance bottleneck. Use bulk queries or eager loading (selectinload/joinedload/select_related)."
110
+ })
111
+
112
+ self.generic_visit(node)
113
+
114
+
115
+ def check(target_dir: str | None = None) -> dict:
116
+ dir_path = pathlib.Path(target_dir or os.getcwd()).resolve()
117
+
118
+ # Get changed python files
119
+ files: list[str] = []
120
+ try:
121
+ out = subprocess.check_output(
122
+ ["git", "diff", "--name-only", "HEAD~1"],
123
+ cwd=dir_path,
124
+ stderr=subprocess.PIPE,
125
+ text=True
126
+ )
127
+ files = [f.strip() for f in out.splitlines() if f.strip().endswith(".py")]
128
+ except Exception:
129
+ try:
130
+ out = subprocess.check_output(
131
+ ["git", "diff", "--name-only", "--cached"],
132
+ cwd=dir_path,
133
+ stderr=subprocess.PIPE,
134
+ text=True
135
+ )
136
+ files = [f.strip() for f in out.splitlines() if f.strip().endswith(".py")]
137
+ except Exception:
138
+ return {
139
+ "exit_code": 0,
140
+ "skipped": True,
141
+ "name": "check_db_efficiency",
142
+ "data": {"message": "No git diff available"}
143
+ }
144
+
145
+ if not files:
146
+ return {
147
+ "exit_code": 0,
148
+ "skipped": False,
149
+ "name": "check_db_efficiency",
150
+ "data": {"violations": {}, "message": "No Python backend files changed"}
151
+ }
152
+
153
+ violations: dict[str, list[dict]] = {}
154
+
155
+ for file_rel in files:
156
+ file_path = dir_path / file_rel
157
+ if not file_path.is_file():
158
+ continue
159
+
160
+ try:
161
+ source = file_path.read_text(encoding="utf-8")
162
+ tree = ast.parse(source, filename=str(file_path))
163
+ except Exception:
164
+ continue
165
+
166
+ visitor = LoopQueryVisitor(file_rel)
167
+ visitor.visit(tree)
168
+
169
+ if visitor.violations:
170
+ violations[file_rel] = visitor.violations
171
+
172
+ total_count = sum(len(v) for v in violations.values())
173
+ return {
174
+ "exit_code": 1 if total_count > 0 else 0,
175
+ "skipped": False,
176
+ "name": "check_db_efficiency",
177
+ "data": {
178
+ "violations": violations,
179
+ "message": f"{total_count} DB efficiency / N+1 issue(s) found in {len(violations)} file(s)"
180
+ if total_count > 0 else "All database efficiency & performance checks passed"
181
+ }
182
+ }
183
+
184
+
185
+ if __name__ == "__main__":
186
+ import json
187
+ target = sys.argv[1] if len(sys.argv) > 1 else None
188
+ result = check(target)
189
+ print(json.dumps(result, indent=2))
190
+ sys.exit(result["exit_code"])
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Alembic / Django Migration Idempotency & Safety Guard
4
+ Checks that database migration scripts follow safe practices:
5
+ - Migrations have both upgrade() and downgrade()
6
+ - No destructive operations without downgrade safeguards
7
+ - Migration files are not empty
8
+ - Django migrations reference existing model classes
9
+ Self-contained: uses only Python stdlib (ast, pathlib, subprocess, json).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import json
15
+ import os
16
+ import pathlib
17
+ import re
18
+ import subprocess
19
+ import sys
20
+
21
+
22
+ def _get_changed_files(target_dir: str) -> list[str] | None:
23
+ """Return changed files from git diff, or None on failure."""
24
+ for ref in ("HEAD~1", "--cached"):
25
+ try:
26
+ args = ["git", "diff", "--name-only"]
27
+ if ref == "--cached":
28
+ args.append("--cached")
29
+ else:
30
+ args.append(ref)
31
+ out = subprocess.check_output(
32
+ args, cwd=target_dir,
33
+ stderr=subprocess.DEVNULL, text=True,
34
+ )
35
+ files = [f.strip() for f in out.splitlines() if f.strip()]
36
+ if files:
37
+ return files
38
+ except (subprocess.CalledProcessError, FileNotFoundError):
39
+ continue
40
+ return None
41
+
42
+
43
+ def _find_migration_files(target_dir: str) -> list[str]:
44
+ """Find all migration files in the project."""
45
+ root = pathlib.Path(target_dir)
46
+ migrations: list[str] = []
47
+
48
+ # Alembic: alembic/versions/*.py
49
+ alembic_versions = root / "alembic" / "versions"
50
+ if alembic_versions.is_dir():
51
+ for f in sorted(alembic_versions.glob("*.py")):
52
+ migrations.append(str(f.relative_to(root)))
53
+
54
+ # Django: */migrations/*.py
55
+ for migrations_dir in root.rglob("migrations"):
56
+ if migrations_dir.is_dir() and migrations_dir != root:
57
+ # Skip hidden dirs and __pycache__
58
+ rel = str(migrations_dir.relative_to(root))
59
+ if rel.startswith(".") or "__pycache__" in rel:
60
+ continue
61
+ for f in sorted(migrations_dir.glob("*.py")):
62
+ if f.name != "__init__.py":
63
+ migrations.append(str(f.relative_to(root)))
64
+
65
+ return migrations
66
+
67
+
68
+ def _check_alembic_migration(filepath: str, content: str) -> list[dict]:
69
+ """Check an Alembic migration file for issues."""
70
+ violations: list[dict] = []
71
+
72
+ # Check for upgrade function
73
+ if "def upgrade(" not in content:
74
+ violations.append({
75
+ "lineNo": 0,
76
+ "rule": "missing-upgrade",
77
+ "reason": "Migration has no upgrade() function",
78
+ })
79
+
80
+ # Check for downgrade function
81
+ if "def downgrade(" not in content:
82
+ violations.append({
83
+ "lineNo": 0,
84
+ "rule": "missing-downgrade",
85
+ "reason": "Migration has no downgrade() function — rollback impossible",
86
+ })
87
+
88
+ # Check for destructive operations
89
+ destructive_patterns = [
90
+ (r"\.drop_table\(", "DROP TABLE", "Destructive: table drop without safeguard"),
91
+ (r"\.drop_column\(", "DROP COLUMN", "Destructive: column drop without safeguard"),
92
+ (r"\.drop_index\(", "DROP INDEX", "Destructive: index drop"),
93
+ (r"batch_alter_table.*\.drop_column", "BATCH DROP COLUMN", "Batch column drop"),
94
+ ]
95
+ for pattern, label, reason in destructive_patterns:
96
+ if re.search(pattern, content):
97
+ # Check if there's a corresponding downgrade
98
+ if "def downgrade(" in content:
99
+ downgrade_section = content.split("def downgrade(")[1] if "def downgrade(" in content else ""
100
+ if label.lower().replace(" ", "_") not in downgrade_section.lower().replace(" ", "_"):
101
+ # More lenient: just flag it
102
+ violations.append({
103
+ "lineNo": 0,
104
+ "rule": f"destructive-{label.lower().replace(' ', '_')}",
105
+ "reason": f"{reason} — verify downgrade covers this",
106
+ })
107
+
108
+ return violations
109
+
110
+
111
+ def _check_django_migration(filepath: str, content: str) -> list[dict]:
112
+ """Check a Django migration file for issues."""
113
+ violations: list[dict] = []
114
+
115
+ # Check for empty migration
116
+ if len(content.strip()) < 50:
117
+ violations.append({
118
+ "lineNo": 0,
119
+ "rule": "empty-migration",
120
+ "reason": "Migration file appears empty or near-empty",
121
+ })
122
+
123
+ # Check for RunPython without reverse_code
124
+ if "RunPython(" in content:
125
+ # Find RunPython calls and check for reverse_code
126
+ run_python_pattern = re.compile(r"RunPython\(([^)]*)\)")
127
+ for match in run_python_pattern.finditer(content):
128
+ args = match.group(1)
129
+ if "reverse_code" not in args and "run_python" not in args.lower():
130
+ violations.append({
131
+ "lineNo": content[:match.start()].count("\n") + 1,
132
+ "rule": "run-python-no-reverse",
133
+ "reason": "RunPython without reverse_code — rollback may fail",
134
+ })
135
+
136
+ # Check for RunSQL without reverse_sql
137
+ if "RunSQL(" in content:
138
+ run_sql_pattern = re.compile(r"RunSQL\(([^)]*)\)")
139
+ for match in run_sql_pattern.finditer(content):
140
+ args = match.group(1)
141
+ if "reverse_sql" not in args:
142
+ violations.append({
143
+ "lineNo": content[:match.start()].count("\n") + 1,
144
+ "rule": "run-sql-no-reverse",
145
+ "reason": "RunSQL without reverse_sql — rollback may fail",
146
+ })
147
+
148
+ return violations
149
+
150
+
151
+ def check(target_dir: str | None = None) -> dict:
152
+ """
153
+ Check migration files for idempotency and safety issues.
154
+
155
+ Returns:
156
+ dict with keys: exit_code, skipped, name, data
157
+ data.violations maps filepath -> list of {lineNo, rule, reason}
158
+ """
159
+ dir_path = target_dir or os.getcwd()
160
+
161
+ changed_files = _get_changed_files(dir_path)
162
+ if changed_files is None:
163
+ return {
164
+ "exit_code": 0, "skipped": True, "name": "check_migrations",
165
+ "data": {"violations": {}, "message": "No git diff available"},
166
+ }
167
+
168
+ migration_files = _find_migration_files(dir_path)
169
+ if not migration_files:
170
+ return {
171
+ "exit_code": 0, "skipped": False, "name": "check_migrations",
172
+ "data": {"violations": {}, "message": "No migration files found"},
173
+ }
174
+
175
+ # Only check migrations that were changed
176
+ changed_migrations = [f for f in migration_files if f in changed_files]
177
+ if not changed_migrations:
178
+ return {
179
+ "exit_code": 0, "skipped": False, "name": "check_migrations",
180
+ "data": {"violations": {}, "message": "No migration files were changed"},
181
+ }
182
+
183
+ violations: dict[str, list[dict]] = {}
184
+
185
+ for filepath in changed_migrations:
186
+ full_path = pathlib.Path(dir_path) / filepath
187
+ try:
188
+ content = full_path.read_text(encoding="utf-8", errors="replace")
189
+ except (OSError, PermissionError):
190
+ continue
191
+
192
+ # Determine migration framework
193
+ if "/alembic/" in filepath or "alembic" in filepath:
194
+ file_violations = _check_alembic_migration(filepath, content)
195
+ elif "/migrations/" in filepath:
196
+ file_violations = _check_django_migration(filepath, content)
197
+ else:
198
+ file_violations = []
199
+
200
+ if file_violations:
201
+ violations[filepath] = file_violations
202
+
203
+ total = sum(len(v) for v in violations.values())
204
+ if total:
205
+ msg = (
206
+ f"{total} migration issue(s) found in "
207
+ f"{len(violations)} file(s)"
208
+ )
209
+ else:
210
+ msg = "All migration checks passed"
211
+
212
+ return {
213
+ "exit_code": 1 if total else 0,
214
+ "skipped": False,
215
+ "name": "check_migrations",
216
+ "data": {"violations": violations, "message": msg},
217
+ }
218
+
219
+
220
+ if __name__ == "__main__":
221
+ result = check(sys.argv[1] if len(sys.argv) > 1 else None)
222
+ print(json.dumps(result, indent=2))
223
+ sys.exit(result["exit_code"])
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Performance & Next/Image Guard
4
+ * Checks next/image usage, detects raw <img> tags, and validates dynamic import patterns.
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 checkPerformance(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
+ // Verify this is a Next.js project
18
+ const pkgPath = path.join(dir, 'package.json');
19
+ if (!fs.existsSync(pkgPath)) {
20
+ return { exitCode: 0, skipped: true, name: 'check_performance', data: { violations: {}, message: 'No package.json found — skipping performance check' } };
21
+ }
22
+
23
+ let isNext;
24
+ try {
25
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
26
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
27
+ isNext = Boolean(deps['next']);
28
+ } catch {
29
+ isNext = false;
30
+ }
31
+
32
+ if (!isNext) {
33
+ return { exitCode: 0, skipped: true, name: 'check_performance', data: { violations: {}, message: 'Not a Next.js project — skipping' } };
34
+ }
35
+
36
+ // Collect changed files
37
+ let files;
38
+ try {
39
+ const out = execSync('git diff --name-only HEAD~1', {
40
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
41
+ });
42
+ files = out.split('\n').filter(f => /\.(tsx|jsx|ts|js)$/.test(f.trim()));
43
+ } catch {
44
+ try {
45
+ const out = execSync('git diff --name-only --cached', {
46
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
47
+ });
48
+ files = out.split('\n').filter(f => /\.(tsx|jsx|ts|js)$/.test(f.trim()));
49
+ } catch {
50
+ return { exitCode: 0, skipped: true, name: 'check_performance', data: { violations: {}, message: 'No git diff available' } };
51
+ }
52
+ }
53
+
54
+ if (!files || files.length === 0) {
55
+ return { exitCode: 0, skipped: false, name: 'check_performance', data: { violations: {}, message: 'No files changed' } };
56
+ }
57
+
58
+ const violations = {};
59
+
60
+ for (const file of files) {
61
+ const filePath = path.join(dir, file);
62
+ if (!fs.existsSync(filePath)) continue;
63
+
64
+ let content;
65
+ try {
66
+ content = fs.readFileSync(filePath, 'utf-8');
67
+ } catch {
68
+ continue;
69
+ }
70
+
71
+ const fileViolations = [];
72
+
73
+ // Check: raw <img> tags (should use next/image)
74
+ const imgMatches = content.match(/<img\b[^>]*>/gi) || [];
75
+ if (imgMatches.length > 0) {
76
+ // Exclude commented-out images
77
+ const activeImgs = imgMatches.filter(m => !/^\s*\/\//.test(m));
78
+ if (activeImgs.length > 0) {
79
+ fileViolations.push({
80
+ lineNo: 0,
81
+ rule: 'raw-img',
82
+ message: `${activeImgs.length} raw <img> tag(s) — use next/image for optimization`,
83
+ });
84
+ }
85
+ }
86
+
87
+ // Check: large inline data URIs in src attributes
88
+ if (/src\s*=\s*['"]data:image\/[^'"]{1000,}/.test(content)) {
89
+ fileViolations.push({
90
+ lineNo: 0,
91
+ rule: 'data-uri',
92
+ message: 'Large inline data URI in image src — use next/image with a proper source',
93
+ });
94
+ }
95
+
96
+ // Check: dynamic imports without loading="lazy" equivalent
97
+ // In Next.js, dynamic() is fine but verify it's used
98
+ const hasDynamicImport = /dynamic\s*\(/.test(content) || /React\.lazy\s*\(/.test(content);
99
+ const hasHeavyLibImport = /from\s+['"](moment|lodash|date-fns|@mui|antd|@chakra-ui)['"]/.test(content);
100
+ if (hasHeavyLibImport && !hasDynamicImport) {
101
+ fileViolations.push({
102
+ lineNo: 0,
103
+ rule: 'heavy-import',
104
+ message: 'Heavy library imported statically — use next/dynamic for code splitting',
105
+ });
106
+ }
107
+
108
+ // Check: barrel file re-exports that hurt tree-shaking
109
+ if (/export\s*\*\s*from/.test(content) && file.includes('index.')) {
110
+ fileViolations.push({
111
+ lineNo: 0,
112
+ rule: 'barrel-export',
113
+ message: 'Barrel re-export (export *) in index file — may prevent tree-shaking',
114
+ });
115
+ }
116
+
117
+ if (fileViolations.length > 0) {
118
+ violations[file] = fileViolations;
119
+ }
120
+ }
121
+
122
+ const totalCount = Object.values(violations).flat().length;
123
+ return {
124
+ exitCode: totalCount > 0 ? 1 : 0,
125
+ skipped: false,
126
+ name: 'check_performance',
127
+ data: {
128
+ violations,
129
+ message: totalCount > 0
130
+ ? `${totalCount} performance issue(s) found in ${Object.keys(violations).length} file(s)`
131
+ : 'All performance checks passed',
132
+ },
133
+ };
134
+ };
135
+ module.exports = check;
136
+
137
+ if (require.main === module) {
138
+ check(process.argv[2]).then(result => {
139
+ console.log(JSON.stringify(result, null, 2));
140
+ process.exit(result.exitCode);
141
+ });
142
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Responsive & Web Adaptability Guard
4
+ * Scans changed frontend files (.tsx, .jsx, .vue, .astro, .html, .css) for:
5
+ * 1. Hardcoded non-responsive fixed widths (e.g. w-[1200px], min-w-[1024px] without breakpoints)
6
+ * 2. Inline fixed pixel dimensions breaking mobile layouts
7
+ * 3. Mobile touch-target accessibility issues (clickable elements < 44px without padding)
8
+ * 4. Missing or restrictive viewport configurations in root layouts
9
+ *
10
+ * Self-contained: uses only Node.js built-ins.
11
+ *
12
+ * @param {string} targetDir - Directory to scan (defaults to cwd)
13
+ * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
14
+ */
15
+ const check = async function checkResponsive(targetDir) {
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { execSync } = require('child_process');
19
+
20
+ const dir = targetDir || process.cwd();
21
+
22
+ let files;
23
+ try {
24
+ const out = execSync('git diff --name-only HEAD~1', {
25
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
26
+ });
27
+ files = out.split('\n').filter(f => /\.(tsx|jsx|vue|astro|html|css)$/.test(f.trim()));
28
+ } catch {
29
+ try {
30
+ const out = execSync('git diff --name-only --cached', {
31
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
32
+ });
33
+ files = out.split('\n').filter(f => /\.(tsx|jsx|vue|astro|html|css)$/.test(f.trim()));
34
+ } catch {
35
+ return { exitCode: 0, skipped: true, name: 'check_responsive', data: { violations: {}, message: 'No git diff available' } };
36
+ }
37
+ }
38
+
39
+ if (!files || files.length === 0) {
40
+ return { exitCode: 0, skipped: false, name: 'check_responsive', data: { violations: {}, message: 'No frontend files changed' } };
41
+ }
42
+
43
+ const violations = {};
44
+
45
+ for (const file of files) {
46
+ const filePath = path.join(dir, file);
47
+ if (!fs.existsSync(filePath)) continue;
48
+
49
+ let content;
50
+ try {
51
+ content = fs.readFileSync(filePath, 'utf-8');
52
+ } catch {
53
+ continue;
54
+ }
55
+
56
+ const lines = content.split('\n');
57
+ const fileViolations = [];
58
+
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const line = lines[i];
61
+
62
+ // 1. Check for hardcoded arbitrary desktop fixed widths without responsive modifiers
63
+ // e.g. w-[1200px], min-w-[1000px] without sm:, md:, lg:, xl:, 2xl: prefixes
64
+ const hardcodedWidthMatch = /(?<!(?:sm|md|lg|xl|2xl):)(?:min-w|w)-\[(\d{3,4})px\]/g;
65
+ let match;
66
+ while ((match = hardcodedWidthMatch.exec(line)) !== null) {
67
+ const px = parseInt(match[1], 10);
68
+ if (px >= 640 && !line.includes('max-w-') && !line.includes('overflow-x-auto') && !line.includes('overflow-auto')) {
69
+ fileViolations.push({
70
+ lineNo: i + 1,
71
+ rule: 'responsive-fixed-width',
72
+ message: `Hardcoded fixed width "${match[0]}" may cause horizontal overflow on mobile/tablet. Use max-w-* or responsive breakpoints (e.g., md:${match[0]}).`
73
+ });
74
+ }
75
+ }
76
+
77
+ // 2. Check for inline fixed pixel styles
78
+ if (/style\s*=\s*\{\{\s*width:\s*['"](\d{3,4})px['"]/i.test(line)) {
79
+ fileViolations.push({
80
+ lineNo: i + 1,
81
+ rule: 'inline-fixed-width',
82
+ message: 'Hardcoded inline style width breaks mobile responsiveness. Use responsive CSS/Tailwind classes.'
83
+ });
84
+ }
85
+
86
+ // 3. Check for restrictive user-scalable=no or maximum-scale=1 in viewport meta
87
+ if (/name\s*=\s*['"]viewport['"]/i.test(line) && /user-scalable\s*=\s*no|maximum-scale\s*=\s*1(?:\.0)?/i.test(line)) {
88
+ fileViolations.push({
89
+ lineNo: i + 1,
90
+ rule: 'a11y-viewport-scalable',
91
+ message: 'Viewport disables user zooming (user-scalable=no / maximum-scale=1), violating mobile accessibility guidelines.'
92
+ });
93
+ }
94
+
95
+ // 4. Check for tiny touch targets on interactive buttons without padding
96
+ if (/<button\b/i.test(line) && /(?:w-[1-4]\b|h-[1-4]\b)/.test(line) && !/p-[2-6]|p[xy]-[2-6]|min-h-\[44px\]|min-w-\[44px\]/.test(line)) {
97
+ fileViolations.push({
98
+ lineNo: i + 1,
99
+ rule: 'mobile-touch-target',
100
+ message: 'Interactive button has small dimensions without sufficient touch padding (minimum 44x44px recommended for mobile).'
101
+ });
102
+ }
103
+ }
104
+
105
+ if (fileViolations.length > 0) {
106
+ violations[file] = fileViolations;
107
+ }
108
+ }
109
+
110
+ const totalCount = Object.values(violations).flat().length;
111
+ return {
112
+ exitCode: totalCount > 0 ? 1 : 0,
113
+ skipped: false,
114
+ name: 'check_responsive',
115
+ data: {
116
+ violations,
117
+ message: totalCount > 0
118
+ ? `${totalCount} responsive / mobile adaptability issue(s) found in ${Object.keys(violations).length} file(s)`
119
+ : 'All responsive & web adaptability checks passed',
120
+ },
121
+ };
122
+ };
123
+
124
+ module.exports = check;
125
+
126
+ if (require.main === module) {
127
+ check(process.argv[2]).then(result => {
128
+ console.log(JSON.stringify(result, null, 2));
129
+ process.exit(result.exitCode);
130
+ });
131
+ }