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.
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 +250 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/deprecated.js +13 -0
  16. package/package.json +14 -4
  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.js CHANGED
@@ -1,19 +1,67 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Modular Audit Orchestrator — JS
4
- * Discovers check_*.js guard scripts at runtime, runs each with graceful
5
- * degradation, categorizes results into the 4 Canonical RDD Lenses,
6
- * and generates both an English audit report (05-audit-report.md) and
7
- * an immutable, machine-readable audit receipt (audit-receipt.json).
8
- * Self-contained: uses only Node.js built-ins.
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, .js/.py;
8
+ * - every tools/guards/*.js and *.py (project guards).
9
+ * audit.js runs the .js guards; the .py ones belong to audit.py, unless
10
+ * tools/audit.py is not installed: then audit.js runs them too, with python3.
11
+ * A top-level guard of this language that exports a function 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.js [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.js.md / audit-receipt.js.json when
21
+ * tools/audit.py 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,
24
+ * or 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: Node.js
28
+ * built-ins plus the sibling _config.js (optional).
9
29
  */
10
30
 
11
31
  const fs = require('fs');
12
32
  const path = require('path');
33
+ const { spawnSync } = require('child_process');
13
34
 
14
35
  const REPO_ROOT = path.resolve(__dirname, '..');
36
+ const LENSES = ['correctness', 'security', 'architecture', 'performance'];
37
+ const PROJECT_GUARD_RUNNERS = { '.js': process.execPath, '.py': 'python3' };
38
+ const MAX_BUFFER = 64 * 1024 * 1024;
39
+ const OWN_EXT = '.js';
40
+ const SIBLING = { ext: '.py', orchestrator: 'audit.py' };
41
+ const REGISTRY_FILE = '.clckernel-managed.json';
42
+ const USAGE = 'usage: audit.js [targetDir] [--hook] [--change <name>] [--staged]';
43
+ const CHANGE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
15
44
 
16
- // ── 4 Canonical RDD Lenses Mapping ──────────────────────────────────────────
45
+ /**
46
+ * Runs a Python guard that defines check() (module contract) as a process:
47
+ * prints its result as JSON and exits with its exit_code.
48
+ */
49
+ const PY_MODULE_RUNNER = [
50
+ 'import importlib.util, json, os, sys',
51
+ 'p = os.path.abspath(sys.argv[1])',
52
+ 'sys.path.insert(0, os.path.dirname(p))',
53
+ 'spec = importlib.util.spec_from_file_location(os.path.splitext(os.path.basename(p))[0], p)',
54
+ 'm = importlib.util.module_from_spec(spec)',
55
+ 'spec.loader.exec_module(m)',
56
+ 'r = m.check(os.getcwd())',
57
+ 'if not isinstance(r, dict):',
58
+ ' print("check() returned " + type(r).__name__, file=sys.stderr)',
59
+ ' sys.exit(1)',
60
+ 'print(json.dumps(r))',
61
+ 'sys.exit(r.get("exit_code", 0) or 0)',
62
+ ].join('\n');
63
+
64
+ // ── Static name → lens mapping (4 RDD lenses) ──────────────────────────────────────────
17
65
 
