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,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"])
|