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
package/tools/audit.py ADDED
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Modular Audit Orchestrator — Python
4
+ Discovers check_*.py and scan_*.py guard scripts at runtime, runs each with
5
+ graceful degradation, and generates an English audit report (05-audit-report.md).
6
+ Self-contained: uses only Python stdlib.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import json
12
+ import os
13
+ import pathlib
14
+ import subprocess
15
+ import sys
16
+ import traceback
17
+
18
+ REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
19
+
20
+
21
+ # ── Helpers ──────────────────────────────────────────────────────────────────
22
+
23
+ def find_active_sdd(change_name: str | None = None) -> pathlib.Path | None:
24
+ """Find the active SDD directory by name or pick the sole candidate."""
25
+ sdds_dir = REPO_ROOT / "sdds"
26
+ if not sdds_dir.exists():
27
+ return None
28
+ if change_name:
29
+ target = sdds_dir / change_name
30
+ return target if target.is_dir() else None
31
+ candidates = [p for p in sdds_dir.iterdir() if p.is_dir()]
32
+ if len(candidates) == 1:
33
+ return candidates[0]
34
+ return None
35
+
36
+
37
+ def discover_guards(tools_dir: pathlib.Path) -> list[pathlib.Path]:
38
+ """
39
+ Discover all check_*.py and scan_*.py guard scripts in tools/.
40
+ Excludes audit.py itself. Returns sorted list for deterministic ordering.
41
+ """
42
+ guards: list[pathlib.Path] = []
43
+ for f in sorted(tools_dir.iterdir()):
44
+ if not f.is_file():
45
+ continue
46
+ name = f.name
47
+ if (name.startswith("check_") or name.startswith("scan_") or name.startswith("verify_")) and name.endswith(".py"):
48
+ guards.append(f)
49
+ return guards
50
+
51
+
52
+ def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
53
+ """
54
+ Run a single guard module by dynamic import and call its check() function.
55
+ Returns a normalized result dict on success, or a skip result on failure.
56
+ """
57
+ guard_name = guard_path.stem # e.g. "check_scope"
58
+
59
+ try:
60
+ spec = importlib.util.spec_from_file_location(guard_name, str(guard_path))
61
+ if spec is None or spec.loader is None:
62
+ return {
63
+ "exit_code": 0, "skipped": True, "name": guard_name,
64
+ "data": {"message": f"Cannot load module spec for {guard_name}"},
65
+ }
66
+
67
+ mod = importlib.util.module_from_spec(spec)
68
+ spec.loader.exec_module(mod)
69
+
70
+ fn = getattr(mod, "check", None)
71
+ if fn is None or not callable(fn):
72
+ return {
73
+ "exit_code": 0, "skipped": True, "name": guard_name,
74
+ "data": {"message": f"Guard {guard_name} does not export a check() function"},
75
+ }
76
+
77
+ result = fn(target_dir)
78
+
79
+ # Normalize result shape
80
+ if not isinstance(result, dict):
81
+ return {
82
+ "exit_code": 0, "skipped": True, "name": guard_name,
83
+ "data": {"message": f"Guard {guard_name} returned non-dict: {type(result).__name__}"},
84
+ }
85
+
86
+ return {
87
+ "exit_code": result.get("exit_code", 0),
88
+ "skipped": bool(result.get("skipped", False)),
89
+ "name": result.get("name", guard_name),
90
+ "data": result.get("data", result),
91
+ }
92
+
93
+ except Exception as exc:
94
+ return {
95
+ "exit_code": 0, "skipped": True, "name": guard_name,
96
+ "data": {"message": f"Guard unavailable: {exc}"},
97
+ }
98
+
99
+
100
+ # ── Report Generation ────────────────────────────────────────────────────────
101
+
102
+ def _format_guard_section(name: str, result: dict) -> str:
103
+ """Format a single guard result into a markdown section."""
104
+ lines: list[str] = []
105
+ skipped = result.get("skipped", False)
106
+ exit_code = result.get("exit_code", 0)
107
+ data = result.get("data", {})
108
+
109
+ if skipped:
110
+ icon, status = "\u23ed\ufe0f", "SKIPPED"
111
+ elif exit_code == 0:
112
+ icon, status = "\u2705", "PASSED"
113
+ else:
114
+ icon, status = "\u274c", "FAILED"
115
+
116
+ title = name.replace("_", " ").title()
117
+ lines.append(f"### {icon} {title} \u2014 {status}")
118
+ lines.append("")
119
+
120
+ if skipped:
121
+ lines.append(f"> Skipped: {data.get('message', 'Dependencies not available')}")
122
+ elif exit_code == 0:
123
+ lines.append(f"> {data.get('message', 'All checks passed')}")
124
+ else:
125
+ lines.append(f"> {data.get('message', 'Issues found')}")
126
+ lines.append("")
127
+
128
+ # Format violations or findings
129
+ violations = data.get("violations") or data.get("findings") or {}
130
+ if isinstance(violations, dict):
131
+ for filepath, items in violations.items():
132
+ if not isinstance(items, list):
133
+ continue
134
+ for item in items:
135
+ loc = f" (line {item.get('lineNo', '')})" if item.get("lineNo") else ""
136
+ label = item.get("label") or item.get("rule") or item.get("module") or ""
137
+ msg = item.get("reason") or item.get("message") or ""
138
+ lines.append(f"- `{filepath}`{loc}: {f'[{label}] ' if label else ''}{msg}")
139
+
140
+ # Handle list-style data (e.g. scope unauthorized files)
141
+ for key in ("unrelated", "warnings", "authorized"):
142
+ items = data.get(key, [])
143
+ if isinstance(items, list) and items:
144
+ icon_map = {"unrelated": "\u274c", "warnings": "\u26a0\ufe0f", "authorized": "\u2705"}
145
+ label_map = {"unrelated": "Not in SDD scope", "warnings": "Related warnings", "authorized": "Authorized"}
146
+ lines.append(f"\n{icon_map.get(key, '')} **{label_map.get(key, key)}:**")
147
+ for f in items:
148
+ lines.append(f" - `{f}`")
149
+
150
+ return "\n".join(lines)
151
+
152
+
153
+ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
154
+ """Generate the 05-audit-report.md from aggregated guard results."""
155
+ task_name = sdd_dir.name
156
+ passed = sum(1 for r in results if not r.get("skipped") and r.get("exit_code") == 0)
157
+ failed = sum(1 for r in results if not r.get("skipped") and r.get("exit_code") != 0)
158
+ skipped = sum(1 for r in results if r.get("skipped"))
159
+ total = len(results)
160
+
161
+ sections: list[str] = []
162
+ sections.append(f"# Code Audit Report: {task_name}")
163
+ sections.append("")
164
+ sections.append("> **Note for reviewer:** This report summarizes security, architecture,")
165
+ sections.append("> scope, migration safety, and TDD coverage to facilitate code review.")
166
+ sections.append("")
167
+ sections.append("---")
168
+ sections.append("")
169
+ sections.append("## Summary")
170
+ sections.append("")
171
+ sections.append("| Metric | Count |")
172
+ sections.append("|--------|-------|")
173
+ sections.append(f"| Total guards | {total} |")
174
+ sections.append(f"| Passed | {passed} |")
175
+ sections.append(f"| Failed | {failed} |")
176
+ sections.append(f"| Skipped | {skipped} |")
177
+ sections.append("")
178
+ sections.append("---")
179
+ sections.append("")
180
+
181
+ for result in results:
182
+ sections.append(_format_guard_section(result.get("name", "unknown"), result))
183
+ sections.append("")
184
+ sections.append("---")
185
+ sections.append("")
186
+
187
+ return "\n".join(sections)
188
+
189
+
190
+ # ── Console Summary ──────────────────────────────────────────────────────────
191
+
192
+ def print_summary(results: list[dict], report_path: pathlib.Path) -> None:
193
+ """Print an educational summary to the terminal."""
194
+ print("\n" + "=" * 65)
195
+ print(" CLC FORGE \u2014 EDUCATIONAL AUDIT SUMMARY")
196
+ print("=" * 65 + "\n")
197
+
198
+ for result in results:
199
+ skipped = result.get("skipped", False)
200
+ exit_code = result.get("exit_code", 0)
201
+ name = result.get("name", "unknown").replace("_", " ")
202
+ msg = result.get("data", {}).get("message", "")
203
+
204
+ if skipped:
205
+ icon = "\u23ed\ufe0f"
206
+ elif exit_code == 0:
207
+ icon = "\u2705"
208
+ else:
209
+ icon = "\u274c"
210
+
211
+ print(f" {icon} {name}: {msg}")
212
+
213
+ print(f"\n Report: {report_path}")
214
+ print("=" * 65 + "\n")
215
+
216
+
217
+ # ── Main ─────────────────────────────────────────────────────────────────────
218
+
219
+ def main(target_dir: str | None = None) -> int:
220
+ """Run all discovered guards and produce the audit report."""
221
+ tools_dir = pathlib.Path(__file__).resolve().parent
222
+ dir_path = target_dir or os.getcwd()
223
+
224
+ sdd_dir = find_active_sdd()
225
+ if sdd_dir is None:
226
+ # Create a default SDD dir for the report
227
+ sdds_dir = REPO_ROOT / "sdds"
228
+ sdds_dir.mkdir(parents=True, exist_ok=True)
229
+ sdd_dir = sdds_dir / "audit-run"
230
+ sdd_dir.mkdir(parents=True, exist_ok=True)
231
+
232
+ print(f"\nRunning CLC Forge audit for: {sdd_dir.name}\n")
233
+
234
+ guard_paths = discover_guards(tools_dir)
235
+
236
+ if not guard_paths:
237
+ print("No guard tools found in tools/. Nothing to audit.")
238
+ return 0
239
+
240
+ print(
241
+ f"Discovered {len(guard_paths)} guard(s): "
242
+ + ", ".join(p.stem for p in guard_paths)
243
+ + "\n"
244
+ )
245
+
246
+ results: list[dict] = []
247
+ for guard_path in guard_paths:
248
+ result = run_guard(guard_path, dir_path)
249
+ results.append(result)
250
+
251
+ # Generate and write report
252
+ report_content = generate_report(results, sdd_dir)
253
+ report_path = sdd_dir / "05-audit-report.md"
254
+ report_path.write_text(report_content, encoding="utf-8")
255
+
256
+ # Print summary
257
+ print_summary(results, report_path)
258
+
259
+ # Exit with failure if any guard failed (not skipped)
260
+ has_failure = any(
261
+ not r.get("skipped") and r.get("exit_code", 0) != 0
262
+ for r in results
263
+ )
264
+ return 1 if has_failure else 0
265
+
266
+
267
+ if __name__ == "__main__":
268
+ try:
269
+ target = sys.argv[1] if len(sys.argv) > 1 else None
270
+ sys.exit(main(target))
271
+ except Exception:
272
+ traceback.print_exc()
273
+ sys.exit(0) # Graceful degradation
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Celery Guard
4
+ Checks for Celery task security issues in changed Python files.
5
+ Self-contained: uses only Python stdlib (ast, pathlib, subprocess, json, sys).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def _get_changed_files(target_dir: str) -> list[str] | None:
18
+ """Return list of changed Python files from git diff, or None on failure."""
19
+ for ref in ("HEAD~1", "--cached"):
20
+ try:
21
+ args = ["git", "diff", "--name-only"]
22
+ if ref == "--cached":
23
+ args.append("--cached")
24
+ else:
25
+ args.append(ref)
26
+ out = subprocess.check_output(
27
+ args, cwd=target_dir,
28
+ stderr=subprocess.DEVNULL, text=True,
29
+ )
30
+ files = [
31
+ f.strip() for f in out.splitlines()
32
+ if f.strip().endswith('.py')
33
+ ]
34
+ if files:
35
+ return files
36
+ except (subprocess.CalledProcessError, FileNotFoundError):
37
+ continue
38
+ return None
39
+
40
+
41
+ class CeleryVisitor(ast.NodeVisitor):
42
+ """AST visitor that detects Celery task security issues."""
43
+
44
+ def __init__(self, filepath: str):
45
+ self.filepath = filepath
46
+ self.violations: list[dict] = []
47
+
48
+ def _add_violation(self, lineno: int, issue: str, snippet: str):
49
+ self.violations.append({
50
+ "file": self.filepath,
51
+ "lineNo": lineno,
52
+ "issue": issue,
53
+ "snippet": snippet[:120],
54
+ })
55
+
56
+ def _is_redis_url(self, node: ast.expr) -> bool:
57
+ """Check if expression looks like a redis:// URL."""
58
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
59
+ val = node.value.lower()
60
+ return 'redis://' in val or 'rediss://' in val
61
+ return False
62
+
63
+ def _is_broker_url(self, node: ast.expr) -> bool:
64
+ """Check if expression looks like a broker URL."""
65
+ return self._is_redis_url(node)
66
+
67
+ def visit_Call(self, node: ast.Call):
68
+ # Check for Celery() instantiation with broker_url or result_backend
69
+ if isinstance(node.func, ast.Name) and node.func.id == 'Celery':
70
+ for keyword in node.keywords:
71
+ if keyword.arg == 'broker_url':
72
+ if self._is_broker_url(keyword.value):
73
+ self._add_violation(
74
+ node.lineno or 0,
75
+ "hardcoded_broker_url",
76
+ f"Celery broker_url directly in code"
77
+ )
78
+ if keyword.arg == 'result_backend':
79
+ if not isinstance(keyword.value, ast.Constant) or keyword.value.value is None:
80
+ pass # Skip None defaults
81
+ elif isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str):
82
+ val = keyword.value.value.lower()
83
+ if val and val not in ('None', 'null', ''):
84
+ self._add_violation(
85
+ node.lineno or 0,
86
+ "hardcoded_result_backend",
87
+ f"Celery result_backend directly in code"
88
+ )
89
+
90
+ # Check for @shared_task or @celery.task decorators
91
+ # Handle @shared_task(...) or @shared_task
92
+ try:
93
+ for decorator in node.decorator_list:
94
+ dec_name = ''
95
+ dec_args: list[ast.expr] = []
96
+ if isinstance(decorator, ast.Call):
97
+ if isinstance(decorator.func, ast.Name):
98
+ dec_name = decorator.func.id
99
+ elif isinstance(decorator.func, ast.Attribute):
100
+ dec_name = decorator.func.attr
101
+ dec_args = decorator.args
102
+ elif isinstance(decorator, ast.Name):
103
+ dec_name = decorator.id
104
+ elif isinstance(decorator, ast.Attribute):
105
+ dec_name = decorator.attr
106
+
107
+ if dec_name in ('shared_task', 'task'):
108
+ # Check if ignore_result=True is missing
109
+ ignore_found = False
110
+ for arg in dec_args:
111
+ if isinstance(arg, ast.keyword) and arg.arg == 'ignore_result':
112
+ if isinstance(arg.value, ast.Constant) and arg.value.value is True:
113
+ ignore_found = True
114
+ if not ignore_found:
115
+ # Check decorators with keyword args
116
+ for decorator_node in node.decorator_list:
117
+ if isinstance(decorator_node, ast.Call):
118
+ for kw in decorator_node.keywords:
119
+ if kw.arg == 'ignore_result':
120
+ if isinstance(kw.value, ast.Constant) and kw.value.value is True:
121
+ ignore_found = True
122
+ if not ignore_found:
123
+ snippet = f"@{dec_name} without ignore_result=True"
124
+ self._add_violation(node.lineno or 0, "missing_ignore_result", snippet)
125
+ except Exception:
126
+ pass
127
+
128
+ self.generic_visit(node)
129
+
130
+ def visit_FunctionDef(self, node: ast.FunctionDef):
131
+ # Check function body for broker URL strings
132
+ for stmt in ast.walk(node):
133
+ if isinstance(stmt, ast.Assign):
134
+ for target in stmt.targets:
135
+ if isinstance(target, ast.Name):
136
+ var_name = target.id.lower()
137
+ if 'broker' in var_name or 'url' in var_name:
138
+ if isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str):
139
+ val = stmt.value.value.lower()
140
+ if 'redis://' in val or 'rediss://' in val:
141
+ self._add_violation(
142
+ stmt.lineno or 0,
143
+ "broker_url_in_variable",
144
+ f"Broker URL assigned to variable {target.id}"
145
+ )
146
+ self.generic_visit(node)
147
+
148
+
149
+ def check(target_dir: str | None = None) -> dict:
150
+ """
151
+ Scan changed Python files for Celery security issues.
152
+
153
+ Returns:
154
+ dict with keys: exit_code, skipped, name, data
155
+ """
156
+ dir_path = target_dir or os.getcwd()
157
+
158
+ files = _get_changed_files(dir_path)
159
+ if files is None:
160
+ return {
161
+ "exit_code": 0, "skipped": True, "name": "celery_guard",
162
+ "data": {"findings": [], "message": "No git diff available"},
163
+ }
164
+
165
+ if not files:
166
+ return {
167
+ "exit_code": 0, "skipped": False, "name": "celery_guard",
168
+ "data": {"findings": [], "message": "No changed Python files to scan"},
169
+ }
170
+
171
+ all_violations: list[dict] = []
172
+
173
+ for filepath in files:
174
+ full_path = Path(dir_path) / filepath
175
+ if not full_path.exists():
176
+ continue
177
+
178
+ try:
179
+ content = full_path.read_text(encoding="utf-8", errors="replace")
180
+ except (OSError, PermissionError):
181
+ continue
182
+
183
+ try:
184
+ tree = ast.parse(content, filename=str(full_path))
185
+ except SyntaxError:
186
+ continue
187
+
188
+ visitor = CeleryVisitor(filepath)
189
+ visitor.visit(tree)
190
+ all_violations.extend(visitor.violations)
191
+
192
+ total = len(all_violations)
193
+ if total:
194
+ msg = f"{total} Celery violation(s) found"
195
+ else:
196
+ msg = "No Celery violations found"
197
+
198
+ return {
199
+ "exit_code": 1 if total else 0,
200
+ "skipped": False,
201
+ "name": "celery_guard",
202
+ "data": {"findings": all_violations, "message": msg},
203
+ }
204
+
205
+
206
+ if __name__ == "__main__":
207
+ result = check(sys.argv[1] if len(sys.argv) > 1 else None)
208
+ print(json.dumps(result, indent=2))
209
+ sys.exit(result["exit_code"])
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Accessibility & ARIA Guard
4
+ * Scans changed .tsx/.jsx files for missing alt tags, aria-labels,
5
+ * and semantic HTML violations.
6
+ * Self-contained: uses only Node.js built-ins.
7
+ *
8
+ * @param {string} targetDir - Directory to scan (defaults to cwd)
9
+ * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
10
+ */
11
+ const check = async function checkA11y(targetDir) {
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execSync } = require('child_process');
15
+
16
+ const dir = targetDir || process.cwd();
17
+
18
+ let files;
19
+ try {
20
+ const out = execSync('git diff --name-only HEAD~1', {
21
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
22
+ });
23
+ files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(f.trim()));
24
+ } catch {
25
+ try {
26
+ const out = execSync('git diff --name-only --cached', {
27
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
28
+ });
29
+ files = out.split('\n').filter(f => /\.(tsx|jsx)$/.test(f.trim()));
30
+ } catch {
31
+ return { exitCode: 0, skipped: true, name: 'check_a11y', data: { violations: {}, message: 'No git diff available' } };
32
+ }
33
+ }
34
+
35
+ if (!files || files.length === 0) {
36
+ return { exitCode: 0, skipped: false, name: 'check_a11y', data: { violations: {}, message: 'No frontend files changed' } };
37
+ }
38
+
39
+ const violations = {};
40
+
41
+ for (const file of files) {
42
+ const filePath = path.join(dir, file);
43
+ if (!fs.existsSync(filePath)) continue;
44
+
45
+ let content;
46
+ try {
47
+ content = fs.readFileSync(filePath, 'utf-8');
48
+ } catch {
49
+ continue;
50
+ }
51
+
52
+ const lines = content.split('\n');
53
+ const fileViolations = [];
54
+
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const line = lines[i];
57
+
58
+ // Check for <img without alt
59
+ if (/<img\b[^>]*(?!.*\balt\s*=)[^>]*>/i.test(line) && !/\balt\s*=/.test(line)) {
60
+ fileViolations.push({ lineNo: i + 1, rule: 'img-alt', message: 'Image missing alt attribute' });
61
+ }
62
+
63
+ // Check for <input without aria-label or aria-labelledby
64
+ if (/<input\b/i.test(line) && !/aria-label|aria-labelledby|aria-describedby/.test(line)) {
65
+ fileViolations.push({ lineNo: i + 1, rule: 'input-label', message: 'Input missing aria-label or aria-labelledby' });
66
+ }
67
+
68
+ // Check for button/link with only icon and no aria-label
69
+ if (/<button[^>]*>\s*(?:<[A-Z]|<span[^>]*className=['"][^'"]*(?:icon|Icon))/i.test(line) && !/aria-label/.test(line)) {
70
+ fileViolations.push({ lineNo: i + 1, rule: 'icon-button', message: 'Icon-only button missing aria-label' });
71
+ }
72
+
73
+ // Check for role="button" without keyboard handler
74
+ if (/role\s*=\s*['"]button['"]/i.test(line) && !/onKeyDown|onKeyUp|onKeyPress/i.test(line)) {
75
+ fileViolations.push({ lineNo: i + 1, rule: 'role-button-keyboard', message: 'role="button" without keyboard handler' });
76
+ }
77
+
78
+ // Check for <a> with href but missing accessible text for icon-only links
79
+ if (/<a\b[^>]*>\s*<[A-Z]/i.test(line) && !/aria-label/.test(line)) {
80
+ fileViolations.push({ lineNo: i + 1, rule: 'link-accessible', message: 'Link with component child may need aria-label' });
81
+ }
82
+ }
83
+
84
+ if (fileViolations.length > 0) {
85
+ violations[file] = fileViolations;
86
+ }
87
+ }
88
+
89
+ const totalCount = Object.values(violations).flat().length;
90
+ return {
91
+ exitCode: totalCount > 0 ? 1 : 0,
92
+ skipped: false,
93
+ name: 'check_a11y',
94
+ data: {
95
+ violations,
96
+ message: totalCount > 0
97
+ ? `${totalCount} a11y issue(s) found in ${Object.keys(violations).length} file(s)`
98
+ : 'All a11y checks passed',
99
+ },
100
+ };
101
+ };
102
+ module.exports = check;
103
+
104
+ if (require.main === module) {
105
+ check(process.argv[2]).then(result => {
106
+ console.log(JSON.stringify(result, null, 2));
107
+ process.exit(result.exitCode);
108
+ });
109
+ }