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
@@ -0,0 +1,233 @@
1
+ """
2
+ Snapshot helpers shared by the diff-based guards (twin of _changes.js).
3
+
4
+ A guard evaluates exactly the snapshot being verified:
5
+ - "staged": something is staged (a pre-commit) -> the index
6
+ - "last-commit": nothing staged (a manual run) -> HEAD, diffed against HEAD~1
7
+ (a root commit lists every file of HEAD)
8
+ - None: no git repository / git unavailable
9
+ File content always comes from that snapshot, never from the working copy.
10
+
11
+ Files the harness installed itself are listed in tools/.clckernel-managed.json
12
+ (written by `clckernel` install/update). changed_files drops every listed path,
13
+ so no guard evaluates the harness's own code. Without a registry (e.g. the
14
+ clckernel repository, where tools/ is source code) nothing is excluded.
15
+
16
+ The project's `exclude_paths` (.clckernel.yaml) are dropped too, for every
17
+ guard. The config is read through the sibling _config.py, which install ships
18
+ next to this file; an older install without it fails closed when a config file
19
+ appears (run `clckernel update`). An invalid config raises its
20
+ ConfigError: a guard never runs on a config it cannot read.
21
+
22
+ Self-contained: Python 3.9+ standard library only. Git runs with argument
23
+ lists, never through a shell; paths are read NUL-separated (-z), so spaces and
24
+ non-ASCII names survive.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import subprocess
31
+ import sys
32
+ from typing import Callable, Optional
33
+
34
+ BINARY_PROBE_BYTES = 8000
35
+ MANAGED_REGISTRY = "tools/.clckernel-managed.json"
36
+ CONFIG_NAMES = ('.clckernel.yaml', '.clckernel.yml', '.clc-forge.yaml', '.clc-forge.yml')
37
+
38
+
39
+ def project_config(target_dir: Optional[str] = None) -> Optional[dict]:
40
+ """
41
+ The project's validated config (defaults filled in), or None when the
42
+ project has no config file. Deprecation warnings go to stderr once.
43
+
44
+ Raises ConfigError on an invalid config, RuntimeError when the sibling
45
+ _config.py (or _yaml.py) is missing.
46
+ """
47
+ root = str(target_dir or os.getcwd())
48
+ if not any(os.path.exists(os.path.join(root, name)) for name in CONFIG_NAMES):
49
+ return None
50
+ tools_dir = os.path.dirname(os.path.abspath(__file__))
51
+ if tools_dir not in sys.path:
52
+ sys.path.insert(0, tools_dir)
53
+ try:
54
+ import _config
55
+ except ImportError as exc:
56
+ raise RuntimeError(
57
+ f"The project config needs tools/_config.py and tools/_yaml.py ({exc}): "
58
+ "run `clckernel update` so the guards can read it"
59
+ ) from exc
60
+ loaded = _config.load_config(root)
61
+ _config.report_warnings(loaded)
62
+ return loaded["config"]
63
+
64
+
65
+ def glob_matcher(globs) -> Callable[[str], bool]:
66
+ """A predicate for root-relative paths matching one of the globs (always False without globs)."""
67
+ if not globs:
68
+ return lambda rel: False
69
+ import _config
70
+ return _config.glob_matcher(globs)
71
+
72
+
73
+ def _git(cwd: str, *args: str) -> Optional[bytes]:
74
+ """Run git in cwd; stdout bytes on success, None on any failure."""
75
+ try:
76
+ proc = subprocess.run(
77
+ ["git", *args], cwd=cwd,
78
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
79
+ )
80
+ except (OSError, ValueError):
81
+ return None
82
+ return proc.stdout if proc.returncode == 0 else None
83
+
84
+
85
+ def _split_nul(out: bytes) -> list:
86
+ return [p for p in out.decode("utf-8", errors="surrogateescape").split("\0") if p]
87
+
88
+
89
+ def _has_commit(cwd: str, rev: str) -> bool:
90
+ return _git(cwd, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}") is not None
91
+
92
+
93
+ def changed_files(target_dir: Optional[str] = None, extensions=None) -> dict:
94
+ """
95
+ List the files added, copied, modified or renamed in the snapshot.
96
+
97
+ Returns {"mode": "staged" | "last-commit" | None, "files": [...]}, minus
98
+ the registry-listed files and the config's exclude_paths; when extensions
99
+ is given, only paths ending in one of them are kept.
100
+ """
101
+ cwd = str(target_dir or os.getcwd())
102
+ any_staged = _git(cwd, "diff", "--cached", "--name-only", "-z")
103
+ if any_staged is None:
104
+ return {"mode": None, "files": []}
105
+
106
+ if any_staged:
107
+ mode = "staged"
108
+ out = _git(cwd, "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR")
109
+ elif not _has_commit(cwd, "HEAD"):
110
+ # No commits and nothing staged: the index holds nothing to evaluate.
111
+ return {"mode": "staged", "files": []}
112
+ else:
113
+ mode = "last-commit"
114
+ if _has_commit(cwd, "HEAD~1"):
115
+ out = _git(cwd, "diff", "--name-only", "-z", "--diff-filter=ACMR", "HEAD~1", "HEAD")
116
+ else:
117
+ out = _git(cwd, "ls-tree", "-r", "-z", "--name-only", "--full-tree", "HEAD")
118
+ if out is None:
119
+ return {"mode": None, "files": []}
120
+
121
+ managed = _managed_files(cwd, mode)
122
+ config = project_config(cwd)
123
+ excluded = glob_matcher(config["exclude_paths"] if config else [])
124
+ files = [f for f in _split_nul(out) if f not in managed and not excluded(f)]
125
+ if extensions:
126
+ files = [f for f in files if f.endswith(tuple(extensions))]
127
+ return {"mode": mode, "files": files}
128
+
129
+
130
+ def pure_renames(target_dir: Optional[str], mode: Optional[str]) -> set:
131
+ """
132
+ New paths of the PURE renames in the snapshot: files whose content is
133
+ identical to a file removed in the same change (same blob, git R100). The
134
+ pair compared is the one changed_files uses: HEAD -> index when staged,
135
+ HEAD~1 -> HEAD in last-commit mode; a root commit (or an index with no HEAD)
136
+ has no renames. A rename with any content change is not listed.
137
+ """
138
+ cwd = str(target_dir or os.getcwd())
139
+ raw = ("--raw", "-z", "--no-abbrev", "-M", "--diff-filter=R")
140
+ out = None
141
+ if mode == "staged" and _has_commit(cwd, "HEAD"):
142
+ out = _git(cwd, "diff", "--cached", *raw, "HEAD")
143
+ elif mode == "last-commit" and _has_commit(cwd, "HEAD~1"):
144
+ out = _git(cwd, "diff", *raw, "HEAD~1", "HEAD")
145
+ renamed: set = set()
146
+ if out is None:
147
+ return renamed
148
+ # Each record: ":<src mode> <dst mode> <src blob> <dst blob> R<score>" NUL old NUL new NUL.
149
+ parts = _split_nul(out)
150
+ for i in range(0, len(parts) - 2, 3):
151
+ fields = parts[i].lstrip(":").split(" ")
152
+ if len(fields) == 5 and fields[2] == fields[3]:
153
+ renamed.add(parts[i + 2])
154
+ return renamed
155
+
156
+
157
+ def _managed_files(cwd: str, mode: Optional[str]) -> set:
158
+ """
159
+ Paths listed in the harness registry: read from the snapshot being evaluated,
160
+ falling back to the working copy at the repository root. A missing or
161
+ malformed registry excludes nothing.
162
+ """
163
+ text = read_file(cwd, MANAGED_REGISTRY, mode)
164
+ if text is None and mode is not None:
165
+ top = _git(cwd, "rev-parse", "--show-toplevel")
166
+ root = top.decode("utf-8", errors="surrogateescape").strip() if top else cwd
167
+ text = read_file(root, MANAGED_REGISTRY, None)
168
+ if text is None:
169
+ return set()
170
+ try:
171
+ data = json.loads(text)
172
+ except ValueError:
173
+ return set()
174
+ # Older installs wrote a list of paths; current ones {kernelVersion, files: [{path, sha256}]}.
175
+ if isinstance(data, list):
176
+ return {f for f in data if isinstance(f, str)}
177
+ if isinstance(data, dict) and isinstance(data.get("files"), list):
178
+ paths = {f.get("path") for f in data["files"] if isinstance(f, dict)}
179
+ return {p for p in paths if isinstance(p, str)} | {MANAGED_REGISTRY}
180
+ return set()
181
+
182
+
183
+ def _object_name(file: str, mode: str) -> str:
184
+ """Object name of a path in the snapshot (paths are relative to the repo root)."""
185
+ return f":0:{file}" if mode == "staged" else f"HEAD:{file}"
186
+
187
+
188
+ def read_file(target_dir: Optional[str], file: str, mode: Optional[str]) -> Optional[str]:
189
+ """
190
+ Read a file from the snapshot: UTF-8 text, or None when missing or binary.
191
+ With mode None (no git) the working copy is the only source there is.
192
+ """
193
+ cwd = str(target_dir or os.getcwd())
194
+ if mode is None:
195
+ try:
196
+ with open(os.path.join(cwd, file), "rb") as fh:
197
+ data = fh.read()
198
+ except OSError:
199
+ return None
200
+ else:
201
+ data = _git(cwd, "cat-file", "blob", _object_name(file, mode))
202
+ if data is None:
203
+ return None
204
+ if b"\0" in data[:BINARY_PROBE_BYTES]:
205
+ return None
206
+ return data.decode("utf-8", errors="replace")
207
+
208
+
209
+ def exists(target_dir: Optional[str], file: str, mode: Optional[str]) -> bool:
210
+ """True when the path exists in the snapshot (the working copy when mode is None)."""
211
+ cwd = str(target_dir or os.getcwd())
212
+ if mode is None:
213
+ return os.path.exists(os.path.join(cwd, file))
214
+ return _git(cwd, "cat-file", "-e", _object_name(file, mode)) is not None
215
+
216
+
217
+ def list_files(target_dir: Optional[str], mode: Optional[str]) -> list:
218
+ """
219
+ List every file of the snapshot, for whole-repository facts (e.g. a name
220
+ that must be unique, a link target that must exist): the index in staged
221
+ mode, HEAD in last-commit mode. Paths are relative to the repository root
222
+ and sorted; nothing is excluded. With mode None (no git) nothing is listed.
223
+ """
224
+ cwd = str(target_dir or os.getcwd())
225
+ if mode == "staged":
226
+ out = _git(cwd, "ls-files", "-z", "--cached", "--full-name", "--", ":/")
227
+ elif mode == "last-commit":
228
+ out = _git(cwd, "ls-tree", "-r", "-z", "--name-only", "--full-tree", "HEAD")
229
+ else:
230
+ return []
231
+ if out is None:
232
+ return []
233
+ return sorted(set(_split_nul(out)))
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Project configuration shared by install/update and the guards (twin of
3
+ * _config.py): finds .clckernel.yaml in the project root, parses it with
4
+ * tools/yaml.js, validates it and fills in the defaults. The defaults
5
+ * reproduce the behaviour of a project without the key.
6
+ *
7
+ * Schema (every key optional):
8
+ * rules custom regex rules, run by check_custom
9
+ * exclude_paths globs no diff-based guard evaluates (_changes)
10
+ * mirrors.list AI IDE mirrors install creates and check_symlinks verifies
11
+ * (null = each one's built-in list)
12
+ * mirrors.required true: a listed mirror that is missing fails check_symlinks
13
+ * hitl.markers regexes (case-insensitive) that count as a HITL marker
14
+ * (null = the built-in markers)
15
+ * hitl.paths globs of the files check_hitl evaluates (null = built-in)
16
+ * hitl.optOut { key, value }: a file whose frontmatter has key: value is exempt
17
+ * update.exclude globs install/update never write
18
+ * tdd.code extra globs verify_tdd counts as production code
19
+ * tdd.ignore globs verify_tdd never counts as production code
20
+ * severities { <guard name>: warning | error }: the level of a failing
21
+ * guard in the audit orchestrators (warning = reported,
22
+ * not blocking; a crash is always an error)
23
+ *
24
+ * Removed keys (no effect) produce a deprecation warning; unknown top-level
25
+ * keys a warning; an invalid value throws ConfigError naming file and key.
26
+ * Self-contained: Node.js built-ins plus the sibling yaml.js.
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const { parseYaml, globToRegex } = require('./yaml.js');
31
+
32
+ // Lookup order: the canonical name first, then older names kept for compatibility.
33
+ const CONFIG_NAMES = ['.clckernel.yaml', '.clckernel.yml', '.clc-forge.yaml', '.clc-forge.yml'];
34
+
35
+ const KEYS = ['rules', 'exclude_paths', 'mirrors', 'hitl', 'update', 'tdd', 'severities'];
36
+ const LEVELS = ['error', 'warning'];
37
+
38
+ /** Keys earlier releases documented that never had an effect, with the reason. */
39
+ const DEPRECATED_KEYS = {
40
+ active_guards: 'guards are chosen by the stack, the registry and `clckernel update --add-guards`',
41
+ phases: 'the workflow phases are agent instructions in AGENTS.md',
42
+ gate_mode: 'every installed guard blocks the commit when it fails',
43
+ layers: 'layer rules live in the architecture guards',
44
+ scope: 'use exclude_paths to keep paths out of the guards',
45
+ };
46
+
47
+ class ConfigError extends Error {
48
+ /**
49
+ * @param {string} key - Dotted key path, e.g. "mirrors.list[0]" ("<root>" for the document)
50
+ * @param {string} message - What is wrong
51
+ * @param {string} [file] - Config file name
52
+ * @param {string[]} [allowed] - Accepted values, when a closed set
53
+ */
54
+ constructor(key, message, file = CONFIG_NAMES[0], allowed = []) {
55
+ super(`Invalid ${file}: key "${key}" — ${message}` + (allowed.length ? ` (allowed: ${allowed.join(', ')})` : ''));
56
+ this.name = 'ConfigError';
57
+ this.key = key;
58
+ this.path = key;
59
+ this.file = file;
60
+ this.allowed = allowed;
61
+ this.fix = allowed.length
62
+ ? `Set "${key}" in ${file} to one of: ${allowed.join(', ')}.`
63
+ : `Correct "${key}" in ${file}: ${message}.`;
64
+ }
65
+ }
66
+
67
+ function defaultConfig() {
68
+ return {
69
+ rules: [],
70
+ exclude_paths: [],
71
+ mirrors: { list: null, required: false },
72
+ hitl: { markers: null, paths: null, optOut: null },
73
+ update: { exclude: [] },
74
+ tdd: { code: [], ignore: [] },
75
+ severities: {},
76
+ };
77
+ }
78
+
79
+ const isMapping = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
80
+ const typeName = (v) => (v === null ? 'null' : Array.isArray(v) ? 'a list' : typeof v);
81
+
82
+ function stringList(value, key, file, check) {
83
+ if (!Array.isArray(value)) throw new ConfigError(key, `expected a list of strings, got ${typeName(value)}`, file);
84
+ value.forEach((item, i) => {
85
+ if (typeof item !== 'string' || item.trim() === '') {
86
+ throw new ConfigError(`${key}[${i}]`, 'each item must be a non-empty string', file);
87
+ }
88
+ if (check) check(item, `${key}[${i}]`);
89
+ });
90
+ return [...value];
91
+ }
92
+
93
+ /** Reads a nested mapping key, rejecting sub-keys the schema does not know. */
94
+ function section(raw, key, known, file, label = key) {
95
+ const value = raw[key];
96
+ if (value === undefined || value === null) return {};
97
+ if (!isMapping(value)) throw new ConfigError(label, `expected a mapping, got ${typeName(value)}`, file);
98
+ for (const sub of Object.keys(value)) {
99
+ if (!known.includes(sub)) throw new ConfigError(`${label}.${sub}`, 'unknown key', file, known.map((k) => `${label}.${k}`));
100
+ }
101
+ return value;
102
+ }
103
+
104
+ function validateRules(rules, file) {
105
+ if (rules === undefined || rules === null) return [];
106
+ if (!Array.isArray(rules)) throw new ConfigError('rules', `expected a list, got ${typeName(rules)}`, file);
107
+ const seen = new Set();
108
+ rules.forEach((rule, i) => {
109
+ if (!isMapping(rule)) throw new ConfigError(`rules[${i}]`, 'each rule must be a mapping', file);
110
+ if (typeof rule.name !== 'string' || rule.name.trim() === '') {
111
+ throw new ConfigError(`rules[${i}].name`, 'each rule requires a non-empty name', file);
112
+ }
113
+ if (seen.has(rule.name)) throw new ConfigError(`rules[${i}].name`, `duplicate rule name "${rule.name}"`, file);
114
+ seen.add(rule.name);
115
+ if (rule.severity !== undefined && rule.severity !== null && !['error', 'warning'].includes(rule.severity)) {
116
+ throw new ConfigError(`rules[${i}].severity`, `invalid severity "${rule.severity}"`, file, ['error', 'warning']);
117
+ }
118
+ });
119
+ return rules;
120
+ }
121
+
122
+ /** severities: a mapping of guard name -> 'error' | 'warning'. */
123
+ function validateSeverities(value, file) {
124
+ if (value === undefined || value === null) return {};
125
+ if (!isMapping(value)) throw new ConfigError('severities', `expected a mapping of guard name to level, got ${typeName(value)}`, file);
126
+ const out = {};
127
+ for (const [name, level] of Object.entries(value)) {
128
+ if (name.trim() === '') throw new ConfigError('severities', 'guard names must be non-empty', file);
129
+ if (!LEVELS.includes(level)) throw new ConfigError(`severities.${name}`, `invalid level ${JSON.stringify(level)}`, file, LEVELS);
130
+ out[name] = level;
131
+ }
132
+ return out;
133
+ }
134
+
135
+ function checkMirrorPath(item, key, file) {
136
+ const segments = item.split(/[\\/]/);
137
+ if (path.isAbsolute(item) || item.startsWith('/') || segments.includes('..')) {
138
+ throw new ConfigError(key, `"${item}" must be a path inside the project`, file);
139
+ }
140
+ if (path.posix.normalize(item) === 'AGENTS.md') throw new ConfigError(key, 'AGENTS.md is the source, not a mirror', file);
141
+ }
142
+
143
+ function checkRegex(item, key, file) {
144
+ try {
145
+ new RegExp(item, 'i');
146
+ } catch (err) {
147
+ throw new ConfigError(key, `invalid regular expression: ${err.message}`, file);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Validates a parsed config and fills in the defaults.
153
+ * @param {object} raw - Parsed YAML (top-level mapping)
154
+ * @param {string} [file] - Config file name, for messages
155
+ * @returns {{ config: object, warnings: string[] }}
156
+ * @throws {ConfigError}
157
+ */
158
+ function validateConfig(raw, file = CONFIG_NAMES[0]) {
159
+ if (!isMapping(raw)) throw new ConfigError('<root>', 'expected a mapping at the top level', file);
160
+ const config = defaultConfig();
161
+ const warnings = [];
162
+ for (const key of Object.keys(raw)) {
163
+ if (Object.prototype.hasOwnProperty.call(DEPRECATED_KEYS, key)) {
164
+ warnings.push(`${file}: key "${key}" is deprecated and has no effect (${DEPRECATED_KEYS[key]}); remove it`);
165
+ } else if (!KEYS.includes(key)) {
166
+ warnings.push(`${file}: unknown key "${key}" is ignored (known keys: ${KEYS.join(', ')})`);
167
+ }
168
+ }
169
+
170
+ config.rules = validateRules(raw.rules, file);
171
+ if (raw.exclude_paths !== undefined && raw.exclude_paths !== null) {
172
+ config.exclude_paths = stringList(raw.exclude_paths, 'exclude_paths', file);
173
+ }
174
+
175
+ const mirrors = section(raw, 'mirrors', ['list', 'required'], file);
176
+ if (mirrors.list !== undefined && mirrors.list !== null) {
177
+ config.mirrors.list = stringList(mirrors.list, 'mirrors.list', file, (item, key) => checkMirrorPath(item, key, file));
178
+ }
179
+ if (mirrors.required !== undefined && mirrors.required !== null) {
180
+ if (typeof mirrors.required !== 'boolean') throw new ConfigError('mirrors.required', `expected true or false, got ${typeName(mirrors.required)}`, file);
181
+ config.mirrors.required = mirrors.required;
182
+ }
183
+
184
+ const hitl = section(raw, 'hitl', ['markers', 'paths', 'optOut'], file);
185
+ if (hitl.markers !== undefined && hitl.markers !== null) {
186
+ config.hitl.markers = stringList(hitl.markers, 'hitl.markers', file, (item, key) => checkRegex(item, key, file));
187
+ }
188
+ if (hitl.paths !== undefined && hitl.paths !== null) config.hitl.paths = stringList(hitl.paths, 'hitl.paths', file);
189
+ if (hitl.optOut !== undefined && hitl.optOut !== null) {
190
+ const optOut = section(hitl, 'optOut', ['key', 'value'], file, 'hitl.optOut');
191
+ if (typeof optOut.key !== 'string' || optOut.key.trim() === '') {
192
+ throw new ConfigError('hitl.optOut.key', 'expected a non-empty frontmatter key', file);
193
+ }
194
+ if (!['string', 'number', 'boolean'].includes(typeof optOut.value)) {
195
+ throw new ConfigError('hitl.optOut.value', 'expected the frontmatter value that exempts a file', file);
196
+ }
197
+ config.hitl.optOut = { key: optOut.key, value: String(optOut.value) };
198
+ }
199
+
200
+ const update = section(raw, 'update', ['exclude'], file);
201
+ if (update.exclude !== undefined && update.exclude !== null) config.update.exclude = stringList(update.exclude, 'update.exclude', file);
202
+
203
+ const tdd = section(raw, 'tdd', ['code', 'ignore'], file);
204
+ if (tdd.code !== undefined && tdd.code !== null) config.tdd.code = stringList(tdd.code, 'tdd.code', file);
205
+ if (tdd.ignore !== undefined && tdd.ignore !== null) config.tdd.ignore = stringList(tdd.ignore, 'tdd.ignore', file);
206
+
207
+ config.severities = validateSeverities(raw.severities, file);
208
+
209
+ return { config, warnings };
210
+ }
211
+
212
+ /**
213
+ * Parses config text.
214
+ * @returns {object} The top-level mapping ({} for an empty document)
215
+ * @throws {ConfigError}
216
+ */
217
+ function parseConfigText(text, file = CONFIG_NAMES[0]) {
218
+ if (text === undefined || text === null || String(text).trim() === '') return {};
219
+ let raw;
220
+ try {
221
+ raw = parseYaml(String(text));
222
+ } catch (err) {
223
+ throw new ConfigError('<root>', `YAML parse error: ${err.message}`, file);
224
+ }
225
+ if (raw === null || raw === undefined) return {};
226
+ if (!isMapping(raw)) throw new ConfigError('<root>', 'expected a mapping at the top level', file);
227
+ return raw;
228
+ }
229
+
230
+ /** Absolute path of the project's config file, or null. */
231
+ function findConfigFile(dir) {
232
+ const root = dir || process.cwd();
233
+ const found = CONFIG_NAMES.map((name) => path.join(root, name)).find((p) => fs.existsSync(p));
234
+ return found || null;
235
+ }
236
+
237
+ const cache = new Map();
238
+
239
+ /**
240
+ * Loads the config of the project at dir (read from the working tree).
241
+ * @param {string} [dir] - Project root (default: cwd)
242
+ * @param {{ cache?: boolean }} [options] - Reuse the result for dir within this process
243
+ * @returns {{ file: string|null, path: string|null, config: object, warnings: string[] }}
244
+ * @throws {ConfigError}
245
+ */
246
+ function loadConfig(dir, { cache: useCache = true } = {}) {
247
+ const root = path.resolve(dir || process.cwd());
248
+ if (useCache && cache.has(root)) return cache.get(root);
249
+ const configPath = findConfigFile(root);
250
+ let loaded;
251
+ if (!configPath) {
252
+ loaded = { file: null, path: null, config: defaultConfig(), warnings: [] };
253
+ } else {
254
+ const file = path.basename(configPath);
255
+ const { config, warnings } = validateConfig(parseConfigText(fs.readFileSync(configPath, 'utf-8'), file), file);
256
+ loaded = { file, path: configPath, config, warnings };
257
+ }
258
+ if (useCache) cache.set(root, loaded);
259
+ return loaded;
260
+ }
261
+
262
+ /**
263
+ * A predicate telling whether a root-relative POSIX path matches one of the globs.
264
+ * @param {string[]} globs
265
+ * @returns {(rel: string) => boolean}
266
+ */
267
+ function globMatcher(globs) {
268
+ const regexes = (globs || []).map(globToRegex);
269
+ return (rel) => regexes.some((re) => re.test(rel));
270
+ }
271
+
272
+ const reported = new Set();
273
+
274
+ /** Prints each warning once per process, on stderr (guards keep stdout for JSON). */
275
+ function reportWarnings(loaded) {
276
+ for (const warning of loaded.warnings) {
277
+ if (reported.has(warning)) continue;
278
+ reported.add(warning);
279
+ process.stderr.write(`clckernel: warning: ${warning}\n`);
280
+ }
281
+ }
282
+
283
+ module.exports = {
284
+ CONFIG_NAMES,
285
+ DEPRECATED_KEYS,
286
+ ConfigError,
287
+ defaultConfig,
288
+ validateConfig,
289
+ parseConfigText,
290
+ findConfigFile,
291
+ loadConfig,
292
+ globMatcher,
293
+ reportWarnings,
294
+ };