clckernel 1.2.18 → 2.0.0
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/skills/clckernel_cli/SKILL.md +23 -6
- package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
- package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
- package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
- package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
- package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
- package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
- package/.agents/skills/clckernel_cli/reference/start.md +14 -4
- package/.agents/skills/clckernel_cli/reference/test.md +1 -1
- package/.agents/skills/clckernel_cli/reference/update.md +19 -19
- package/CHANGELOG.md +244 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/cli.js +8 -0
- package/package.json +12 -2
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +27 -46
- package/src/adapters/django.js +14 -13
- package/src/adapters/fastapi.js +16 -15
- package/src/adapters/go.js +11 -9
- package/src/adapters/laravel.js +12 -10
- package/src/adapters/nextjs.js +7 -5
- package/src/adapters/rails.js +13 -11
- package/src/adapters/rust.js +11 -9
- package/src/catalog.js +58 -49
- package/src/config.js +38 -240
- package/src/detector.js +243 -44
- package/src/doctor.js +18 -9
- package/src/generator.js +737 -309
- package/src/gitignore.js +144 -0
- package/src/index.js +257 -18
- package/src/io.js +409 -0
- package/src/known-hashes.json +309 -0
- package/src/onboarding.js +15 -0
- package/src/technologies/detector.js +1 -1
- package/src/technologies/guards/_shared.js +1 -1
- package/src/ui.js +233 -40
- package/src/updater.js +271 -36
- package/tools/_changes.js +279 -0
- package/tools/_changes.py +233 -0
- package/tools/_config.js +294 -0
- package/tools/_config.py +271 -0
- package/tools/_yaml.py +489 -0
- package/tools/audit.js +511 -111
- package/tools/audit.py +506 -128
- package/tools/celery_guard.py +13 -35
- package/tools/check_a11y.js +8 -28
- package/tools/check_api_contracts.js +13 -39
- package/tools/check_architecture.js +35 -44
- package/tools/check_architecture.py +172 -105
- package/tools/check_custom.js +108 -61
- package/tools/check_custom.py +108 -271
- package/tools/check_data_files.js +88 -0
- package/tools/check_db_efficiency.py +80 -65
- package/tools/check_hitl.js +112 -69
- package/tools/check_links.js +138 -0
- package/tools/check_migrations.py +143 -94
- package/tools/check_performance.js +35 -44
- package/tools/check_placeholders.js +104 -0
- package/tools/check_responsive.js +8 -28
- package/tools/check_scope.py +40 -31
- package/tools/check_seo.js +8 -28
- package/tools/check_skills.js +158 -0
- package/tools/check_storybook.js +8 -21
- package/tools/check_symlinks.js +40 -22
- package/tools/check_ui_reuse.js +17 -29
- package/tools/docker_guard.py +19 -41
- package/tools/postgres_guard.py +15 -36
- package/tools/redis_guard.py +13 -35
- package/tools/scan_secrets.js +134 -67
- package/tools/scan_secrets.py +163 -72
- package/tools/verify_tdd.js +867 -0
- package/tools/verify_tdd.py +407 -165
- package/tools/yaml.js +404 -132
- package/tools/check_domain_data.js +0 -100
- package/tools/check_test_calendar_sync.js +0 -111
package/tools/check_custom.py
CHANGED
|
@@ -1,233 +1,61 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
Custom Rules Guard — Python
|
|
4
|
-
Reads .
|
|
5
|
-
Self-contained:
|
|
4
|
+
Reads .clckernel.yaml and executes user-defined regex rules against changed files.
|
|
5
|
+
Self-contained: Python stdlib + optional PyYAML, and the sibling helpers
|
|
6
|
+
_changes.py and _yaml.py (the YAML subset parser, shared with _config.py).
|
|
6
7
|
"""
|
|
7
8
|
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
8
11
|
import json
|
|
9
12
|
import os
|
|
10
13
|
import pathlib
|
|
11
14
|
import re
|
|
12
|
-
import subprocess
|
|
13
15
|
import sys
|
|
14
16
|
from typing import Any, Optional
|
|
15
17
|
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
18
|
+
# Sibling helper shipped next to the guards; works both as a script and when
|
|
19
|
+
# loaded by audit.py through importlib.
|
|
20
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
21
|
+
if _TOOLS_DIR not in sys.path:
|
|
22
|
+
sys.path.insert(0, _TOOLS_DIR)
|
|
23
|
+
import _changes # noqa: E402
|
|
30
24
|
|
|
31
|
-
return _minimal_yaml_parse(text)
|
|
32
25
|
|
|
26
|
+
def _yaml_module():
|
|
27
|
+
"""The shared parser (tools/_yaml.py), imported only when a config exists.
|
|
33
28
|
|
|
34
|
-
|
|
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.
|
|
29
|
+
Install ships it next to _changes.py; an older install without it fails
|
|
30
|
+
closed (run `clckernel update`).
|
|
39
31
|
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
32
|
+
try:
|
|
33
|
+
import _yaml
|
|
34
|
+
except ImportError as exc:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
"tools/_yaml.py is missing: run `clckernel update` so the guards can read the config"
|
|
37
|
+
) from exc
|
|
38
|
+
return _yaml
|
|
174
39
|
|
|
175
40
|
|
|
176
41
|
# ═══════════════════════════════════════════════════════════════════════
|
|
177
|
-
#
|
|
178
|
-
# Handles: **, *, {a,b} alternation
|
|
42
|
+
# Main Guard Contract
|
|
179
43
|
# ═══════════════════════════════════════════════════════════════════════
|
|
180
44
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
45
|
+
# Lookup order: the canonical name first, then older names kept for compatibility.
|
|
46
|
+
CONFIG_NAMES = ['.clckernel.yaml', '.clckernel.yml', '.clc-forge.yaml', '.clc-forge.yml']
|
|
47
|
+
CANONICAL_CONFIG = CONFIG_NAMES[0]
|
|
48
|
+
DEPRECATED_PREFIX = '.clc-forge.'
|
|
217
49
|
|
|
218
|
-
# ═══════════════════════════════════════════════════════════════════════
|
|
219
|
-
# Main Guard Contract
|
|
220
|
-
# ═══════════════════════════════════════════════════════════════════════
|
|
221
50
|
|
|
222
51
|
def check(target_dir: Optional[str] = None) -> dict:
|
|
223
52
|
"""
|
|
224
53
|
Custom rules guard entry point.
|
|
225
|
-
Reads .
|
|
226
|
-
Returns: {exit_code, skipped, name, data: {violations, message}}
|
|
54
|
+
Reads .clckernel.yaml and executes user-defined regex rules.
|
|
55
|
+
Returns: {exit_code, skipped, name, data: {violations, message[, warnings]}}
|
|
227
56
|
"""
|
|
228
57
|
dir_path = pathlib.Path(target_dir or os.getcwd())
|
|
229
|
-
|
|
230
|
-
config_path = next((dir_path / c for c in candidates if (dir_path / c).exists()), None)
|
|
58
|
+
config_path = next((dir_path / c for c in CONFIG_NAMES if (dir_path / c).exists()), None)
|
|
231
59
|
|
|
232
60
|
# ── 1. Load config ──────────────────────────────────────────────
|
|
233
61
|
if not config_path:
|
|
@@ -235,27 +63,42 @@ def check(target_dir: Optional[str] = None) -> dict:
|
|
|
235
63
|
"exit_code": 0,
|
|
236
64
|
"skipped": True,
|
|
237
65
|
"name": "check_custom",
|
|
238
|
-
"data": {"violations": {}, "message": "No
|
|
66
|
+
"data": {"violations": {}, "message": f"No {CANONICAL_CONFIG} found"},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
config_name = config_path.name
|
|
70
|
+
result = _run_rules(dir_path, config_path, config_name)
|
|
71
|
+
if config_name.startswith(DEPRECATED_PREFIX):
|
|
72
|
+
result["data"]["warnings"] = [f"{config_name}: deprecated config name, rename to {CANONICAL_CONFIG}"]
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_rules(dir_path: pathlib.Path, config_path: pathlib.Path, config_name: str) -> dict:
|
|
77
|
+
"""Validate the rules in one config file and run them against changed files."""
|
|
78
|
+
|
|
79
|
+
def config_error(message: str) -> dict:
|
|
80
|
+
return {
|
|
81
|
+
"exit_code": 1,
|
|
82
|
+
"skipped": False,
|
|
83
|
+
"name": "check_custom",
|
|
84
|
+
"data": {"violations": {}, "message": f"Config error in {config_name}: {message}"},
|
|
239
85
|
}
|
|
240
86
|
|
|
241
87
|
try:
|
|
242
|
-
|
|
88
|
+
yaml_module = _yaml_module()
|
|
89
|
+
config = yaml_module.parse_yaml(config_path.read_text(encoding='utf-8'))
|
|
243
90
|
except Exception as e:
|
|
91
|
+
# Fail closed: rules we cannot read must not turn into a silent PASS.
|
|
244
92
|
return {
|
|
245
|
-
"exit_code":
|
|
246
|
-
"skipped":
|
|
93
|
+
"exit_code": 1,
|
|
94
|
+
"skipped": False,
|
|
247
95
|
"name": "check_custom",
|
|
248
|
-
"data": {"violations": {}, "message": f"Config parse error: {e}"},
|
|
96
|
+
"data": {"violations": {}, "message": f"Config parse error in {config_name}: {e}"},
|
|
249
97
|
}
|
|
250
98
|
|
|
251
99
|
rules = config.get('rules') if isinstance(config, dict) else None
|
|
252
100
|
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
|
-
}
|
|
101
|
+
return config_error('"rules" must be a list')
|
|
259
102
|
if not isinstance(rules, list) or len(rules) == 0:
|
|
260
103
|
return {
|
|
261
104
|
"exit_code": 0,
|
|
@@ -264,42 +107,40 @@ def check(target_dir: Optional[str] = None) -> dict:
|
|
|
264
107
|
"data": {"violations": {}, "message": "No custom rules defined"},
|
|
265
108
|
}
|
|
266
109
|
|
|
267
|
-
# ── 2. Validate rules:
|
|
110
|
+
# ── 2. Validate rules: shape and regex; any invalid rule fails the guard ──
|
|
268
111
|
validated = []
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
112
|
+
errors = []
|
|
113
|
+
for i, rule in enumerate(rules):
|
|
114
|
+
problems = _validate_rule(rule)
|
|
115
|
+
is_named = isinstance(rule, dict) and isinstance(rule.get('name'), str)
|
|
116
|
+
label = f'"{rule["name"]}"' if is_named else f'#{i + 1}'
|
|
117
|
+
if problems:
|
|
118
|
+
errors.append(f"rule {label}: {', '.join(problems)}")
|
|
273
119
|
continue
|
|
274
120
|
try:
|
|
275
121
|
regex = re.compile(rule['pattern'], re.MULTILINE)
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
122
|
+
except re.error as e:
|
|
123
|
+
errors.append(f'rule {label}: invalid pattern: {e}')
|
|
290
124
|
continue
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if
|
|
125
|
+
validated.append({
|
|
126
|
+
'name': rule['name'],
|
|
127
|
+
'pattern': rule['pattern'],
|
|
128
|
+
'message': rule['message'],
|
|
129
|
+
'severity': rule.get('severity') or 'error',
|
|
130
|
+
'exclude': rule.get('exclude') or [],
|
|
131
|
+
'regex': regex,
|
|
132
|
+
'scope_re': yaml_module.glob_to_regex(rule.get('scope') or '**/*'),
|
|
133
|
+
'exclude_re': [yaml_module.glob_to_regex(ex) for ex in rule.get('exclude') or []],
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if errors:
|
|
137
|
+
return config_error('; '.join(errors))
|
|
138
|
+
|
|
139
|
+
# ── 3. Changed files of the snapshot being verified ─────────────
|
|
140
|
+
# The index, or HEAD when nothing is staged; never the working copy.
|
|
141
|
+
changes = _changes.changed_files(str(dir_path))
|
|
142
|
+
mode, files = changes["mode"], changes["files"]
|
|
143
|
+
if mode is None:
|
|
303
144
|
return {
|
|
304
145
|
"exit_code": 0,
|
|
305
146
|
"skipped": True,
|
|
@@ -319,11 +160,9 @@ def check(target_dir: Optional[str] = None) -> dict:
|
|
|
319
160
|
has_error = False
|
|
320
161
|
|
|
321
162
|
for file in files:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
except (PermissionError, OSError):
|
|
326
|
-
continue # permission denied or unreadable — skip
|
|
163
|
+
content = _changes.read_file(str(dir_path), file, mode)
|
|
164
|
+
if content is None:
|
|
165
|
+
continue # missing or binary in the snapshot — skip
|
|
327
166
|
|
|
328
167
|
for rule in validated:
|
|
329
168
|
if not rule['scope_re'].match(file):
|
|
@@ -331,6 +170,8 @@ def check(target_dir: Optional[str] = None) -> dict:
|
|
|
331
170
|
if any(re.match(str(ex.pattern), file) for ex in rule['exclude_re']):
|
|
332
171
|
continue
|
|
333
172
|
|
|
173
|
+
# Empty matches (e.g. `x*`, `^`) are violations too; check_custom.js
|
|
174
|
+
# steps over them one code point at a time to report the same ones.
|
|
334
175
|
for m in rule['regex'].finditer(content):
|
|
335
176
|
line_no = content[:m.start()].count('\n') + 1
|
|
336
177
|
if file not in violations:
|
|
@@ -360,28 +201,22 @@ def check(target_dir: Optional[str] = None) -> dict:
|
|
|
360
201
|
}
|
|
361
202
|
|
|
362
203
|
|
|
363
|
-
def
|
|
364
|
-
"""
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
204
|
+
def _validate_rule(rule: Any) -> list:
|
|
205
|
+
"""Shape problems of one rule (empty list when valid)."""
|
|
206
|
+
if not isinstance(rule, dict):
|
|
207
|
+
return ['must be a mapping']
|
|
208
|
+
problems = []
|
|
209
|
+
for field in ('name', 'pattern', 'message'):
|
|
210
|
+
if not isinstance(rule.get(field), str) or rule.get(field) == '':
|
|
211
|
+
problems.append(f'"{field}" must be a non-empty string')
|
|
212
|
+
if rule.get('severity') is not None and rule.get('severity') not in ('error', 'warning'):
|
|
213
|
+
problems.append('"severity" must be "error" or "warning"')
|
|
214
|
+
if rule.get('scope') is not None and not isinstance(rule.get('scope'), str):
|
|
215
|
+
problems.append('"scope" must be a string')
|
|
216
|
+
exclude = rule.get('exclude')
|
|
217
|
+
if exclude is not None and not (isinstance(exclude, list) and all(isinstance(e, str) for e in exclude)):
|
|
218
|
+
problems.append('"exclude" must be a list of strings')
|
|
219
|
+
return problems
|
|
385
220
|
|
|
386
221
|
|
|
387
222
|
# ═══════════════════════════════════════════════════════════════════════
|
|
@@ -391,5 +226,7 @@ def _get_changed_files(dir_path: str) -> Optional[list[str]]:
|
|
|
391
226
|
if __name__ == '__main__':
|
|
392
227
|
target = sys.argv[1] if len(sys.argv) > 1 else None
|
|
393
228
|
result = check(target)
|
|
229
|
+
for warning in result["data"].get("warnings", []):
|
|
230
|
+
print(f"[check_custom] warning: {warning}", file=sys.stderr)
|
|
394
231
|
print(json.dumps(result, indent=2))
|
|
395
232
|
sys.exit(result["exit_code"])
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Data File Guard
|
|
4
|
+
* Every .json file changed in the snapshot must parse as JSON, whatever folder
|
|
5
|
+
* it lives in. A failure names the file and the parse error (reason and
|
|
6
|
+
* line/column); the file content is never printed.
|
|
7
|
+
* Only JSON: YAML data files may use full YAML, which the strict subset parser
|
|
8
|
+
* of the harness (tools/yaml.js) cannot validate without false positives.
|
|
9
|
+
* Self-contained: uses only Node.js built-ins.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} targetDir - Repository root (defaults to cwd)
|
|
12
|
+
* @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
|
|
13
|
+
*/
|
|
14
|
+
const { changedFiles, readFile } = require('./_changes');
|
|
15
|
+
|
|
16
|
+
const NAME = 'check_data_files';
|
|
17
|
+
const MAX_REASON = 120;
|
|
18
|
+
|
|
19
|
+
/** Line and column (1-based) of a character offset. */
|
|
20
|
+
function lineColumn(text, offset) {
|
|
21
|
+
const before = text.slice(0, offset).split('\n');
|
|
22
|
+
return { line: before.length, column: before[before.length - 1].length + 1 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse error without the file content: newer V8 messages quote the input
|
|
27
|
+
* (`..., "<content>" is not valid JSON`), so that part is dropped and the
|
|
28
|
+
* position, when reported, becomes a line and column.
|
|
29
|
+
*/
|
|
30
|
+
function describeError(err, text) {
|
|
31
|
+
let reason = String(err && err.message ? err.message : err)
|
|
32
|
+
.replace(/,\s*"[\s\S]*" is not valid JSON$/, '')
|
|
33
|
+
.replace(/\s*\(line \d+ column \d+\)$/, '');
|
|
34
|
+
const position = /\s*(?:in JSON\s*)?at position (\d+)/.exec(reason);
|
|
35
|
+
let where = '';
|
|
36
|
+
if (position) {
|
|
37
|
+
reason = reason.slice(0, position.index);
|
|
38
|
+
const { line, column } = lineColumn(text, Number(position[1]));
|
|
39
|
+
where = ` at line ${line}, column ${column}`;
|
|
40
|
+
}
|
|
41
|
+
reason = reason.trim().slice(0, MAX_REASON) || 'Invalid JSON';
|
|
42
|
+
return `Invalid JSON${where}: ${reason}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const check = async function checkDataFiles(targetDir) {
|
|
46
|
+
const dir = targetDir || process.cwd();
|
|
47
|
+
const { mode, files } = changedFiles(dir, { extensions: ['.json'] });
|
|
48
|
+
|
|
49
|
+
if (files.length === 0) {
|
|
50
|
+
return {
|
|
51
|
+
exitCode: 0, skipped: true, name: NAME,
|
|
52
|
+
data: { violations: [], message: 'No changed .json files' },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const violations = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const text = readFile(dir, file, mode);
|
|
59
|
+
if (text === null) continue;
|
|
60
|
+
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
61
|
+
try {
|
|
62
|
+
JSON.parse(body);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
violations.push({ file, message: describeError(err, body) });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
exitCode: violations.length > 0 ? 1 : 0,
|
|
70
|
+
skipped: false,
|
|
71
|
+
name: NAME,
|
|
72
|
+
data: {
|
|
73
|
+
violations,
|
|
74
|
+
message: violations.length > 0
|
|
75
|
+
? `${violations.length} JSON file(s) do not parse: ${violations.map(v => v.file).join(', ')}`
|
|
76
|
+
: `${files.length} changed JSON file(s) parse`,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
module.exports = check;
|
|
82
|
+
|
|
83
|
+
if (require.main === module) {
|
|
84
|
+
check(process.argv[2]).then(result => {
|
|
85
|
+
console.log(JSON.stringify(result, null, 2));
|
|
86
|
+
process.exit(result.exitCode);
|
|
87
|
+
});
|
|
88
|
+
}
|