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,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Custom Rules Guard
4
+ * Reads .clc-forge.yml and executes user-defined regex rules against changed files.
5
+ * Self-contained: uses only Node.js built-ins (inline YAML parser, no npm deps).
6
+ *
7
+ * @param {string} targetDir - Directory to scan (defaults to cwd)
8
+ * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
9
+ */
10
+ const { parseYaml, globToRegex } = require('../src/yaml.js');
11
+ const check = async function checkCustom(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
+ // ── 1. Load config ──────────────────────────────────────────────────
19
+ const candidates = ['.clckernel.yml', '.clckernel.yaml', '.clc-forge.yml', '.clc-forge.yaml'];
20
+ const configPath = candidates.map(c => path.join(dir, c)).find(p => fs.existsSync(p));
21
+ if (!configPath) {
22
+ return {
23
+ exitCode: 0, skipped: true, name: 'check_custom',
24
+ data: { violations: {}, message: 'No .clckernel.yml or .clc-forge.yml found' },
25
+ };
26
+ }
27
+
28
+ let config;
29
+ try {
30
+ const raw = fs.readFileSync(configPath, 'utf-8');
31
+ config = parseYaml(raw);
32
+ } catch (e) {
33
+ return {
34
+ exitCode: 0, skipped: true, name: 'check_custom',
35
+ data: { violations: {}, message: `Config parse error: ${e.message}` },
36
+ };
37
+ }
38
+
39
+ const rules = config && config.rules;
40
+ if (rules !== undefined && !Array.isArray(rules)) {
41
+ return {
42
+ exitCode: 0, skipped: true, name: 'check_custom',
43
+ data: { violations: {}, message: 'Config error: "rules" must be a list' },
44
+ };
45
+ }
46
+ if (!Array.isArray(rules) || rules.length === 0) {
47
+ return {
48
+ exitCode: 0, skipped: false, name: 'check_custom',
49
+ data: { violations: {}, message: 'No custom rules defined' },
50
+ };
51
+ }
52
+
53
+ // ── 2. Validate rules: compile regexes, check required fields ───────
54
+ const validated = [];
55
+ for (const rule of rules) {
56
+ if (!rule.name || !rule.pattern || !rule.message) continue;
57
+ try {
58
+ validated.push({
59
+ name: rule.name,
60
+ pattern: rule.pattern,
61
+ message: rule.message,
62
+ severity: rule.severity || 'error',
63
+ exclude: rule.exclude || [],
64
+ regex: new RegExp(rule.pattern, 'gm'),
65
+ scopeRe: globToRegex(rule.scope || '**/*'),
66
+ excludeRe: (rule.exclude || []).map(globToRegex),
67
+ });
68
+ } catch {
69
+ // Invalid regex — skip this rule silently
70
+ }
71
+ }
72
+
73
+ if (validated.length === 0) {
74
+ return {
75
+ exitCode: 0, skipped: false, name: 'check_custom',
76
+ data: { violations: {}, message: 'No valid custom rules after validation' },
77
+ };
78
+ }
79
+
80
+ // ── 3. Get changed files via git diff ───────────────────────────────
81
+ let files;
82
+ try {
83
+ const out = execSync('git diff --name-only HEAD~1', {
84
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
85
+ });
86
+ files = out.split('\n').filter(f => f.trim());
87
+ } catch {
88
+ try {
89
+ const out = execSync('git diff --name-only --cached', {
90
+ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
91
+ });
92
+ files = out.split('\n').filter(f => f.trim());
93
+ } catch {
94
+ return {
95
+ exitCode: 0, skipped: true, name: 'check_custom',
96
+ data: { violations: {}, message: 'No git diff available' },
97
+ };
98
+ }
99
+ }
100
+
101
+ if (!files || files.length === 0) {
102
+ return {
103
+ exitCode: 0, skipped: false, name: 'check_custom',
104
+ data: { violations: {}, message: 'No files to check' },
105
+ };
106
+ }
107
+
108
+ // ── 4. Run rules against files ──────────────────────────────────────
109
+ const violations = {};
110
+ let hasError = false;
111
+
112
+ for (const file of files) {
113
+ const filePath = path.join(dir, file);
114
+ let content;
115
+ try {
116
+ content = fs.readFileSync(filePath, 'utf-8');
117
+ } catch {
118
+ continue; // permission denied or unreadable — skip
119
+ }
120
+
121
+ for (const rule of validated) {
122
+ if (!rule.scopeRe.test(file)) continue;
123
+ if (rule.excludeRe.some(re => re.test(file))) continue;
124
+
125
+ rule.regex.lastIndex = 0;
126
+ let match;
127
+ while ((match = rule.regex.exec(content)) !== null) {
128
+ const lineNo = content.slice(0, match.index).split('\n').length;
129
+ if (!violations[file]) violations[file] = [];
130
+ violations[file].push({
131
+ lineNo,
132
+ rule: rule.name,
133
+ message: rule.message,
134
+ severity: rule.severity,
135
+ });
136
+ if (rule.severity === 'error') hasError = true;
137
+ }
138
+ }
139
+ }
140
+
141
+ // ── 5. Build result ─────────────────────────────────────────────────
142
+ const totalCount = Object.values(violations).flat().length;
143
+ return {
144
+ exitCode: hasError ? 1 : 0,
145
+ skipped: false,
146
+ name: 'check_custom',
147
+ data: {
148
+ violations,
149
+ message: totalCount > 0
150
+ ? `${totalCount} custom rule violation(s) in ${Object.keys(violations).length} file(s)`
151
+ : 'No custom rule violations',
152
+ },
153
+ };
154
+ };
155
+ module.exports = check;
156
+
157
+ if (require.main === module) {
158
+ check(process.argv[2]).then(result => {
159
+ console.log(JSON.stringify(result, null, 2));
160
+ process.exit(result.exitCode);
161
+ });
162
+ }
163
+
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Custom Rules Guard — Python
4
+ Reads .clc-forge.yml and executes user-defined regex rules against changed files.
5
+ Self-contained: uses only Python stdlib + optional pyyaml.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import pathlib
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from typing import Any, Optional
15
+
16
+ # ═══════════════════════════════════════════════════════════════════════
17
+ # YAML Parser — try pyyaml first, fallback to inline minimal parser
18
+ # ═══════════════════════════════════════════════════════════════════════
19
+
20
+ def _parse_yaml(text: str) -> dict:
21
+ """Parse YAML text. Uses PyYAML if available, otherwise inline minimal parser."""
22
+ try:
23
+ import yaml
24
+ result = yaml.safe_load(text)
25
+ return result if isinstance(result, dict) else {}
26
+ except ImportError:
27
+ pass
28
+ except Exception:
29
+ raise
30
+
31
+ return _minimal_yaml_parse(text)
32
+
33
+
34
+ def _minimal_yaml_parse(text: str) -> dict:
35
+ """
36
+ Minimal YAML parser (~80 lines).
37
+ Supports: scalars, block sequences, block mappings, inline flow sequences,
38
+ comments. No anchors/aliases, no multiple documents.
39
+ """
40
+ lines = text.split('\n')
41
+ root: dict = {}
42
+ # Stack entries: (obj, indent, parent_obj, parent_key)
43
+ stack: list[tuple[dict, int, Optional[dict], Optional[str]]] = [
44
+ (root, -1, None, None)
45
+ ]
46
+
47
+ for line_text in lines:
48
+ # Strip comments (but not inside quoted strings)
49
+ stripped = line_text.rstrip()
50
+ comment_idx = _find_comment(stripped)
51
+ if comment_idx != -1:
52
+ stripped = stripped[:comment_idx]
53
+ if not stripped or stripped.isspace():
54
+ continue
55
+
56
+ indent = len(stripped) - len(stripped.lstrip())
57
+ trimmed = stripped.lstrip()
58
+
59
+ # Pop stack back to correct parent
60
+ while len(stack) > 1 and stack[-1][1] >= indent:
61
+ stack.pop()
62
+ entry = stack[-1]
63
+ obj = entry[0]
64
+ parent_obj = entry[2]
65
+ parent_key = entry[3]
66
+
67
+ # ── Sequence item: "- rest" ──
68
+ if trimmed.startswith('- '):
69
+ rest = trimmed[2:].strip()
70
+
71
+ if ': ' in rest:
72
+ # "- key: value" — mapping inside a sequence
73
+ arr = _get_or_create_array(parent_obj, parent_key, obj)
74
+ if arr is not None:
75
+ item = {}
76
+ colon_idx = rest.index(': ')
77
+ k = rest[:colon_idx].strip()
78
+ v = rest[colon_idx + 2:].strip()
79
+ item[k] = _parse_scalar(v)
80
+ arr.append(item)
81
+ stack.append((item, indent, arr, None))
82
+ elif parent_obj is not None and parent_key is not None:
83
+ # "- scalar" inside a sequence
84
+ pk = parent_obj.get(parent_key)
85
+ if isinstance(pk, list):
86
+ pk.append(_parse_scalar(rest))
87
+ continue
88
+
89
+ # ── key: value pair ──
90
+ kv_match = re.match(r'^([^:]+):\s*(.*)$', trimmed)
91
+ if kv_match:
92
+ key = kv_match.group(1).strip()
93
+ val = kv_match.group(2).strip()
94
+
95
+ if not val or val == '|' or val == '>':
96
+ # Empty value → children follow
97
+ if key not in obj or not isinstance(obj[key], (dict, list)):
98
+ obj[key] = {}
99
+ stack.append((obj[key] if isinstance(obj[key], dict) else {}, indent, obj, key))
100
+ elif val.startswith('[') and val.endswith(']'):
101
+ inner = val[1:-1].strip()
102
+ if inner:
103
+ obj[key] = [_parse_scalar(s.strip()) for s in inner.split(',')]
104
+ else:
105
+ obj[key] = []
106
+ elif val.startswith('{') and val.endswith('}'):
107
+ obj[key] = _parse_flow_mapping(val)
108
+ else:
109
+ obj[key] = _parse_scalar(val)
110
+ continue
111
+
112
+ return root
113
+
114
+
115
+ def _find_comment(line: str) -> int:
116
+ """Find index of comment character not inside quotes."""
117
+ in_single = False
118
+ in_double = False
119
+ for i, ch in enumerate(line):
120
+ if ch == "'" and not in_double:
121
+ in_single = not in_single
122
+ elif ch == '"' and not in_single:
123
+ in_double = not in_double
124
+ elif ch == '#' and not in_single and not in_double:
125
+ return i
126
+ return -1
127
+
128
+
129
+ def _get_or_create_array(parent_obj, parent_key, current_obj):
130
+ """Get or create an array for sequence items."""
131
+ if parent_obj is None or parent_key is None:
132
+ return None
133
+ pk = parent_obj.get(parent_key)
134
+ if isinstance(pk, list):
135
+ return pk
136
+ if isinstance(pk, dict) and len(pk) == 0:
137
+ parent_obj[parent_key] = []
138
+ return parent_obj[parent_key]
139
+ return None
140
+
141
+
142
+ def _parse_scalar(val: str) -> Any:
143
+ """Parse a YAML scalar value."""
144
+ if val == '' or val == '~' or val == 'null':
145
+ return None
146
+ if val == 'true':
147
+ return True
148
+ if val == 'false':
149
+ return False
150
+ if re.match(r'^-?\d+$', val):
151
+ return int(val)
152
+ if re.match(r'^-?\d+\.\d+$', val):
153
+ return float(val)
154
+ # Strip quotes and unescape
155
+ if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
156
+ return val[1:-1].replace('\\', '')
157
+ return val
158
+
159
+
160
+ def _parse_flow_mapping(s: str) -> dict:
161
+ """Parse a YAML inline flow mapping {key: value, ...}."""
162
+ inner = s[1:-1].strip()
163
+ if not inner:
164
+ return {}
165
+ obj = {}
166
+ for pair in inner.split(','):
167
+ colon_idx = pair.find(':')
168
+ if colon_idx == -1:
169
+ continue
170
+ k = pair[:colon_idx].strip()
171
+ v = pair[colon_idx + 1:].strip()
172
+ obj[k] = _parse_scalar(v)
173
+ return obj
174
+
175
+
176
+ # ═══════════════════════════════════════════════════════════════════════
177
+ # Glob-to-Regex Converter (~15 lines)
178
+ # Handles: **, *, {a,b} alternation
179
+ # ═══════════════════════════════════════════════════════════════════════
180
+
181
+ import fnmatch
182
+
183
+
184
+ def _glob_to_regex(glob_pattern: str) -> re.Pattern:
185
+ """Convert a glob pattern to a compiled regex. Handles **, *, {a,b}."""
186
+ # fnmatch.translate converts globs but adds \Z$ at end, we need to adjust
187
+ # for ** patterns that need to match across path separators
188
+ parts = glob_pattern.split('/')
189
+ regex_parts = []
190
+ i = 0
191
+ while i < len(parts):
192
+ part = parts[i]
193
+ if part == '**':
194
+ # ** matches zero or more path segments
195
+ regex_parts.append('(?:.+/)?')
196
+ # Skip trailing empty segment if glob ends with **
197
+ i += 1
198
+ continue
199
+ else:
200
+ # Convert single segment with fnmatch
201
+ translated = fnmatch.translate(part)
202
+ # fnmatch.translate adds (?s:...) and \Z at end; strip wrapper
203
+ # Remove leading (?s: and trailing )\Z
204
+ inner = translated
205
+ if inner.startswith('(?s:'):
206
+ inner = inner[4:]
207
+ if inner.endswith(')\\Z'):
208
+ inner = inner[:-3]
209
+ elif inner.endswith('\\Z'):
210
+ inner = inner[:-2]
211
+ regex_parts.append(inner)
212
+ i += 1
213
+
214
+ full = '/'.join(regex_parts)
215
+ return re.compile('^' + full + '$')
216
+
217
+
218
+ # ═══════════════════════════════════════════════════════════════════════
219
+ # Main Guard Contract
220
+ # ═══════════════════════════════════════════════════════════════════════
221
+
222
+ def check(target_dir: Optional[str] = None) -> dict:
223
+ """
224
+ Custom rules guard entry point.
225
+ Reads .clc-forge.yml and executes user-defined regex rules.
226
+ Returns: {exit_code, skipped, name, data: {violations, message}}
227
+ """
228
+ dir_path = pathlib.Path(target_dir or os.getcwd())
229
+ candidates = ['.clckernel.yml', '.clckernel.yaml', '.clc-forge.yml', '.clc-forge.yaml']
230
+ config_path = next((dir_path / c for c in candidates if (dir_path / c).exists()), None)
231
+
232
+ # ── 1. Load config ──────────────────────────────────────────────
233
+ if not config_path:
234
+ return {
235
+ "exit_code": 0,
236
+ "skipped": True,
237
+ "name": "check_custom",
238
+ "data": {"violations": {}, "message": "No .clckernel.yml or .clc-forge.yml found"},
239
+ }
240
+
241
+ try:
242
+ config = _parse_yaml(config_path.read_text(encoding='utf-8'))
243
+ except Exception as e:
244
+ return {
245
+ "exit_code": 0,
246
+ "skipped": True,
247
+ "name": "check_custom",
248
+ "data": {"violations": {}, "message": f"Config parse error: {e}"},
249
+ }
250
+
251
+ rules = config.get('rules') if isinstance(config, dict) else None
252
+ if rules is not None and not isinstance(rules, list):
253
+ return {
254
+ "exit_code": 0,
255
+ "skipped": True,
256
+ "name": "check_custom",
257
+ "data": {"violations": {}, "message": 'Config error: "rules" must be a list'},
258
+ }
259
+ if not isinstance(rules, list) or len(rules) == 0:
260
+ return {
261
+ "exit_code": 0,
262
+ "skipped": False,
263
+ "name": "check_custom",
264
+ "data": {"violations": {}, "message": "No custom rules defined"},
265
+ }
266
+
267
+ # ── 2. Validate rules: compile regexes, check required fields ───
268
+ validated = []
269
+ for rule in rules:
270
+ if not isinstance(rule, dict):
271
+ continue
272
+ if not rule.get('name') or not rule.get('pattern') or not rule.get('message'):
273
+ continue
274
+ try:
275
+ regex = re.compile(rule['pattern'], re.MULTILINE)
276
+ scope_re = _glob_to_regex(rule.get('scope', '**/*'))
277
+ exclude_re = [_glob_to_regex(ex) for ex in rule.get('exclude', [])]
278
+ validated.append({
279
+ 'name': rule['name'],
280
+ 'pattern': rule['pattern'],
281
+ 'message': rule['message'],
282
+ 'severity': rule.get('severity', 'error'),
283
+ 'exclude': rule.get('exclude', []),
284
+ 'regex': regex,
285
+ 'scope_re': scope_re,
286
+ 'exclude_re': exclude_re,
287
+ })
288
+ except re.error:
289
+ # Invalid regex — skip this rule silently
290
+ continue
291
+
292
+ if len(validated) == 0:
293
+ return {
294
+ "exit_code": 0,
295
+ "skipped": False,
296
+ "name": "check_custom",
297
+ "data": {"violations": {}, "message": "No valid custom rules after validation"},
298
+ }
299
+
300
+ # ── 3. Get changed files via git diff ───────────────────────────
301
+ files = _get_changed_files(dir_path)
302
+ if files is None:
303
+ return {
304
+ "exit_code": 0,
305
+ "skipped": True,
306
+ "name": "check_custom",
307
+ "data": {"violations": {}, "message": "No git diff available"},
308
+ }
309
+ if len(files) == 0:
310
+ return {
311
+ "exit_code": 0,
312
+ "skipped": False,
313
+ "name": "check_custom",
314
+ "data": {"violations": {}, "message": "No files to check"},
315
+ }
316
+
317
+ # ── 4. Run rules against files ──────────────────────────────────
318
+ violations = {}
319
+ has_error = False
320
+
321
+ for file in files:
322
+ file_path = pathlib.Path(dir_path) / file
323
+ try:
324
+ content = file_path.read_text(encoding='utf-8')
325
+ except (PermissionError, OSError):
326
+ continue # permission denied or unreadable — skip
327
+
328
+ for rule in validated:
329
+ if not rule['scope_re'].match(file):
330
+ continue
331
+ if any(re.match(str(ex.pattern), file) for ex in rule['exclude_re']):
332
+ continue
333
+
334
+ for m in rule['regex'].finditer(content):
335
+ line_no = content[:m.start()].count('\n') + 1
336
+ if file not in violations:
337
+ violations[file] = []
338
+ violations[file].append({
339
+ "lineNo": line_no,
340
+ "rule": rule['name'],
341
+ "message": rule['message'],
342
+ "severity": rule['severity'],
343
+ })
344
+ if rule['severity'] == 'error':
345
+ has_error = True
346
+
347
+ # ── 5. Build result ─────────────────────────────────────────────
348
+ total_count = sum(len(v) for v in violations.values())
349
+ file_count = len(violations)
350
+ if total_count > 0:
351
+ msg = f"{total_count} custom rule violation(s) in {file_count} file(s)"
352
+ else:
353
+ msg = "No custom rule violations"
354
+
355
+ return {
356
+ "exit_code": 1 if has_error else 0,
357
+ "skipped": False,
358
+ "name": "check_custom",
359
+ "data": {"violations": violations, "message": msg},
360
+ }
361
+
362
+
363
+ def _get_changed_files(dir_path: str) -> Optional[list[str]]:
364
+ """Get list of changed files via git diff. Returns None if no diff available."""
365
+ try:
366
+ result = subprocess.run(
367
+ ['git', 'diff', '--name-only', 'HEAD~1'],
368
+ cwd=dir_path, capture_output=True, text=True, timeout=10,
369
+ )
370
+ files = [f for f in result.stdout.split('\n') if f.strip()]
371
+ if files:
372
+ return files
373
+ except Exception:
374
+ pass
375
+
376
+ try:
377
+ result = subprocess.run(
378
+ ['git', 'diff', '--name-only', '--cached'],
379
+ cwd=dir_path, capture_output=True, text=True, timeout=10,
380
+ )
381
+ files = [f for f in result.stdout.split('\n') if f.strip()]
382
+ return files if files else []
383
+ except Exception:
384
+ return None
385
+
386
+
387
+ # ═══════════════════════════════════════════════════════════════════════
388
+ # CLI entry point
389
+ # ═══════════════════════════════════════════════════════════════════════
390
+
391
+ if __name__ == '__main__':
392
+ target = sys.argv[1] if len(sys.argv) > 1 else None
393
+ result = check(target)
394
+ print(json.dumps(result, indent=2))
395
+ sys.exit(result["exit_code"])