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.
- package/AGENTS.md +124 -0
- package/README.es.md +166 -0
- package/README.md +167 -0
- package/bin/cli.js +12 -0
- package/docs/Journal/001-adr-clckernel-governance.md +18 -0
- package/package.json +44 -0
- package/src/adapters/astro.js +67 -0
- package/src/adapters/base.js +109 -0
- package/src/adapters/django.js +70 -0
- package/src/adapters/fastapi.js +69 -0
- package/src/adapters/go.js +50 -0
- package/src/adapters/laravel.js +52 -0
- package/src/adapters/nextjs.js +72 -0
- package/src/adapters/rails.js +54 -0
- package/src/adapters/rust.js +51 -0
- package/src/catalog.js +217 -0
- package/src/config.js +250 -0
- package/src/detector.js +85 -0
- package/src/doctor.js +125 -0
- package/src/generator.js +473 -0
- package/src/index.js +52 -0
- package/src/technologies/detector.js +243 -0
- package/src/technologies/guards/_shared.js +66 -0
- package/src/ui.js +101 -0
- package/src/yaml.js +173 -0
- package/test/adapters/astro.test.js +102 -0
- package/test/adapters/base.test.js +86 -0
- package/test/adapters/django.test.js +95 -0
- package/test/adapters/fastapi.test.js +88 -0
- package/test/adapters/go.test.js +81 -0
- package/test/adapters/laravel.test.js +106 -0
- package/test/adapters/nextjs.test.js +113 -0
- package/test/adapters/rails.test.js +108 -0
- package/test/adapters/rust.test.js +83 -0
- package/test/catalog.test.js +170 -0
- package/test/config.test.js +512 -0
- package/test/detector.test.js +345 -0
- package/test/doctor.test.js +302 -0
- package/test/generator.test.js +419 -0
- package/test/guards_new.test.js +95 -0
- package/test/helpers.js +36 -0
- package/test/integration/cli-flow.test.js +270 -0
- package/test/technologies/celery_guard.test.js +185 -0
- package/test/technologies/detector.test.js +342 -0
- package/test/technologies/docker_guard.test.js +187 -0
- package/test/technologies/integration.test.js +123 -0
- package/test/technologies/postgres_guard.test.js +87 -0
- package/test/technologies/redis_guard.test.js +128 -0
- package/tools/audit.js +219 -0
- package/tools/audit.py +273 -0
- package/tools/celery_guard.py +209 -0
- package/tools/check_a11y.js +109 -0
- package/tools/check_api_contracts.js +139 -0
- package/tools/check_architecture.js +139 -0
- package/tools/check_architecture.py +264 -0
- package/tools/check_custom.js +163 -0
- package/tools/check_custom.py +395 -0
- package/tools/check_db_efficiency.py +190 -0
- package/tools/check_migrations.py +223 -0
- package/tools/check_performance.js +142 -0
- package/tools/check_responsive.js +131 -0
- package/tools/check_scope.py +180 -0
- package/tools/check_seo.js +139 -0
- package/tools/check_storybook.js +108 -0
- package/tools/check_ui_reuse.js +135 -0
- package/tools/docker_guard.py +210 -0
- package/tools/postgres_guard.py +192 -0
- package/tools/redis_guard.py +197 -0
- package/tools/scan_secrets.js +109 -0
- package/tools/scan_secrets.py +153 -0
- 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
|
+
}
|