18
66
  const LENS_MAP = {
19
67
  // Correctness
@@ -26,6 +74,10 @@ const LENS_MAP = {
26
74
  tsc: 'correctness',
27
75
  lint: 'correctness',
28
76
  ruff: 'correctness',
77
+ check_skills: 'correctness',
78
+ check_links: 'correctness',
79
+ check_placeholders: 'correctness',
80
+ check_data_files: 'correctness',
29
81
  // Security
30
82
  scan_secrets: 'security',
31
83
  docker_guard: 'security',
@@ -38,7 +90,6 @@ const LENS_MAP = {
38
90
  check_ui_reuse: 'architecture',
39
91
  check_symlinks: 'architecture',
40
92
  check_hitl: 'architecture',
41
- check_domain_data: 'architecture',
42
93
  check_migrations: 'architecture',
43
94
  graphify: 'architecture',
44
95
  gentle_ai_review: 'architecture',
@@ -48,7 +99,6 @@ const LENS_MAP = {
48
99
  check_seo: 'performance',
49
100
  check_responsive: 'performance',
50
101
  check_storybook: 'performance',
51
- check_test_calendar_sync: 'performance',
52
102
  check_observability: 'performance',
53
103
  };
54
104
 
@@ -63,42 +113,210 @@ function getLensForGuard(guardName) {
63
113
 
64
114
  // ── Helpers ──────────────────────────────────────────────────────────────────
65
115
 
66
- function getActiveSddDir(baseDir) {
67
- const root = baseDir || REPO_ROOT;
68
- const sddsDir = path.join(root, 'sdds');
69
- if (!fs.existsSync(sddsDir)) {
70
- fs.mkdirSync(sddsDir, { recursive: true });
71
- }
72
- const dirs = fs.readdirSync(sddsDir).filter(f => {
73
- try { return fs.statSync(path.join(sddsDir, f)).isDirectory(); }
74
- catch { return false; }
75
- });
76
- if (dirs.length > 0) {
77
- return path.join(sddsDir, dirs[0]);
116
+ /** True for a top-level guard file name: check_*, scan_*, verify_* or *_guard, .js or .py. */
117
+ function isGuardFile(name) {
118
+ const ext = path.extname(name);
119
+ if (ext !== '.js' && ext !== '.py') return false;
120
+ const stem = path.basename(name, ext);
121
+ return /^(check|scan|verify)_/.test(stem) || stem.endsWith('_guard');
122
+ }
123
+
124
+ /** Regular files of dir (names, sorted); [] when it cannot be read. */
125
+ function regularFiles(dir) {
126
+ try {
127
+ return fs.readdirSync(dir, { withFileTypes: true })
128
+ .filter(entry => entry.isFile())
129
+ .map(entry => entry.name)
130
+ .sort();
131
+ } catch {
132
+ return [];
78
133
  }
79
- const defaultDir = path.join(sddsDir, 'audit-run');
80
- fs.mkdirSync(defaultDir, { recursive: true });
81
- return defaultDir;
82
134
  }
83
135
 
84
136
  /**
85
- * Discover all check_*.js guard scripts in the tools/ directory.
86
- * Returns absolute paths sorted alphabetically for deterministic ordering.
137
+ * Top-level guards of this orchestrator's language (.js) in tools/,
138
+ * alphabetical. Snapshot helpers (_*), yaml.js and the orchestrators never
139
+ * match the guard name rule.
140
+ * @param {string} [toolsDirParam] - The tools/ directory
141
+ * @returns {string[]} Absolute paths
87
142
  */
88
143
  function discoverGuards(toolsDirParam) {
89
144
  const toolsDir = toolsDirParam || __dirname;
90
- if (!fs.existsSync(toolsDir)) return [];
91
- return fs.readdirSync(toolsDir)
92
- .filter(f => (f.startsWith('check_') || f.startsWith('scan_')) && f.endsWith('.js') && f !== path.basename(__filename || 'audit.js'))
93
- .sort()
145
+ return regularFiles(toolsDir)
146
+ .filter(f => isGuardFile(f) && path.extname(f) === OWN_EXT)
94
147
  .map(f => path.join(toolsDir, f));
95
148
  }
96
149
 
150
+ /**
151
+ * Project guards (sdds/domain-agnostic, decision 3): every *.js and *.py file
152
+ * in tools/guards/, alphabetical.
153
+ * @param {string} toolsDir - The project's tools/ directory
154
+ * @returns {string[]} Absolute paths
155
+ */
156
+ function discoverProjectGuards(toolsDir) {
157
+ const dir = path.join(toolsDir, 'guards');
158
+ return regularFiles(dir)
159
+ .filter(name => PROJECT_GUARD_RUNNERS[path.extname(name)])
160
+ .map(name => path.join(dir, name));
161
+ }
162
+
163
+ /**
164
+ * Paths (relative to the project root) the managed registry lists, in
165
+ * either format: a list of paths, or { files: [{ path }] }.
166
+ * @param {string} toolsDir - The tools/ directory
167
+ * @returns {string[]}
168
+ */
169
+ function registeredPaths(toolsDir) {
170
+ let data;
171
+ try {
172
+ data = JSON.parse(fs.readFileSync(path.join(toolsDir, REGISTRY_FILE), 'utf-8'));
173
+ } catch {
174
+ return [];
175
+ }
176
+ const entries = Array.isArray(data) ? data : (data && Array.isArray(data.files) ? data.files.map(f => f && f.path) : []);
177
+ return entries.filter(p => typeof p === 'string');
178
+ }
179
+
180
+ /** True when a JS file follows the module contract (it exports something). */
181
+ function exportsModule(file) {
182
+ try {
183
+ return /\bmodule\.exports\b|^\s*exports\.[\w$]+\s*=/m.test(fs.readFileSync(file, 'utf-8'));
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /** True when a Python file follows the module contract (a top-level check()). */
190
+ function definesCheck(file) {
191
+ try {
192
+ return /^def check\s*\(/m.test(fs.readFileSync(file, 'utf-8'));
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * The guards this orchestrator runs (sdds/unified-discovery), in order:
200
+ * top-level guards, then tools/guards/*. Each entry says how it runs:
201
+ * 'module' — a top-level .js guard that exports a function: in process;
202
+ * 'process' — any other guard of a language this orchestrator owns.
203
+ * Languages owned: .js, plus .py when tools/audit.py is not installed.
204
+ * Registered guards missing on disk come back as 'missing'.
205
+ * @param {string} toolsDir - The project's tools/ directory
206
+ * @returns {Array<{ file: string, label: string, mode: 'module'|'process'|'missing' }>}
207
+ */
208
+ function planGuards(toolsDir) {
209
+ const exts = new Set([OWN_EXT]);
210
+ if (!fs.existsSync(path.join(toolsDir, SIBLING.orchestrator))) exts.add(SIBLING.ext);
211
+ const plan = [];
212
+ const top = regularFiles(toolsDir).filter(f => isGuardFile(f) && exts.has(path.extname(f)));
213
+ for (const name of top) {
214
+ const file = path.join(toolsDir, name);
215
+ const module = path.extname(name) === OWN_EXT && exportsModule(file);
216
+ plan.push({ file, label: name, mode: module ? 'module' : 'process' });
217
+ }
218
+ for (const file of discoverProjectGuards(toolsDir).filter(f => exts.has(path.extname(f)))) {
219
+ plan.push({ file, label: `guards/${path.basename(file)}`, mode: 'process' });
220
+ }
221
+ const present = new Set(top);
222
+ for (const rel of registeredPaths(toolsDir)) {
223
+ const name = rel.startsWith('tools/') ? rel.slice('tools/'.length) : null;
224
+ if (!name || name.includes('/') || !isGuardFile(name) || !exts.has(path.extname(name)) || present.has(name)) continue;
225
+ plan.push({ file: path.join(toolsDir, name), label: name, mode: 'missing' });
226
+ }
227
+ return plan;
228
+ }
229
+
230
+ /** Result for a registered guard that is not on disk (fail closed). */
231
+ function missingGuardResult(entry) {
232
+ const name = path.basename(entry.label, path.extname(entry.label));
233
+ return {
234
+ exitCode: 1, skipped: false, crashed: true, name, lens: getLensForGuard(name),
235
+ data: { message: `Kernel guard missing: tools/${entry.label} is listed in tools/${REGISTRY_FILE} but not on disk; restore it with \`clckernel update\`` },
236
+ };
237
+ }
238
+
239
+ /** The guard's JSON result when its stdout (or its last line) is a JSON object. */
240
+ function parseGuardJson(stdout) {
241
+ const text = String(stdout || '').trim();
242
+ const lines = text.split('\n');
243
+ for (const candidate of [text, lines[lines.length - 1]]) {
244
+ try {
245
+ const value = JSON.parse(candidate);
246
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
247
+ } catch {}
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /** Last non-empty line of a process stream. */
253
+ function lastLine(text) {
254
+ const lines = String(text || '').split('\n').map(l => l.trim()).filter(Boolean);
255
+ return lines.length > 0 ? lines[lines.length - 1] : '';
256
+ }
257
+
258
+ /**
259
+ * Runs one guard as a process with cwd = targetDir, the way a project guard
260
+ * runs. The exit code decides (non-zero fails); a JSON result with the
261
+ * standard shape ({ name, skipped, lens?, data: { message, violations } }) is
262
+ * used for the report, plain output is reported by its last line. A .py guard
263
+ * that defines check() is called through that contract; when it exits non-zero
264
+ * without a JSON result it crashed. Lens: the JSON's, else the guard name's,
265
+ * else architecture; level: the JSON's `level`.
266
+ * @param {string} guardPath - Absolute path to the guard
267
+ * @param {string} targetDir - Project root
268
+ * @returns {object} Normalized result (`output`: the guard's raw output)
269
+ */
270
+ function runProjectGuard(guardPath, targetDir) {
271
+ const ext = path.extname(guardPath);
272
+ const fallbackName = path.basename(guardPath, ext);
273
+ const moduleContract = ext === '.py' && definesCheck(guardPath);
274
+ const args = moduleContract ? ['-c', PY_MODULE_RUNNER, guardPath] : [guardPath];
275
+ const res = spawnSync(PROJECT_GUARD_RUNNERS[ext], args, { cwd: targetDir, encoding: 'utf-8', maxBuffer: MAX_BUFFER });
276
+ const fallbackLens = getLensForGuard(fallbackName);
277
+ if (res.error) {
278
+ return {
279
+ exitCode: 1, skipped: false, crashed: true, name: fallbackName, lens: fallbackLens,
280
+ data: { message: `Guard crashed: ${res.error.message}` },
281
+ };
282
+ }
283
+ const output = `${res.stdout || ''}${res.stderr || ''}`;
284
+ if (typeof res.status !== 'number') {
285
+ return {
286
+ exitCode: 1, skipped: false, crashed: true, name: fallbackName, lens: fallbackLens,
287
+ data: { message: `Guard crashed: killed by ${res.signal || 'a signal'}` },
288
+ output,
289
+ };
290
+ }
291
+ const exitCode = res.status;
292
+ const json = parseGuardJson(res.stdout);
293
+ if (json) {
294
+ const data = json.data && typeof json.data === 'object' ? json.data : json;
295
+ return {
296
+ exitCode,
297
+ skipped: exitCode === 0 && Boolean(json.skipped),
298
+ crashed: false,
299
+ level: json.level,
300
+ name: typeof json.name === 'string' && json.name ? json.name : fallbackName,
301
+ lens: LENSES.includes(json.lens) ? json.lens : fallbackLens,
302
+ data,
303
+ output,
304
+ };
305
+ }
306
+ const line = exitCode === 0 ? lastLine(res.stdout) : (lastLine(res.stderr) || lastLine(res.stdout));
307
+ const crashed = moduleContract && exitCode !== 0;
308
+ return {
309
+ exitCode, skipped: false, crashed, name: fallbackName, lens: fallbackLens,
310
+ data: { message: crashed ? `Guard crashed: ${line || `exited with code ${exitCode}`}` : (line || (exitCode === 0 ? 'Passed' : `Exited with code ${exitCode}`)) },
311
+ output,
312
+ };
313
+ }
314
+
97
315
  /**
98
316
  * Run a single guard module with try/catch.
99
317
  * @param {string} guardPath - Absolute path to the guard script
100
318
  * @param {string} targetDir - Directory to scan
101
- * @returns {object} Result from the guard, or a skip result on failure
319
+ * @returns {object} Result from the guard, or a FAILED result if it cannot run
102
320
  */
103
321
  async function runGuard(guardPath, targetDir) {
104
322
  const guardName = path.basename(guardPath, '.js');
@@ -108,36 +326,110 @@ async function runGuard(guardPath, targetDir) {
108
326
  const fn = typeof mod === 'function' ? mod : Object.values(mod)[0];
109
327
  if (typeof fn !== 'function') {
110
328
  return {
111
- exitCode: 0, skipped: true, name: guardName, lens,
112
- data: { message: `Guard ${guardName} does not export a function` },
329
+ exitCode: 1, skipped: false, crashed: true, name: guardName, lens,
330
+ data: { message: `Guard crashed: ${guardName} does not export a function` },
113
331
  };
114
332
  }
115
333
  const result = await fn(targetDir);
334
+ if (!result || typeof result !== 'object') {
335
+ return {
336
+ exitCode: 1, skipped: false, crashed: true, name: guardName, lens,
337
+ data: { message: `Guard crashed: ${guardName} returned ${result === null ? 'null' : typeof result}` },
338
+ };
339
+ }
116
340
  // Normalize result shape
117
341
  return {
118
342
  exitCode: result.exitCode || 0,
119
343
  skipped: Boolean(result.skipped),
344
+ crashed: false,
345
+ level: result.level,
120
346
  name: result.name || guardName,
121
347
  lens,
122
348
  data: result.data || result,
123
349
  };
124
350
  } catch (e) {
351
+ // A guard that cannot run must never approve a commit (fail closed).
125
352
  return {
126
- exitCode: 0, skipped: true, name: guardName, lens,
127
- data: { message: `Guard unavailable: ${e.message}` },
353
+ exitCode: 1, skipped: false, crashed: true, name: guardName, lens,
354
+ data: { message: `Guard crashed: ${e.message}` },
128
355
  };
129
356
  }
130
357
  }
131
358
 
132
359
  // ── Report Generation ────────────────────────────────────────────────────────
133
360
 
361
+ /** One markdown bullet per violation (or finding) of a failed guard. */
362
+ function violationLines(result) {
363
+ const lines = [];
364
+ const violations = result.data?.violations || result.data?.findings || {};
365
+ if (Array.isArray(violations)) {
366
+ for (const item of violations) {
367
+ lines.push(`- ${item.file ? `\`${item.file}\`: ` : ''}${item.message || item.reason || JSON.stringify(item)}`);
368
+ }
369
+ return lines;
370
+ }
371
+ if (!violations || typeof violations !== 'object') return lines;
372
+ for (const file of Object.keys(violations)) {
373
+ const items = Array.isArray(violations[file]) ? violations[file] : [];
374
+ for (const item of items) {
375
+ const loc = item.lineNo ? ` (line ${item.lineNo})` : '';
376
+ const label = item.label || item.rule || item.module || '';
377
+ const msg = item.message || item.reason || '';
378
+ lines.push(`- \`${file}\`${loc}: ${label ? `[${label}] ` : ''}${msg}`);
379
+ }
380
+ }
381
+ return lines;
382
+ }
383
+
384
+ // ── Result levels (sdds/audit-cli) ───────────────────────────────────────────
385
+
386
+ /** A guard that ran and did not pass (skipped guards never fail). */
387
+ const isFailed = (r) => !r.skipped && r.exitCode !== 0;
388
+ /** A failure reported but not blocking: effective level 'warning'. */
389
+ const isWarning = (r) => isFailed(r) && r.level === 'warning';
390
+ /** A failure that fails the run. */
391
+ const isBlocking = (r) => isFailed(r) && r.level !== 'warning';
392
+
393
+ /**
394
+ * Sets each result's effective level: severities[name] (or [file stem]) from
395
+ * .clckernel.yaml, else the guard's own `level` ('warning' only), else
396
+ * 'error'. A crash is always 'error' (fail closed).
397
+ * @param {object[]} results - Normalized results (with `stem`)
398
+ * @param {Record<string, 'error'|'warning'>} severities
399
+ * @returns {object[]} The same results
400
+ */
401
+ function applyLevels(results, severities = {}) {
402
+ const has = (key) => typeof key === 'string' && Object.prototype.hasOwnProperty.call(severities, key);
403
+ for (const result of results) {
404
+ let level = result.level === 'warning' ? 'warning' : 'error';
405
+ if (has(result.name)) level = severities[result.name];
406
+ else if (has(result.stem)) level = severities[result.stem];
407
+ result.level = result.crashed ? 'error' : level;
408
+ }
409
+ return results;
410
+ }
411
+
412
+ /** [icon, STATUS] of a result. */
413
+ function statusOf(result) {
414
+ if (result.skipped) return ['⏭️', 'SKIPPED'];
415
+ if (!isFailed(result)) return ['✅', 'PASSED'];
416
+ return isWarning(result) ? ['⚠️', 'WARNING'] : ['❌', 'FAILED'];
417
+ }
418
+
419
+ /** Lens/verdict status: FAILED on a blocking failure, PASSED_WITH_WARNINGS on warnings only. */
420
+ function verdictOf(results) {
421
+ if (results.some(isBlocking)) return 'FAILED';
422
+ return results.some(isWarning) ? 'PASSED_WITH_WARNINGS' : 'PASSED';
423
+ }
424
+
425
+ // ── Report Generation ────────────────────────────────────────────────────────
426
+
134
427
  /**
135
428
  * Format a single guard result into a markdown section.
136
429
  */
137
430
  function formatGuardSection(name, result) {
138
431
  const lines = [];
139
- const icon = result.skipped ? '⏭️' : result.exitCode === 0 ? '✅' : '❌';
140
- const status = result.skipped ? 'SKIPPED' : result.exitCode === 0 ? 'PASSED' : 'FAILED';
432
+ const [icon, status] = statusOf(result);
141
433
  const lensBadge = (result.lens || 'architecture').toUpperCase();
142
434
 
143
435
  lines.push(`### ${icon} ${name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} [${lensBadge}] — ${status}`);
@@ -150,25 +442,7 @@ function formatGuardSection(name, result) {
150
442
  } else {
151
443
  lines.push(`> ${result.data?.message || 'Issues found'}`);
152
444
  lines.push('');
153
- const violations = result.data?.violations || result.data?.findings || {};
154
- if (Array.isArray(violations)) {
155
- for (const item of violations) {
156
- lines.push(`- ${item.file ? `\`${item.file}\`: ` : ''}${item.message || item.reason || JSON.stringify(item)}`);
157
- }
158
- } else {
159
- const files = Object.keys(violations);
160
- if (files.length > 0) {
161
- for (const file of files) {
162
- const items = violations[file];
163
- for (const item of items) {
164
- const loc = item.lineNo ? ` (line ${item.lineNo})` : '';
165
- const label = item.label || item.rule || item.module || '';
166
- const msg = item.message || item.reason || '';
167
- lines.push(`- \`${file}\`${loc}: ${label ? `[${label}] ` : ''}${msg}`);
168
- }
169
- }
170
- }
171
- }
445
+ lines.push(...violationLines(result));
172
446
  }
173
447
 
174
448
  return lines.join('\n');
@@ -180,7 +454,8 @@ function formatGuardSection(name, result) {
180
454
  function generateReport(results, sddDir) {
181
455
  const taskName = path.basename(sddDir);
182
456
  const passed = results.filter(r => !r.skipped && r.exitCode === 0).length;
183
- const failed = results.filter(r => !r.skipped && r.exitCode !== 0).length;
457
+ const failed = results.filter(isBlocking).length;
458
+ const warnings = results.filter(isWarning);
184
459
  const skipped = results.filter(r => r.skipped).length;
185
460
  const total = results.length;
186
461
 
@@ -189,7 +464,9 @@ function generateReport(results, sddDir) {
189
464
  sections.push(`# Code Audit Report: ${taskName}`);
190
465
  sections.push('');
191
466
  sections.push('> **Review-Driven Development (RDD) Report:**');
192
- sections.push('> Segmented across the 4 Canonical Review Lenses (Correctness, Security, Architecture, Performance).');
467
+ sections.push('> Results grouped into 4 lenses (Correctness, Security, Architecture, Performance).');
468
+ sections.push('');
469
+ sections.push(`**Verdict:** ${verdictOf(results)}`);
193
470
  sections.push('');
194
471
  sections.push('---');
195
472
  sections.push('');
@@ -200,12 +477,23 @@ function generateReport(results, sddDir) {
200
477
  sections.push(`| Total guards | ${total} |`);
201
478
  sections.push(`| Passed | ${passed} |`);
202
479
  sections.push(`| Failed | ${failed} |`);
480
+ sections.push(`| Warnings | ${warnings.length} |`);
203
481
  sections.push(`| Skipped | ${skipped} |`);
204
482
  sections.push('');
205
483
  sections.push('---');
206
484
  sections.push('');
207
485
 
208
- const lenses = ['correctness', 'security', 'architecture', 'performance'];
486
+ if (warnings.length > 0) {
487
+ sections.push('## ⚠️ Warnings');
488
+ sections.push('');
489
+ sections.push('_Failed, but not blocking (level `warning`)._');
490
+ sections.push('');
491
+ for (const result of warnings) sections.push(`- **${result.name}** [${result.lens.toUpperCase()}]: ${result.data?.message || 'Issues found'}`);
492
+ sections.push('');
493
+ sections.push('---');
494
+ sections.push('');
495
+ }
496
+
209
497
  const lensHeaders = {
210
498
  correctness: '## 🎯 Correctness Lens',
211
499
  security: '## 🛡️ Security Lens',
@@ -213,7 +501,7 @@ function generateReport(results, sddDir) {
213
501
  performance: '## ⚡ Performance Lens',
214
502
  };
215
503
 
216
- for (const lens of lenses) {
504
+ for (const lens of LENSES) {
217
505
  sections.push(lensHeaders[lens]);
218
506
  sections.push('');
219
507
  const lensResults = results.filter(r => r.lens === lens);
@@ -234,24 +522,20 @@ function generateReport(results, sddDir) {
234
522
 
235
523
  /**
236
524
  * Generate an immutable machine-readable audit receipt (audit-receipt.json).
525
+ * verdict: FAILED | PASSED_WITH_WARNINGS | PASSED; `failed` counts blocking
526
+ * failures, `warnings` the non-blocking ones (listed in `warnings`).
237
527
  */
238
528
  function generateReceipt(results, targetDir) {
239
- const lenses = ['correctness', 'security', 'architecture', 'performance'];
240
529
  const lensSummary = {};
241
530
 
242
- let hasFailure = false;
243
- for (const lens of lenses) {
531
+ for (const lens of LENSES) {
244
532
  const lensResults = results.filter(r => r.lens === lens);
245
- const passed = lensResults.filter(r => !r.skipped && r.exitCode === 0).length;
246
- const failed = lensResults.filter(r => !r.skipped && r.exitCode !== 0).length;
247
- const skipped = lensResults.filter(r => r.skipped).length;
248
- if (failed > 0) hasFailure = true;
249
-
250
533
  lensSummary[lens] = {
251
- status: failed > 0 ? 'FAILED' : 'PASSED',
252
- passed,
253
- failed,
254
- skipped,
534
+ status: verdictOf(lensResults),
535
+ passed: lensResults.filter(r => !r.skipped && r.exitCode === 0).length,
536
+ failed: lensResults.filter(isBlocking).length,
537
+ warnings: lensResults.filter(isWarning).length,
538
+ skipped: lensResults.filter(r => r.skipped).length,
255
539
  };
256
540
  }
257
541
 
@@ -273,8 +557,9 @@ function generateReceipt(results, targetDir) {
273
557
  contract: 'clckernel.audit-receipt/v1',
274
558
  timestamp: new Date().toISOString(),
275
559
  commitHash,
276
- verdict: hasFailure ? 'FAILED' : 'PASSED',
560
+ verdict: verdictOf(results),
277
561
  lenses: lensSummary,
562
+ warnings: results.filter(isWarning).map(r => ({ name: r.name, lens: r.lens, message: r.data?.message || '' })),
278
563
  generator: 'clckernel',
279
564
  gentle_ai: {
280
565
  compatible: true,
@@ -286,12 +571,15 @@ function generateReceipt(results, targetDir) {
286
571
 
287
572
  // ── Console Summary ──────────────────────────────────────────────────────────
288
573
 
574
+ /**
575
+ * The 4-lens summary. reportPath/receiptPath: where they were written, or
576
+ * null when no --change was given (then a hint says how to write them).
577
+ */
289
578
  function printSummary(results, reportPath, receiptPath) {
290
579
  console.log('\n=================================================================');
291
580
  console.log(' CLC KERNEL — 4-LENS RDD AUDIT SUMMARY');
292
581
  console.log('=================================================================\n');
293
582
 
294
- const lenses = ['correctness', 'security', 'architecture', 'performance'];
295
583
  const lensLabels = {
296
584
  correctness: '🎯 CORRECTNESS LENS',
297
585
  security: '🛡️ SECURITY LENS',
@@ -299,14 +587,14 @@ function printSummary(results, reportPath, receiptPath) {
299
587
  performance: '⚡ PERFORMANCE LENS',
300
588
  };
301
589
 
302
- for (const lens of lenses) {
590
+ for (const lens of LENSES) {
303
591
  const lensResults = results.filter(r => r.lens === lens);
304
592
  console.log(` ${lensLabels[lens]}:`);
305
593
  if (lensResults.length === 0) {
306
594
  console.log(' — No guards active for this lens');
307
595
  } else {
308
596
  for (const result of lensResults) {
309
- const icon = result.skipped ? '⏭️' : result.exitCode === 0 ? '✅' : '❌';
597
+ const [icon] = statusOf(result);
310
598
  const label = result.name.replace(/_/g, ' ');
311
599
  const msg = result.data?.message || (result.exitCode === 0 ? 'Passed' : 'Failed');
312
600
  console.log(` ${icon} ${label}: ${msg}`);
@@ -315,64 +603,176 @@ function printSummary(results, reportPath, receiptPath) {
315
603
  console.log('');
316
604
  }
317
605
 
318
- console.log(`📄 Full report: ${reportPath}`);
319
- if (receiptPath) {
320
- console.log(`🧾 Audit receipt: ${receiptPath}`);
606
+ console.log(`Verdict: ${verdictOf(results)}`);
607
+ if (reportPath) {
608
+ console.log(`📄 Full report: ${reportPath}`);
609
+ if (receiptPath) console.log(`🧾 Audit receipt: ${receiptPath}`);
610
+ } else {
611
+ console.log('No report written: pass --change <name> to write sdds/<name>/05-audit-report.md and audit-receipt.json.');
321
612
  }
322
613
  console.log('=================================================================\n');
323
614
  }
324
615
 
325
616
  // ── Main ─────────────────────────────────────────────────────────────────────
326
617
 
327
- async function main(targetDir) {
328
- const dir = targetDir ? path.resolve(targetDir) : process.cwd();
329
- const sddDir = getActiveSddDir(dir);
618
+ /** Runs every planned guard once, in plan order; each result keeps its file stem. */
619
+ async function runPlan(plan, dir) {
620
+ const results = [];
621
+ for (const entry of plan) {
622
+ let result;
623
+ if (entry.mode === 'missing') result = missingGuardResult(entry);
624
+ else if (entry.mode === 'module') result = await runGuard(entry.file, dir);
625
+ else result = runProjectGuard(entry.file, dir);
626
+ result.stem = path.basename(entry.file, path.extname(entry.file));
627
+ results.push(result);
628
+ }
629
+ return results;
630
+ }
330
631
 
331
- console.log('\nRunning CLC Kernel audit...\n');
632
+ /**
633
+ * --hook output: one line per guard (ok, skip, WARN, FAIL); a failed guard
634
+ * also prints its violations, or its raw output when it reported none.
635
+ */
636
+ function printHookSummary(results) {
637
+ for (const result of results) {
638
+ const status = result.skipped ? 'skip' : !isFailed(result) ? 'ok ' : isWarning(result) ? 'WARN' : 'FAIL';
639
+ console.log(`clckernel: ${status} ${result.name}: ${result.data?.message || ''}`);
640
+ if (!isFailed(result)) continue;
641
+ const details = violationLines(result);
642
+ const lines = details.length > 0 ? details : String(result.output || '').trimEnd().split('\n').filter(Boolean);
643
+ for (const line of lines) console.log(` ${line}`);
644
+ }
645
+ }
332
646
 
647
+ /**
648
+ * `severities` from the project's .clckernel.yaml, read with the sibling
649
+ * _config.js; {} when that module is not installed. An invalid config throws.
650
+ */
651
+ function loadSeverities(dir) {
652
+ let configModule;
653
+ try {
654
+ configModule = require(path.join(__dirname, '_config.js'));
655
+ } catch (err) {
656
+ if (err && err.code === 'MODULE_NOT_FOUND') return {};
657
+ throw err;
658
+ }
659
+ return configModule.loadConfig(dir).config.severities || {};
660
+ }
661
+
662
+ /**
663
+ * Report and receipt file names. With both orchestrators installed side by
664
+ * side (a mixed stack), each runs its own language's guards and writes its own
665
+ * pair (05-audit-report.js.md, audit-receipt.js.json), so the second run of
666
+ * `clckernel audit --change` does not overwrite the first. A single
667
+ * orchestrator keeps the plain names.
668
+ * @param {string} toolsDir - Directory holding the orchestrators
669
+ * @returns {{ report: string, receipt: string }}
670
+ */
671
+ function reportNames(toolsDir) {
672
+ if (fs.existsSync(path.join(toolsDir, 'audit.py'))) {
673
+ return { report: '05-audit-report.js.md', receipt: 'audit-receipt.js.json' };
674
+ }
675
+ return { report: '05-audit-report.md', receipt: 'audit-receipt.json' };
676
+ }
677
+
678
+ /**
679
+ * @param {string} [targetDir] - Project root (default: cwd)
680
+ * @param {{ hook?: boolean, change?: string|null }} [options] - hook: one line
681
+ * per guard; change: write the report and receipt to sdds/<change>/
682
+ * @returns {Promise<number>} 1 when a guard failed with level error, else 0
683
+ */
684
+ async function main(targetDir, options = {}) {
685
+ const hook = Boolean(options.hook);
686
+ const change = options.change || null;
687
+ const dir = targetDir ? path.resolve(targetDir) : process.cwd();
333
688
  const targetTools = path.join(dir, 'tools');
334
689
  const toolsDir = fs.existsSync(targetTools) ? targetTools : __dirname;
335
- const guardPaths = discoverGuards(toolsDir);
690
+ const plan = planGuards(toolsDir);
336
691
 
337
- if (guardPaths.length === 0) {
338
- console.log('No guard tools found in tools/. Nothing to audit.');
339
- return;
692
+ let severities;
693
+ try {
694
+ severities = loadSeverities(dir);
695
+ } catch (err) {
696
+ console.error(`clckernel: ${err.message}`);
697
+ return 1;
340
698
  }
341
699
 
342
- console.log(`Discovered ${guardPaths.length} guard(s): ${guardPaths.map(p => path.basename(p, '.js')).join(', ')}\n`);
343
-
344
- const results = [];
345
- for (const guardPath of guardPaths) {
346
- const result = await runGuard(guardPath, dir);
347
- results.push(result);
700
+ if (hook) {
701
+ const results = applyLevels(await runPlan(plan, dir), severities);
702
+ printHookSummary(results);
703
+ return results.some(isBlocking) ? 1 : 0;
348
704
  }
349
705
 
350
- // Generate report
351
- const reportContent = generateReport(results, sddDir);
352
- const reportPath = path.join(sddDir, '05-audit-report.md');
353
- fs.writeFileSync(reportPath, reportContent, 'utf-8');
706
+ console.log('\nRunning CLC Kernel audit...\n');
707
+
708
+ if (plan.length === 0) {
709
+ console.log('No guard tools found in tools/. Nothing to audit.');
710
+ return 0;
711
+ }
354
712
 
355
- // Generate receipt
356
- const receipt = generateReceipt(results, dir);
357
- const receiptPath = path.join(sddDir, 'audit-receipt.json');
358
- fs.writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n', 'utf-8');
713
+ const names = plan.map(entry => entry.label.replace(/\.js$/, ''));
714
+ console.log(`Discovered ${names.length} guard(s): ${names.join(', ')}\n`);
715
+
716
+ const results = applyLevels(await runPlan(plan, dir), severities);
717
+
718
+ let reportPath = null;
719
+ let receiptPath = null;
720
+ if (change) {
721
+ const sddDir = path.join(dir, 'sdds', change);
722
+ fs.mkdirSync(sddDir, { recursive: true });
723
+ const names = reportNames(toolsDir);
724
+ reportPath = path.join(sddDir, names.report);
725
+ fs.writeFileSync(reportPath, generateReport(results, sddDir), 'utf-8');
726
+ receiptPath = path.join(sddDir, names.receipt);
727
+ fs.writeFileSync(receiptPath, JSON.stringify(generateReceipt(results, dir), null, 2) + '\n', 'utf-8');
728
+ }
359
729
 
360
- // Print summary
361
730
  printSummary(results, reportPath, receiptPath);
362
731
 
363
- // Exit with failure if any guard failed (not skipped)
364
- const hasFailure = results.some(r => !r.skipped && r.exitCode !== 0);
365
- if (hasFailure) {
366
- process.exit(1);
732
+ return results.some(isBlocking) ? 1 : 0;
733
+ }
734
+
735
+ /**
736
+ * argv: [targetDir] [--hook] [--change <name>] [--staged], in any order.
737
+ * @returns {{ targetDir?: string, hook: boolean, change: string|null, staged: boolean } | { error: string }}
738
+ */
739
+ function parseArgs(argv) {
740
+ const out = { targetDir: undefined, hook: false, change: null, staged: false };
741
+ for (let i = 0; i < argv.length; i += 1) {
742
+ const arg = argv[i];
743
+ if (arg === '--hook') out.hook = true;
744
+ else if (arg === '--staged') out.staged = true;
745
+ else if (arg === '--change') {
746
+ if (i + 1 >= argv.length) return { error: '--change needs a name' };
747
+ const name = argv[i += 1];
748
+ if (!CHANGE_NAME.test(name)) return { error: `invalid --change name ${JSON.stringify(name)}: use one path segment (letters, digits, ., _, -)` };
749
+ out.change = name;
750
+ } else if (arg.startsWith('-')) return { error: `unknown option ${arg}` };
751
+ else if (out.targetDir !== undefined) return { error: `unexpected argument ${arg}` };
752
+ else out.targetDir = arg;
367
753
  }
754
+ if (out.hook && out.change) return { error: '--hook writes nothing; it cannot be combined with --change' };
755
+ return out;
368
756
  }
369
757
 
370
- // Support both require() and direct execution
758
+ // Support both require() and direct execution. Any crash fails closed.
371
759
  if (require.main === module) {
372
- main(process.argv[2]).catch(err => {
373
- console.error(`Audit orchestrator error: ${err.message}`);
374
- process.exit(1);
375
- });
760
+ const args = parseArgs(process.argv.slice(2));
761
+ if (args.error) {
762
+ console.error(`audit.js: ${args.error}\n${USAGE}`);
763
+ process.exitCode = 2;
764
+ } else {
765
+ main(args.targetDir, { hook: args.hook, change: args.change }).then(code => {
766
+ // exitCode, not exit(): a piped stdout is flushed before the process ends.
767
+ process.exitCode = code;
768
+ }, err => {
769
+ console.error(`Audit orchestrator error: ${err && err.message}`);
770
+ process.exit(1);
771
+ });
772
+ }
376
773
  }
377
774
 
378
- module.exports = { main, getActiveSddDir, discoverGuards, runGuard, generateReceipt };
775
+ module.exports = {
776
+ main, parseArgs, reportNames, applyLevels, discoverGuards, discoverProjectGuards, planGuards, isGuardFile,
777
+ runGuard, runProjectGuard, generateReceipt,
778
+ };