clckernel 1.2.19 → 2.0.1
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 +250 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/deprecated.js +13 -0
- package/package.json +14 -4
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +23 -38
- 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 +734 -302
- package/src/gitignore.js +60 -14
- 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 -38
- 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/_config.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project configuration shared by the Python guards (twin of _config.js):
|
|
3
|
+
finds .clckernel.yaml in the project root, parses it with the sibling
|
|
4
|
+
_yaml.py (PyYAML when installed), validates it and fills in the defaults.
|
|
5
|
+
The defaults reproduce the behaviour of a project without the key. See
|
|
6
|
+
_config.js for the schema.
|
|
7
|
+
|
|
8
|
+
The `hitl.markers` regexes are compiled by the JavaScript guard check_hitl;
|
|
9
|
+
here they are only checked to be strings.
|
|
10
|
+
|
|
11
|
+
Self-contained: Python 3.9+ standard library only (PyYAML optional).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import posixpath
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any, Callable, Optional
|
|
20
|
+
|
|
21
|
+
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
22
|
+
if _TOOLS_DIR not in sys.path:
|
|
23
|
+
sys.path.insert(0, _TOOLS_DIR)
|
|
24
|
+
import _yaml # noqa: E402
|
|
25
|
+
|
|
26
|
+
# Lookup order: the canonical name first, then older names kept for compatibility.
|
|
27
|
+
CONFIG_NAMES = ['.clckernel.yaml', '.clckernel.yml', '.clc-forge.yaml', '.clc-forge.yml']
|
|
28
|
+
|
|
29
|
+
KEYS = ["rules", "exclude_paths", "mirrors", "hitl", "update", "tdd", "severities"]
|
|
30
|
+
LEVELS = ["error", "warning"]
|
|
31
|
+
|
|
32
|
+
DEPRECATED_KEYS = {
|
|
33
|
+
"active_guards": "guards are chosen by the stack, the registry and `clckernel update --add-guards`",
|
|
34
|
+
"phases": "the workflow phases are agent instructions in AGENTS.md",
|
|
35
|
+
"gate_mode": "every installed guard blocks the commit when it fails",
|
|
36
|
+
"layers": "layer rules live in the architecture guards",
|
|
37
|
+
"scope": "use exclude_paths to keep paths out of the guards",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigError(ValueError):
|
|
42
|
+
"""An invalid value; the message names the file and the key."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, key: str, message: str, file: str = CONFIG_NAMES[0], allowed: Optional[list] = None):
|
|
45
|
+
allowed = allowed or []
|
|
46
|
+
suffix = f" (allowed: {', '.join(allowed)})" if allowed else ""
|
|
47
|
+
super().__init__(f'Invalid {file}: key "{key}" — {message}{suffix}')
|
|
48
|
+
self.key = key
|
|
49
|
+
self.file = file
|
|
50
|
+
self.allowed = allowed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_config() -> dict:
|
|
54
|
+
return {
|
|
55
|
+
"rules": [],
|
|
56
|
+
"exclude_paths": [],
|
|
57
|
+
"mirrors": {"list": None, "required": False},
|
|
58
|
+
"hitl": {"markers": None, "paths": None, "optOut": None},
|
|
59
|
+
"update": {"exclude": []},
|
|
60
|
+
"tdd": {"code": [], "ignore": []},
|
|
61
|
+
"severities": {},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _type_name(value: Any) -> str:
|
|
66
|
+
if value is None:
|
|
67
|
+
return "null"
|
|
68
|
+
if isinstance(value, list):
|
|
69
|
+
return "a list"
|
|
70
|
+
if isinstance(value, bool):
|
|
71
|
+
return "boolean"
|
|
72
|
+
if isinstance(value, (int, float)):
|
|
73
|
+
return "number"
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
return "string"
|
|
76
|
+
return "object"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _string_list(value: Any, key: str, file: str, check: Optional[Callable] = None) -> list:
|
|
80
|
+
if not isinstance(value, list):
|
|
81
|
+
raise ConfigError(key, f"expected a list of strings, got {_type_name(value)}", file)
|
|
82
|
+
for i, item in enumerate(value):
|
|
83
|
+
if not isinstance(item, str) or item.strip() == "":
|
|
84
|
+
raise ConfigError(f"{key}[{i}]", "each item must be a non-empty string", file)
|
|
85
|
+
if check:
|
|
86
|
+
check(item, f"{key}[{i}]")
|
|
87
|
+
return list(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _section(raw: dict, key: str, known: list, file: str, label: Optional[str] = None) -> dict:
|
|
91
|
+
label = label or key
|
|
92
|
+
value = raw.get(key)
|
|
93
|
+
if value is None:
|
|
94
|
+
return {}
|
|
95
|
+
if not isinstance(value, dict):
|
|
96
|
+
raise ConfigError(label, f"expected a mapping, got {_type_name(value)}", file)
|
|
97
|
+
for sub in value:
|
|
98
|
+
if sub not in known:
|
|
99
|
+
raise ConfigError(f"{label}.{sub}", "unknown key", file, [f"{label}.{k}" for k in known])
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _validate_rules(rules: Any, file: str) -> list:
|
|
104
|
+
if rules is None:
|
|
105
|
+
return []
|
|
106
|
+
if not isinstance(rules, list):
|
|
107
|
+
raise ConfigError("rules", f"expected a list, got {_type_name(rules)}", file)
|
|
108
|
+
seen = set()
|
|
109
|
+
for i, rule in enumerate(rules):
|
|
110
|
+
if not isinstance(rule, dict):
|
|
111
|
+
raise ConfigError(f"rules[{i}]", "each rule must be a mapping", file)
|
|
112
|
+
name = rule.get("name")
|
|
113
|
+
if not isinstance(name, str) or name.strip() == "":
|
|
114
|
+
raise ConfigError(f"rules[{i}].name", "each rule requires a non-empty name", file)
|
|
115
|
+
if name in seen:
|
|
116
|
+
raise ConfigError(f"rules[{i}].name", f'duplicate rule name "{name}"', file)
|
|
117
|
+
seen.add(name)
|
|
118
|
+
severity = rule.get("severity")
|
|
119
|
+
if severity is not None and severity not in ("error", "warning"):
|
|
120
|
+
raise ConfigError(f"rules[{i}].severity", f'invalid severity "{severity}"', file, ["error", "warning"])
|
|
121
|
+
return rules
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validate_severities(value: Any, file: str) -> dict:
|
|
125
|
+
"""severities: a mapping of guard name -> 'error' | 'warning'."""
|
|
126
|
+
if value is None:
|
|
127
|
+
return {}
|
|
128
|
+
if not isinstance(value, dict):
|
|
129
|
+
raise ConfigError("severities", f"expected a mapping of guard name to level, got {_type_name(value)}", file)
|
|
130
|
+
out = {}
|
|
131
|
+
for name, level in value.items():
|
|
132
|
+
if not isinstance(name, str) or name.strip() == "":
|
|
133
|
+
raise ConfigError("severities", "guard names must be non-empty", file)
|
|
134
|
+
if not isinstance(level, str) or level not in LEVELS:
|
|
135
|
+
raise ConfigError(f"severities.{name}", f"invalid level {json.dumps(level)}", file, LEVELS)
|
|
136
|
+
out[name] = level
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_mirror_path(item: str, key: str, file: str) -> None:
|
|
141
|
+
segments = item.replace("\\", "/").split("/")
|
|
142
|
+
if os.path.isabs(item) or item.startswith("/") or ".." in segments:
|
|
143
|
+
raise ConfigError(key, f'"{item}" must be a path inside the project', file)
|
|
144
|
+
if posixpath.normpath(item) == "AGENTS.md":
|
|
145
|
+
raise ConfigError(key, "AGENTS.md is the source, not a mirror", file)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def validate_config(raw: Any, file: str = CONFIG_NAMES[0]) -> tuple:
|
|
149
|
+
"""Validate a parsed config and fill in the defaults: (config, warnings)."""
|
|
150
|
+
if not isinstance(raw, dict):
|
|
151
|
+
raise ConfigError("<root>", "expected a mapping at the top level", file)
|
|
152
|
+
config = default_config()
|
|
153
|
+
warnings = []
|
|
154
|
+
for key in raw:
|
|
155
|
+
if key in DEPRECATED_KEYS:
|
|
156
|
+
warnings.append(f'{file}: key "{key}" is deprecated and has no effect ({DEPRECATED_KEYS[key]}); remove it')
|
|
157
|
+
elif key not in KEYS:
|
|
158
|
+
warnings.append(f'{file}: unknown key "{key}" is ignored (known keys: {", ".join(KEYS)})')
|
|
159
|
+
|
|
160
|
+
config["rules"] = _validate_rules(raw.get("rules"), file)
|
|
161
|
+
if raw.get("exclude_paths") is not None:
|
|
162
|
+
config["exclude_paths"] = _string_list(raw["exclude_paths"], "exclude_paths", file)
|
|
163
|
+
|
|
164
|
+
mirrors = _section(raw, "mirrors", ["list", "required"], file)
|
|
165
|
+
if mirrors.get("list") is not None:
|
|
166
|
+
config["mirrors"]["list"] = _string_list(
|
|
167
|
+
mirrors["list"], "mirrors.list", file, lambda item, key: _check_mirror_path(item, key, file))
|
|
168
|
+
if mirrors.get("required") is not None:
|
|
169
|
+
if not isinstance(mirrors["required"], bool):
|
|
170
|
+
raise ConfigError("mirrors.required", f"expected true or false, got {_type_name(mirrors['required'])}", file)
|
|
171
|
+
config["mirrors"]["required"] = mirrors["required"]
|
|
172
|
+
|
|
173
|
+
hitl = _section(raw, "hitl", ["markers", "paths", "optOut"], file)
|
|
174
|
+
if hitl.get("markers") is not None:
|
|
175
|
+
config["hitl"]["markers"] = _string_list(hitl["markers"], "hitl.markers", file)
|
|
176
|
+
if hitl.get("paths") is not None:
|
|
177
|
+
config["hitl"]["paths"] = _string_list(hitl["paths"], "hitl.paths", file)
|
|
178
|
+
if hitl.get("optOut") is not None:
|
|
179
|
+
opt_out = _section(hitl, "optOut", ["key", "value"], file, "hitl.optOut")
|
|
180
|
+
key = opt_out.get("key")
|
|
181
|
+
if not isinstance(key, str) or key.strip() == "":
|
|
182
|
+
raise ConfigError("hitl.optOut.key", "expected a non-empty frontmatter key", file)
|
|
183
|
+
value = opt_out.get("value")
|
|
184
|
+
if isinstance(value, bool):
|
|
185
|
+
value = "true" if value else "false"
|
|
186
|
+
elif isinstance(value, (int, float)):
|
|
187
|
+
value = str(value)
|
|
188
|
+
elif not isinstance(value, str):
|
|
189
|
+
raise ConfigError("hitl.optOut.value", "expected the frontmatter value that exempts a file", file)
|
|
190
|
+
config["hitl"]["optOut"] = {"key": key, "value": value}
|
|
191
|
+
|
|
192
|
+
update = _section(raw, "update", ["exclude"], file)
|
|
193
|
+
if update.get("exclude") is not None:
|
|
194
|
+
config["update"]["exclude"] = _string_list(update["exclude"], "update.exclude", file)
|
|
195
|
+
|
|
196
|
+
tdd = _section(raw, "tdd", ["code", "ignore"], file)
|
|
197
|
+
if tdd.get("code") is not None:
|
|
198
|
+
config["tdd"]["code"] = _string_list(tdd["code"], "tdd.code", file)
|
|
199
|
+
if tdd.get("ignore") is not None:
|
|
200
|
+
config["tdd"]["ignore"] = _string_list(tdd["ignore"], "tdd.ignore", file)
|
|
201
|
+
|
|
202
|
+
config["severities"] = _validate_severities(raw.get("severities"), file)
|
|
203
|
+
|
|
204
|
+
return config, warnings
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_config_text(text: Optional[str], file: str = CONFIG_NAMES[0]) -> dict:
|
|
208
|
+
if text is None or text.strip() == "":
|
|
209
|
+
return {}
|
|
210
|
+
try:
|
|
211
|
+
raw = _yaml.parse_yaml(text)
|
|
212
|
+
except Exception as exc: # PyYAML's YAMLError or the subset parser's ValueError
|
|
213
|
+
raise ConfigError("<root>", f"YAML parse error: {exc}", file) from exc
|
|
214
|
+
if raw is None:
|
|
215
|
+
return {}
|
|
216
|
+
if not isinstance(raw, dict):
|
|
217
|
+
raise ConfigError("<root>", "expected a mapping at the top level", file)
|
|
218
|
+
return raw
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def find_config_file(target_dir: Optional[str] = None) -> Optional[str]:
|
|
222
|
+
root = str(target_dir or os.getcwd())
|
|
223
|
+
for name in CONFIG_NAMES:
|
|
224
|
+
candidate = os.path.join(root, name)
|
|
225
|
+
if os.path.exists(candidate):
|
|
226
|
+
return candidate
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_CACHE: dict = {}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load_config(target_dir: Optional[str] = None, cache: bool = True) -> dict:
|
|
234
|
+
"""
|
|
235
|
+
Load the config of the project at target_dir (read from the working tree).
|
|
236
|
+
|
|
237
|
+
Returns {"file", "path", "config", "warnings"}; raises ConfigError.
|
|
238
|
+
"""
|
|
239
|
+
root = os.path.abspath(str(target_dir or os.getcwd()))
|
|
240
|
+
if cache and root in _CACHE:
|
|
241
|
+
return _CACHE[root]
|
|
242
|
+
config_path = find_config_file(root)
|
|
243
|
+
if config_path is None:
|
|
244
|
+
loaded = {"file": None, "path": None, "config": default_config(), "warnings": []}
|
|
245
|
+
else:
|
|
246
|
+
file = os.path.basename(config_path)
|
|
247
|
+
with open(config_path, encoding="utf-8") as handle:
|
|
248
|
+
raw = parse_config_text(handle.read(), file)
|
|
249
|
+
config, warnings = validate_config(raw, file)
|
|
250
|
+
loaded = {"file": file, "path": config_path, "config": config, "warnings": warnings}
|
|
251
|
+
if cache:
|
|
252
|
+
_CACHE[root] = loaded
|
|
253
|
+
return loaded
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def glob_matcher(globs: Optional[list]) -> Callable[[str], bool]:
|
|
257
|
+
"""A predicate telling whether a root-relative POSIX path matches one of the globs."""
|
|
258
|
+
regexes = [_yaml.glob_to_regex(g) for g in globs or []]
|
|
259
|
+
return lambda rel: any(r.match(rel) for r in regexes)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
_REPORTED: set = set()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def report_warnings(loaded: dict) -> None:
|
|
266
|
+
"""Print each warning once per process, on stderr (guards keep stdout for JSON)."""
|
|
267
|
+
for warning in loaded["warnings"]:
|
|
268
|
+
if warning in _REPORTED:
|
|
269
|
+
continue
|
|
270
|
+
_REPORTED.add(warning)
|
|
271
|
+
print(f"clckernel: warning: {warning}", file=sys.stderr)
|