clckernel 1.2.19 → 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.
Files changed (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +244 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/cli.js +8 -0
  16. package/package.json +12 -2
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +23 -38
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +734 -302
  32. package/src/gitignore.js +60 -14
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -38
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. package/tools/check_test_calendar_sync.js +0 -111
package/tools/audit.py CHANGED
@@ -1,9 +1,31 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
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.
4
+ The single place that decides which guards run (sdds/unified-discovery):
5
+ `clckernel audit` and agents run it, the pre-commit hook runs it with --hook.
6
+ Same discovery, same guards, each exactly once:
7
+ - top-level tools/ guards: check_*, scan_*, verify_* or *_guard, .py/.js;
8
+ - every tools/guards/*.py and *.js (project guards).
9
+ audit.py runs the .py guards; the .js ones belong to audit.js, unless
10
+ tools/audit.js is not installed: then audit.py runs them too, with node.
11
+ A top-level guard of this language that defines check() is called in
12
+ process; every other guard runs as a process (exit code decides, a JSON
13
+ result is optional). A guard listed in the managed registry but missing on
14
+ disk fails the run. Results are grouped into 4 lenses.
15
+
16
+ CLI (sdds/audit-cli): audit.py [targetDir] [--hook] [--change <name>] [--staged]
17
+ (none) print the 4-lens summary; write nothing
18
+ --change <name> also write sdds/<name>/05-audit-report.md and
19
+ audit-receipt.json (<name>: one safe path segment);
20
+ 05-audit-report.py.md / audit-receipt.py.json when
21
+ tools/audit.js is installed too (mixed stack)
22
+ --hook one line per guard; write nothing (not with --change)
23
+ --staged the default snapshot, spelled out: the staged changes, or
24
+ the last commit when nothing is staged (_changes)
25
+ Unknown input exits 2. A failing guard whose level is 'warning' (its result's
26
+ `level`, overridden by `severities` in .clckernel.yaml) is reported but does
27
+ not fail the run; a guard that crashes always does. Self-contained: Python
28
+ stdlib plus the sibling _config.py (optional).
7
29
  """
8
30
  from __future__ import annotations
9
31
 
@@ -11,13 +33,35 @@ import importlib.util
11
33
  import json
12
34
  import os
13
35
  import pathlib
36
+ import re
14
37
  import subprocess
15
38
  import sys
16
39
  import traceback
17
40
 
18
41
  REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
19
-
20
- # ── 4 Canonical RDD Lenses Mapping ──────────────────────────────────────────
42
+ LENSES = ("correctness", "security", "architecture", "performance")
43
+ PROJECT_GUARD_RUNNERS = {".py": sys.executable or "python3", ".js": "node"}
44
+ OWN_EXT = ".py"
45
+ SIBLING_EXT, SIBLING_ORCHESTRATOR = ".js", "audit.js"
46
+ REGISTRY_FILE = ".clckernel-managed.json"
47
+ USAGE = "usage: audit.py [targetDir] [--hook] [--change <name>] [--staged]"
48
+ CHANGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
49
+ GUARD_STEM = re.compile(r"^(check|scan|verify)_")
50
+
51
+ # Runs a JS guard that exports a function (module contract) as a process:
52
+ # prints its result as JSON and exits with its exitCode.
53
+ JS_MODULE_RUNNER = (
54
+ "const p = require('path').resolve(process.argv[1]);"
55
+ "const m = require(p);"
56
+ "const fn = typeof m === 'function' ? m : Object.values(m || {}).find(v => typeof v === 'function');"
57
+ "if (!fn) { console.error('does not export a function'); process.exit(1); }"
58
+ "Promise.resolve(fn(process.cwd())).then(r => {"
59
+ " if (!r || typeof r !== 'object') { console.error('returned ' + (r === null ? 'null' : typeof r)); process.exitCode = 1; return; }"
60
+ " console.log(JSON.stringify(r)); process.exitCode = r.exitCode || 0; },"
61
+ " e => { console.error(e && e.message); process.exitCode = 1; });"
62
+ )
63
+
64
+ # ── Static name → lens mapping (4 RDD lenses) ──────────────────────────────────────────
21
65
 
22
66
  LENS_MAP = {
23
67
  # Correctness
@@ -30,6 +74,10 @@ LENS_MAP = {
30
74
  "tsc": "correctness",
31
75
  "lint": "correctness",
32
76
  "ruff": "correctness",
77
+ "check_skills": "correctness",
78
+ "check_links": "correctness",
79
+ "check_placeholders": "correctness",
80
+ "check_data_files": "correctness",
33
81
  # Security
34
82
  "scan_secrets": "security",
35
83
  "docker_guard": "security",
@@ -42,7 +90,6 @@ LENS_MAP = {
42
90
  "check_ui_reuse": "architecture",
43
91
  "check_symlinks": "architecture",
44
92
  "check_hitl": "architecture",
45
- "check_domain_data": "architecture",
46
93
  "check_migrations": "architecture",
47
94
  "graphify": "architecture",
48
95
  "gentle_ai_review": "architecture",
@@ -52,7 +99,6 @@ LENS_MAP = {
52
99
  "check_seo": "performance",
53
100
  "check_responsive": "performance",
54
101
  "check_storybook": "performance",
55
- "check_test_calendar_sync": "performance",
56
102
  "check_observability": "performance",
57
103
  }
58
104
 
@@ -62,39 +108,190 @@ def get_lens(guard_name: str) -> str:
62
108
 
63
109
  # ── Helpers ──────────────────────────────────────────────────────────────────
64
110
 
65
- def find_active_sdd(change_name: str | None = None) -> pathlib.Path | None:
66
- """Find the active SDD directory by name or pick the sole candidate."""
67
- sdds_dir = REPO_ROOT / "sdds"
68
- if not sdds_dir.exists():
69
- return None
70
- if change_name:
71
- target = sdds_dir / change_name
72
- return target if target.is_dir() else None
73
- candidates = [p for p in sdds_dir.iterdir() if p.is_dir()]
74
- if len(candidates) == 1:
75
- return candidates[0]
76
- return None
111
+ def is_guard_file(name: str) -> bool:
112
+ """A top-level guard file name: check_*, scan_*, verify_* or *_guard, .py or .js."""
113
+ stem, ext = os.path.splitext(name)
114
+ if ext not in (".py", ".js"):
115
+ return False
116
+ return bool(GUARD_STEM.match(stem)) or stem.endswith("_guard")
117
+
118
+
119
+ def _regular_files(directory: pathlib.Path) -> list[pathlib.Path]:
120
+ if not directory.is_dir():
121
+ return []
122
+ return sorted((f for f in directory.iterdir() if f.is_file()), key=lambda f: f.name)
77
123
 
78
124
 
79
125
  def discover_guards(tools_dir: pathlib.Path) -> list[pathlib.Path]:
80
126
  """
81
- Discover all check_*.py and scan_*.py guard scripts in tools/.
82
- Excludes audit.py itself. Returns sorted list for deterministic ordering.
127
+ Top-level guards of this orchestrator's language (.py) in tools/,
128
+ alphabetical: check_*, scan_*, verify_* and *_guard (technology guards
129
+ such as docker_guard.py). Helpers (_*) and audit.py never match.
130
+ """
131
+ return [f for f in _regular_files(tools_dir) if is_guard_file(f.name) and f.suffix == OWN_EXT]
132
+
133
+
134
+ def discover_project_guards(tools_dir: pathlib.Path) -> list[pathlib.Path]:
135
+ """
136
+ Project guards (sdds/domain-agnostic, decision 3): every *.py and *.js
137
+ file in tools/guards/, alphabetical.
138
+ """
139
+ return [f for f in _regular_files(tools_dir / "guards") if f.suffix in PROJECT_GUARD_RUNNERS]
140
+
141
+
142
+ def registered_paths(tools_dir: pathlib.Path) -> list[str]:
143
+ """Paths the managed registry lists, in either format (list, or {files: [{path}]})."""
144
+ try:
145
+ data = json.loads((tools_dir / REGISTRY_FILE).read_text(encoding="utf-8"))
146
+ except (OSError, ValueError):
147
+ return []
148
+ if isinstance(data, list):
149
+ entries = data
150
+ elif isinstance(data, dict) and isinstance(data.get("files"), list):
151
+ entries = [f.get("path") for f in data["files"] if isinstance(f, dict)]
152
+ else:
153
+ entries = []
154
+ return [p for p in entries if isinstance(p, str)]
155
+
156
+
157
+ def _defines_check(path: pathlib.Path) -> bool:
158
+ try:
159
+ return re.search(r"^def check\s*\(", path.read_text(encoding="utf-8"), re.M) is not None
160
+ except (OSError, UnicodeDecodeError):
161
+ return False
162
+
163
+
164
+ def _exports_module(path: pathlib.Path) -> bool:
165
+ try:
166
+ text = path.read_text(encoding="utf-8")
167
+ except (OSError, UnicodeDecodeError):
168
+ return False
169
+ return re.search(r"\bmodule\.exports\b|^\s*exports\.[\w$]+\s*=", text, re.M) is not None
170
+
171
+
172
+ def plan_guards(tools_dir: pathlib.Path) -> list[dict]:
173
+ """
174
+ The guards this orchestrator runs (sdds/unified-discovery), in order:
175
+ top-level guards, then tools/guards/*. mode 'module' = a top-level .py
176
+ guard that defines check(): called in process; 'process' = any other
177
+ guard of a language this orchestrator owns (.py, plus .js when
178
+ tools/audit.js is not installed); 'missing' = registered, not on disk.
83
179
  """
84
- guards: list[pathlib.Path] = []
85
- for f in sorted(tools_dir.iterdir()):
86
- if not f.is_file():
180
+ exts = {OWN_EXT}
181
+ if not (tools_dir / SIBLING_ORCHESTRATOR).exists():
182
+ exts.add(SIBLING_EXT)
183
+ plan: list[dict] = []
184
+ top = [f for f in _regular_files(tools_dir) if is_guard_file(f.name) and f.suffix in exts]
185
+ for f in top:
186
+ module = f.suffix == OWN_EXT and _defines_check(f)
187
+ plan.append({"file": f, "label": f.name, "mode": "module" if module else "process"})
188
+ for f in discover_project_guards(tools_dir):
189
+ if f.suffix in exts:
190
+ plan.append({"file": f, "label": f"guards/{f.name}", "mode": "process"})
191
+ present = {f.name for f in top}
192
+ for rel in registered_paths(tools_dir):
193
+ if not rel.startswith("tools/"):
194
+ continue
195
+ name = rel[len("tools/"):]
196
+ if "/" in name or not is_guard_file(name) or os.path.splitext(name)[1] not in exts or name in present:
87
197
  continue
88
- name = f.name
89
- if (name.startswith("check_") or name.startswith("scan_") or name.startswith("verify_")) and name.endswith(".py"):
90
- guards.append(f)
91
- return guards
198
+ plan.append({"file": tools_dir / name, "label": name, "mode": "missing"})
199
+ return plan
200
+
201
+
202
+ def _missing_guard_result(entry: dict) -> dict:
203
+ name = os.path.splitext(entry["label"])[0]
204
+ return {
205
+ "exit_code": 1, "skipped": False, "crashed": True, "name": name, "lens": get_lens(name),
206
+ "data": {"message": (
207
+ f"Kernel guard missing: tools/{entry['label']} is listed in tools/{REGISTRY_FILE} "
208
+ "but not on disk; restore it with `clckernel update`"
209
+ )},
210
+ }
211
+
212
+
213
+ def _parse_guard_json(stdout: str):
214
+ """The guard's JSON result when its stdout (or its last line) is a JSON object."""
215
+ text = (stdout or "").strip()
216
+ for candidate in (text, text.split("\n")[-1]):
217
+ try:
218
+ value = json.loads(candidate)
219
+ except ValueError:
220
+ continue
221
+ if isinstance(value, dict):
222
+ return value
223
+ return None
224
+
225
+
226
+ def _last_line(text: str) -> str:
227
+ lines = [line.strip() for line in (text or "").split("\n") if line.strip()]
228
+ return lines[-1] if lines else ""
229
+
230
+
231
+ def run_project_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
232
+ """
233
+ Run one guard as a process with cwd = target_dir. The exit code decides
234
+ (non-zero fails); a JSON result with the standard shape
235
+ ({name, skipped, lens?, data: {message, violations}}) is used for the
236
+ report, plain output is reported by its last line. A .js guard that
237
+ exports a function is called through that contract; when it exits
238
+ non-zero without a JSON result it crashed. Lens: the JSON's, else the
239
+ guard name's, else architecture; level: the JSON's `level`.
240
+ """
241
+ fallback_name = guard_path.stem
242
+ fallback_lens = get_lens(fallback_name)
243
+ runner = PROJECT_GUARD_RUNNERS[guard_path.suffix]
244
+ module_contract = guard_path.suffix == ".js" and _exports_module(guard_path)
245
+ if module_contract:
246
+ args = [runner, "-e", JS_MODULE_RUNNER, str(guard_path)]
247
+ else:
248
+ args = [runner, str(guard_path)]
249
+ try:
250
+ proc = subprocess.run(args, cwd=target_dir, capture_output=True, text=True)
251
+ except OSError as exc:
252
+ return {
253
+ "exit_code": 1, "skipped": False, "crashed": True, "name": fallback_name, "lens": fallback_lens,
254
+ "data": {"message": f"Guard crashed: {exc}"},
255
+ }
256
+ exit_code = proc.returncode
257
+ output = (proc.stdout or "") + (proc.stderr or "")
258
+ if exit_code < 0:
259
+ return {
260
+ "exit_code": 1, "skipped": False, "crashed": True, "name": fallback_name, "lens": fallback_lens,
261
+ "data": {"message": f"Guard crashed: killed by signal {-exit_code}"},
262
+ "output": output,
263
+ }
264
+ result = _parse_guard_json(proc.stdout)
265
+ if result is not None:
266
+ data = result.get("data") if isinstance(result.get("data"), dict) else result
267
+ name = result.get("name")
268
+ return {
269
+ "exit_code": exit_code,
270
+ "skipped": exit_code == 0 and bool(result.get("skipped", False)),
271
+ "crashed": False,
272
+ "level": result.get("level"),
273
+ "name": name if isinstance(name, str) and name else fallback_name,
274
+ "lens": result.get("lens") if result.get("lens") in LENSES else fallback_lens,
275
+ "data": data,
276
+ "output": output,
277
+ }
278
+ line = _last_line(proc.stdout) if exit_code == 0 else (_last_line(proc.stderr) or _last_line(proc.stdout))
279
+ crashed = module_contract and exit_code != 0
280
+ if crashed:
281
+ message = f"Guard crashed: {line or f'exited with code {exit_code}'}"
282
+ else:
283
+ message = line or ("Passed" if exit_code == 0 else f"Exited with code {exit_code}")
284
+ return {
285
+ "exit_code": exit_code, "skipped": False, "crashed": crashed, "name": fallback_name, "lens": fallback_lens,
286
+ "data": {"message": message},
287
+ "output": output,
288
+ }
92
289
 
93
290
 
94
291
  def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
95
292
  """
96
293
  Run a single guard module by dynamic import and call its check() function.
97
- Returns a normalized result dict on success, or a skip result on failure.
294
+ Returns a normalized result dict on success, or a FAILED result if it cannot run.
98
295
  """
99
296
  guard_name = guard_path.stem # e.g. "check_scope"
100
297
 
@@ -102,8 +299,9 @@ def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
102
299
  spec = importlib.util.spec_from_file_location(guard_name, str(guard_path))
103
300
  if spec is None or spec.loader is None:
104
301
  return {
105
- "exit_code": 0, "skipped": True, "name": guard_name,
106
- "data": {"message": f"Cannot load module spec for {guard_name}"},
302
+ "exit_code": 1, "skipped": False, "crashed": True, "name": guard_name,
303
+ "lens": get_lens(guard_name),
304
+ "data": {"message": f"Guard crashed: cannot load module spec for {guard_name}"},
107
305
  }
108
306
 
109
307
  mod = importlib.util.module_from_spec(spec)
@@ -112,8 +310,9 @@ def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
112
310
  fn = getattr(mod, "check", None)
113
311
  if fn is None or not callable(fn):
114
312
  return {
115
- "exit_code": 0, "skipped": True, "name": guard_name,
116
- "data": {"message": f"Guard {guard_name} does not export a check() function"},
313
+ "exit_code": 1, "skipped": False, "crashed": True, "name": guard_name,
314
+ "lens": get_lens(guard_name),
315
+ "data": {"message": f"Guard crashed: {guard_name} does not export a check() function"},
117
316
  }
118
317
 
119
318
  result = fn(target_dir)
@@ -121,26 +320,85 @@ def run_guard(guard_path: pathlib.Path, target_dir: str) -> dict:
121
320
  # Normalize result shape
122
321
  if not isinstance(result, dict):
123
322
  return {
124
- "exit_code": 0, "skipped": True, "name": guard_name,
125
- "data": {"message": f"Guard {guard_name} returned non-dict: {type(result).__name__}"},
323
+ "exit_code": 1, "skipped": False, "crashed": True, "name": guard_name,
324
+ "lens": get_lens(guard_name),
325
+ "data": {"message": f"Guard crashed: {guard_name} returned non-dict: {type(result).__name__}"},
126
326
  }
127
327
 
128
328
  return {
129
329
  "exit_code": result.get("exit_code", 0),
130
330
  "skipped": bool(result.get("skipped", False)),
331
+ "crashed": False,
332
+ "level": result.get("level"),
131
333
  "name": result.get("name", guard_name),
132
334
  "lens": get_lens(guard_name),
133
335
  "data": result.get("data", result),
134
336
  }
135
337
 
136
338
  except Exception as exc:
339
+ # A guard that cannot run must never approve a commit (fail closed).
137
340
  return {
138
- "exit_code": 0, "skipped": True, "name": guard_name,
341
+ "exit_code": 1, "skipped": False, "crashed": True, "name": guard_name,
139
342
  "lens": get_lens(guard_name),
140
- "data": {"message": f"Guard unavailable: {exc}"},
343
+ "data": {"message": f"Guard crashed: {exc}"},
141
344
  }
142
345
 
143
346
 
347
+ # ── Result levels (sdds/audit-cli) ───────────────────────────────────────────
348
+
349
+ def _is_failed(r: dict) -> bool:
350
+ """A guard that ran and did not pass (skipped guards never fail)."""
351
+ return not r.get("skipped") and r.get("exit_code", 0) != 0
352
+
353
+
354
+ def _is_warning(r: dict) -> bool:
355
+ """A failure reported but not blocking: effective level 'warning'."""
356
+ return _is_failed(r) and r.get("level") == "warning"
357
+
358
+
359
+ def _is_blocking(r: dict) -> bool:
360
+ """A failure that fails the run."""
361
+ return _is_failed(r) and r.get("level") != "warning"
362
+
363
+
364
+ def apply_levels(results: list[dict], severities: dict | None = None) -> list[dict]:
365
+ """
366
+ Set each result's effective level: severities[name] (or [file stem]) from
367
+ .clckernel.yaml, else the guard's own `level` ('warning' only), else
368
+ 'error'. A crash is always 'error' (fail closed).
369
+ """
370
+ severities = severities or {}
371
+ for result in results:
372
+ level = "warning" if result.get("level") == "warning" else "error"
373
+ name, stem = result.get("name"), result.get("stem")
374
+ if isinstance(name, str) and name in severities:
375
+ level = severities[name]
376
+ elif isinstance(stem, str) and stem in severities:
377
+ level = severities[stem]
378
+ result["level"] = "error" if result.get("crashed") else level
379
+ return results
380
+
381
+
382
+ def _status_of(result: dict) -> tuple:
383
+ if result.get("skipped"):
384
+ return "⏭️", "SKIPPED"
385
+ if not _is_failed(result):
386
+ return "✅", "PASSED"
387
+ return ("⚠️", "WARNING") if _is_warning(result) else ("❌", "FAILED")
388
+
389
+
390
+ def _verdict_of(results: list[dict]) -> str:
391
+ """FAILED on a blocking failure, PASSED_WITH_WARNINGS on warnings only."""
392
+ if any(_is_blocking(r) for r in results):
393
+ return "FAILED"
394
+ return "PASSED_WITH_WARNINGS" if any(_is_warning(r) for r in results) else "PASSED"
395
+
396
+
397
+ def _message(result: dict, default: str = "") -> str:
398
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
399
+ return data.get("message") or default
400
+
401
+
144
402
  # ── Report Generation ────────────────────────────────────────────────────────
145
403
 
146
404
  def _format_guard_section(name: str, result: dict) -> str:
@@ -148,17 +406,11 @@ def _format_guard_section(name: str, result: dict) -> str:
148
406
  lines: list[str] = []
149
407
  skipped = result.get("skipped", False)
150
408
  exit_code = result.get("exit_code", 0)
151
- data = result.get("data", {})
152
-
153
- if skipped:
154
- icon, status = "\u23ed\ufe0f", "SKIPPED"
155
- elif exit_code == 0:
156
- icon, status = "\u2705", "PASSED"
157
- else:
158
- icon, status = "\u274c", "FAILED"
409
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
410
+ icon, status = _status_of(result)
159
411
 
160
412
  title = name.replace("_", " ").title()
161
- lines.append(f"### {icon} {title} \u2014 {status}")
413
+ lines.append(f"### {icon} {title} — {status}")
162
414
  lines.append("")
163
415
 
164
416
  if skipped:
@@ -169,23 +421,13 @@ def _format_guard_section(name: str, result: dict) -> str:
169
421
  lines.append(f"> {data.get('message', 'Issues found')}")
170
422
  lines.append("")
171
423
 
172
- # Format violations or findings
173
- violations = data.get("violations") or data.get("findings") or {}
174
- if isinstance(violations, dict):
175
- for filepath, items in violations.items():
176
- if not isinstance(items, list):
177
- continue
178
- for item in items:
179
- loc = f" (line {item.get('lineNo', '')})" if item.get("lineNo") else ""
180
- label = item.get("label") or item.get("rule") or item.get("module") or ""
181
- msg = item.get("reason") or item.get("message") or ""
182
- lines.append(f"- `{filepath}`{loc}: {f'[{label}] ' if label else ''}{msg}")
424
+ lines.extend(_violation_lines(data))
183
425
 
184
426
  # Handle list-style data (e.g. scope unauthorized files)
185
427
  for key in ("unrelated", "warnings", "authorized"):
186
428
  items = data.get(key, [])
187
429
  if isinstance(items, list) and items:
188
- icon_map = {"unrelated": "\u274c", "warnings": "\u26a0\ufe0f", "authorized": "\u2705"}
430
+ icon_map = {"unrelated": "❌", "warnings": "⚠️", "authorized": "✅"}
189
431
  label_map = {"unrelated": "Not in SDD scope", "warnings": "Related warnings", "authorized": "Authorized"}
190
432
  lines.append(f"\n{icon_map.get(key, '')} **{label_map.get(key, key)}:**")
191
433
  for f in items:
@@ -198,7 +440,8 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
198
440
  """Generate the 05-audit-report.md from aggregated guard results grouped by 4 RDD lenses."""
199
441
  task_name = sdd_dir.name
200
442
  passed = sum(1 for r in results if not r.get("skipped") and r.get("exit_code") == 0)
201
- failed = sum(1 for r in results if not r.get("skipped") and r.get("exit_code") != 0)
443
+ failed = sum(1 for r in results if _is_blocking(r))
444
+ warnings = [r for r in results if _is_warning(r)]
202
445
  skipped = sum(1 for r in results if r.get("skipped"))
203
446
  total = len(results)
204
447
 
@@ -206,7 +449,9 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
206
449
  sections.append(f"# Code Audit Report: {task_name}")
207
450
  sections.append("")
208
451
  sections.append("> **Review-Driven Development (RDD) Report:**")
209
- sections.append("> Segmented across the 4 Canonical Review Lenses (Correctness, Security, Architecture, Performance).")
452
+ sections.append("> Results grouped into 4 lenses (Correctness, Security, Architecture, Performance).")
453
+ sections.append("")
454
+ sections.append(f"**Verdict:** {_verdict_of(results)}")
210
455
  sections.append("")
211
456
  sections.append("---")
212
457
  sections.append("")
@@ -217,12 +462,23 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
217
462
  sections.append(f"| Total guards | {total} |")
218
463
  sections.append(f"| Passed | {passed} |")
219
464
  sections.append(f"| Failed | {failed} |")
465
+ sections.append(f"| Warnings | {len(warnings)} |")
220
466
  sections.append(f"| Skipped | {skipped} |")
221
467
  sections.append("")
222
468
  sections.append("---")
223
469
  sections.append("")
224
470
 
225
- lenses = ["correctness", "security", "architecture", "performance"]
471
+ if warnings:
472
+ sections.append("## ⚠️ Warnings")
473
+ sections.append("")
474
+ sections.append("_Failed, but not blocking (level `warning`)._")
475
+ sections.append("")
476
+ for r in warnings:
477
+ sections.append(f"- **{r.get('name', 'unknown')}** [{str(r.get('lens', 'architecture')).upper()}]: {_message(r, 'Issues found')}")
478
+ sections.append("")
479
+ sections.append("---")
480
+ sections.append("")
481
+
226
482
  headers = {
227
483
  "correctness": "## 🎯 Correctness Lens",
228
484
  "security": "## 🛡️ Security Lens",
@@ -230,7 +486,7 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
230
486
  "performance": "## ⚡ Performance Lens",
231
487
  }
232
488
 
233
- for lens in lenses:
489
+ for lens in LENSES:
234
490
  sections.append(headers[lens])
235
491
  sections.append("")
236
492
  lens_results = [r for r in results if r.get("lens") == lens]
@@ -247,25 +503,22 @@ def generate_report(results: list[dict], sdd_dir: pathlib.Path) -> str:
247
503
 
248
504
 
249
505
  def generate_receipt(results: list[dict], target_dir: str) -> dict:
250
- """Generate an immutable machine-readable audit receipt (audit-receipt.json)."""
506
+ """
507
+ Generate an immutable machine-readable audit receipt (audit-receipt.json).
508
+ verdict: FAILED | PASSED_WITH_WARNINGS | PASSED; `failed` counts blocking
509
+ failures, `warnings` the non-blocking ones (listed in `warnings`).
510
+ """
251
511
  import datetime
252
512
 
253
- lenses = ["correctness", "security", "architecture", "performance"]
254
513
  lens_summary = {}
255
- has_failure = False
256
-
257
- for lens in lenses:
514
+ for lens in LENSES:
258
515
  lens_results = [r for r in results if r.get("lens") == lens]
259
- passed = sum(1 for r in lens_results if not r.get("skipped") and r.get("exit_code") == 0)
260
- failed = sum(1 for r in lens_results if not r.get("skipped") and r.get("exit_code") != 0)
261
- skipped = sum(1 for r in lens_results if r.get("skipped"))
262
- if failed > 0:
263
- has_failure = True
264
516
  lens_summary[lens] = {
265
- "status": "FAILED" if failed > 0 else "PASSED",
266
- "passed": passed,
267
- "failed": failed,
268
- "skipped": skipped,
517
+ "status": _verdict_of(lens_results),
518
+ "passed": sum(1 for r in lens_results if not r.get("skipped") and r.get("exit_code") == 0),
519
+ "failed": sum(1 for r in lens_results if _is_blocking(r)),
520
+ "warnings": sum(1 for r in lens_results if _is_warning(r)),
521
+ "skipped": sum(1 for r in lens_results if r.get("skipped")),
269
522
  }
270
523
 
271
524
  commit_hash = "UNKNOWN"
@@ -287,8 +540,12 @@ def generate_receipt(results: list[dict], target_dir: str) -> dict:
287
540
  "contract": "clckernel.audit-receipt/v1",
288
541
  "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
289
542
  "commitHash": commit_hash,
290
- "verdict": "FAILED" if has_failure else "PASSED",
543
+ "verdict": _verdict_of(results),
291
544
  "lenses": lens_summary,
545
+ "warnings": [
546
+ {"name": r.get("name", "unknown"), "lens": r.get("lens", "architecture"), "message": _message(r)}
547
+ for r in results if _is_warning(r)
548
+ ],
292
549
  "generator": "clckernel",
293
550
  "gentle_ai": {
294
551
  "compatible": True,
@@ -300,13 +557,16 @@ def generate_receipt(results: list[dict], target_dir: str) -> dict:
300
557
 
301
558
  # ── Console Summary ──────────────────────────────────────────────────────────
302
559
 
303
- def print_summary(results: list[dict], report_path: pathlib.Path, receipt_path: pathlib.Path | None = None) -> None:
304
- """Print an educational summary to the terminal."""
560
+ def print_summary(results: list[dict], report_path: pathlib.Path | None = None,
561
+ receipt_path: pathlib.Path | None = None) -> None:
562
+ """
563
+ Print the 4-lens summary. report_path/receipt_path: where they were
564
+ written, or None when no --change was given (then a hint says how).
565
+ """
305
566
  print("\n" + "=" * 65)
306
567
  print(" CLC KERNEL — 4-LENS RDD AUDIT SUMMARY")
307
568
  print("=" * 65 + "\n")
308
569
 
309
- lenses = ["correctness", "security", "architecture", "performance"]
310
570
  labels = {
311
571
  "correctness": "🎯 CORRECTNESS LENS",
312
572
  "security": "🛡️ SECURITY LENS",
@@ -314,86 +574,204 @@ def print_summary(results: list[dict], report_path: pathlib.Path, receipt_path:
314
574
  "performance": "⚡ PERFORMANCE LENS",
315
575
  }
316
576
 
317
- for lens in lenses:
577
+ for lens in LENSES:
318
578
  lens_results = [r for r in results if r.get("lens") == lens]
319
579
  print(f" {labels[lens]}:")
320
580
  if not lens_results:
321
581
  print(" — No guards active for this lens")
322
582
  else:
323
583
  for result in lens_results:
324
- skipped = result.get("skipped", False)
325
584
  exit_code = result.get("exit_code", 0)
326
585
  name = result.get("name", "unknown").replace("_", " ")
327
- msg = result.get("data", {}).get("message", "Passed" if exit_code == 0 else "Failed")
328
- icon = "⏭️" if skipped else ("✅" if exit_code == 0 else "❌")
586
+ msg = _message(result, "Passed" if exit_code == 0 else "Failed")
587
+ icon, _ = _status_of(result)
329
588
  print(f" {icon} {name}: {msg}")
330
589
  print("")
331
590
 
332
- print(f"📄 Full report: {report_path}")
333
- if receipt_path:
334
- print(f"🧾 Audit receipt: {receipt_path}")
591
+ print(f"Verdict: {_verdict_of(results)}")
592
+ if report_path:
593
+ print(f"📄 Full report: {report_path}")
594
+ if receipt_path:
595
+ print(f"🧾 Audit receipt: {receipt_path}")
596
+ else:
597
+ print("No report written: pass --change <name> to write sdds/<name>/05-audit-report.md and audit-receipt.json.")
335
598
  print("=" * 65 + "\n")
336
599
 
337
600
 
338
601
  # ── Main ─────────────────────────────────────────────────────────────────────
339
602
 
340
- def main(target_dir: str | None = None) -> int:
341
- """Run all discovered guards and produce the audit report."""
603
+ def _violation_lines(data: dict) -> list[str]:
604
+ """One bullet per violation (or finding) of a failed guard."""
605
+ lines: list[str] = []
606
+ violations = data.get("violations") or data.get("findings") or {}
607
+ if isinstance(violations, list):
608
+ for item in violations:
609
+ if isinstance(item, dict):
610
+ where = f"`{item['file']}`: " if item.get("file") else ""
611
+ lines.append(f"- {where}{item.get('message') or item.get('reason') or json.dumps(item)}")
612
+ return lines
613
+ if isinstance(violations, dict):
614
+ for filepath, items in violations.items():
615
+ for item in items if isinstance(items, list) else []:
616
+ if not isinstance(item, dict):
617
+ continue
618
+ loc = f" (line {item.get('lineNo')})" if item.get("lineNo") else ""
619
+ label = item.get("label") or item.get("rule") or item.get("module") or ""
620
+ msg = item.get("reason") or item.get("message") or ""
621
+ lines.append(f"- `{filepath}`{loc}: {f'[{label}] ' if label else ''}{msg}")
622
+ return lines
623
+
624
+
625
+ def _run_plan(plan: list[dict], dir_path: str) -> list[dict]:
626
+ """Run every planned guard once, in plan order; each result keeps its file stem."""
627
+ results: list[dict] = []
628
+ for entry in plan:
629
+ if entry["mode"] == "missing":
630
+ result = _missing_guard_result(entry)
631
+ elif entry["mode"] == "module":
632
+ result = run_guard(entry["file"], dir_path)
633
+ else:
634
+ result = run_project_guard(entry["file"], dir_path)
635
+ result["stem"] = pathlib.Path(entry["file"]).stem
636
+ results.append(result)
637
+ return results
638
+
639
+
640
+ def _print_hook_summary(results: list[dict]) -> None:
641
+ """--hook output: one line per guard (ok, skip, WARN, FAIL); a failed one also prints its violations or raw output."""
642
+ for result in results:
643
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
644
+ if result.get("skipped"):
645
+ status = "skip"
646
+ elif not _is_failed(result):
647
+ status = "ok "
648
+ else:
649
+ status = "WARN" if _is_warning(result) else "FAIL"
650
+ print(f"clckernel: {status} {result.get('name', 'unknown')}: {data.get('message', '')}")
651
+ if not _is_failed(result):
652
+ continue
653
+ details = _violation_lines(data) or [l for l in (result.get("output") or "").rstrip().split("\n") if l]
654
+ for line in details:
655
+ print(f" {line}")
656
+
657
+
658
+ def _load_severities(dir_path: str) -> dict:
659
+ """
660
+ `severities` from the project's .clckernel.yaml, read with the sibling
661
+ _config.py; {} when that module is not installed. An invalid config raises.
662
+ """
663
+ tools_dir = str(pathlib.Path(__file__).resolve().parent)
664
+ if not (pathlib.Path(tools_dir) / "_config.py").is_file():
665
+ return {}
666
+ if tools_dir not in sys.path:
667
+ sys.path.insert(0, tools_dir)
668
+ import _config # noqa: E402
669
+ return _config.load_config(dir_path)["config"].get("severities") or {}
670
+
671
+
672
+ def report_names(tools_dir: pathlib.Path) -> tuple[str, str]:
673
+ """
674
+ Report and receipt file names. With both orchestrators installed side by
675
+ side (a mixed stack), each runs its own language's guards and writes its
676
+ own pair (05-audit-report.py.md, audit-receipt.py.json), so the second run
677
+ of `clckernel audit --change` does not overwrite the first. A single
678
+ orchestrator keeps the plain names.
679
+ """
680
+ if (pathlib.Path(tools_dir) / "audit.js").is_file():
681
+ return "05-audit-report.py.md", "audit-receipt.py.json"
682
+ return "05-audit-report.md", "audit-receipt.json"
683
+
684
+
685
+ def main(target_dir: str | None = None, hook: bool = False, change: str | None = None) -> int:
686
+ """
687
+ Run all discovered guards. hook: one line per guard; change: also write the
688
+ report and receipt to sdds/<change>/. Returns 1 when a guard failed with
689
+ level error, else 0.
690
+ """
342
691
  tools_dir = pathlib.Path(__file__).resolve().parent
343
- dir_path = target_dir or os.getcwd()
344
-
345
- sdd_dir = find_active_sdd()
346
- if sdd_dir is None:
347
- # Create a default SDD dir for the report
348
- sdds_dir = REPO_ROOT / "sdds"
349
- sdds_dir.mkdir(parents=True, exist_ok=True)
350
- sdd_dir = sdds_dir / "audit-run"
351
- sdd_dir.mkdir(parents=True, exist_ok=True)
692
+ dir_path = os.path.abspath(target_dir or os.getcwd())
693
+ plan = plan_guards(tools_dir)
352
694
 
353
- print(f"\nRunning CLC Kernel audit for: {sdd_dir.name}\n")
695
+ try:
696
+ severities = _load_severities(dir_path)
697
+ except ValueError as exc: # _config.ConfigError
698
+ print(f"clckernel: {exc}", file=sys.stderr)
699
+ return 1
354
700
 
355
- guard_paths = discover_guards(tools_dir)
701
+ if hook:
702
+ results = apply_levels(_run_plan(plan, dir_path), severities)
703
+ sys.stdout.flush()
704
+ _print_hook_summary(results)
705
+ return 1 if any(_is_blocking(r) for r in results) else 0
356
706
 
357
- if not guard_paths:
707
+ print("\nRunning CLC Kernel audit...\n")
708
+
709
+ if not plan:
358
710
  print("No guard tools found in tools/. Nothing to audit.")
359
711
  return 0
360
712
 
361
- print(
362
- f"Discovered {len(guard_paths)} guard(s): "
363
- + ", ".join(p.stem for p in guard_paths)
364
- + "\n"
365
- )
366
-
367
- results: list[dict] = []
368
- for guard_path in guard_paths:
369
- result = run_guard(guard_path, dir_path)
370
- results.append(result)
713
+ names = [os.path.splitext(e["label"])[0] if e["label"].endswith(OWN_EXT) else e["label"] for e in plan]
714
+ print(f"Discovered {len(names)} guard(s): " + ", ".join(names) + "\n")
371
715
 
372
- # Generate and write report
373
- report_content = generate_report(results, sdd_dir)
374
- report_path = sdd_dir / "05-audit-report.md"
375
- report_path.write_text(report_content, encoding="utf-8")
716
+ results = apply_levels(_run_plan(plan, dir_path), severities)
717
+ sys.stdout.flush()
376
718
 
377
- # Generate and write receipt
378
- receipt = generate_receipt(results, dir_path)
379
- receipt_path = sdd_dir / "audit-receipt.json"
380
- receipt_path.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
719
+ report_path = receipt_path = None
720
+ if change:
721
+ sdd_dir = pathlib.Path(dir_path) / "sdds" / change
722
+ sdd_dir.mkdir(parents=True, exist_ok=True)
723
+ report_name, receipt_name = report_names(tools_dir)
724
+ report_path = sdd_dir / report_name
725
+ report_path.write_text(generate_report(results, sdd_dir), encoding="utf-8")
726
+ receipt_path = sdd_dir / receipt_name
727
+ receipt_path.write_text(json.dumps(generate_receipt(results, dir_path), indent=2) + "\n", encoding="utf-8")
381
728
 
382
- # Print summary
383
729
  print_summary(results, report_path, receipt_path)
384
730
 
385
- # Exit with failure if any guard failed (not skipped)
386
- has_failure = any(
387
- not r.get("skipped") and r.get("exit_code", 0) != 0
388
- for r in results
389
- )
390
- return 1 if has_failure else 0
731
+ return 1 if any(_is_blocking(r) for r in results) else 0
732
+
733
+
734
+ def parse_args(argv: list[str]) -> dict:
735
+ """
736
+ argv: [targetDir] [--hook] [--change <name>] [--staged], in any order.
737
+ Returns {target_dir, hook, change, staged}, or {error} on invalid input.
738
+ """
739
+ out = {"target_dir": None, "hook": False, "change": None, "staged": False}
740
+ i = 0
741
+ while i < len(argv):
742
+ arg = argv[i]
743
+ if arg == "--hook":
744
+ out["hook"] = True
745
+ elif arg == "--staged":
746
+ out["staged"] = True
747
+ elif arg == "--change":
748
+ if i + 1 >= len(argv):
749
+ return {"error": "--change needs a name"}
750
+ i += 1
751
+ name = argv[i]
752
+ if not CHANGE_NAME.match(name):
753
+ return {"error": f"invalid --change name {json.dumps(name)}: use one path segment (letters, digits, ., _, -)"}
754
+ out["change"] = name
755
+ elif arg.startswith("-"):
756
+ return {"error": f"unknown option {arg}"}
757
+ elif out["target_dir"] is not None:
758
+ return {"error": f"unexpected argument {arg}"}
759
+ else:
760
+ out["target_dir"] = arg
761
+ i += 1
762
+ if out["hook"] and out["change"]:
763
+ return {"error": "--hook writes nothing; it cannot be combined with --change"}
764
+ return out
391
765
 
392
766
 
393
767
  if __name__ == "__main__":
768
+ # Unknown input exits 2. Any crash fails closed.
769
+ parsed = parse_args(sys.argv[1:])
770
+ if "error" in parsed:
771
+ print(f"audit.py: {parsed['error']}\n{USAGE}", file=sys.stderr)
772
+ sys.exit(2)
394
773
  try:
395
- target = sys.argv[1] if len(sys.argv) > 1 else None
396
- sys.exit(main(target))
774
+ sys.exit(main(parsed["target_dir"], hook=parsed["hook"], change=parsed["change"]))
397
775
  except Exception:
398
776
  traceback.print_exc()
399
777
  sys.exit(1)