clckernel 1.2.19 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/clckernel_cli/SKILL.md +23 -6
- package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
- package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
- package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
- package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
- package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
- package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
- package/.agents/skills/clckernel_cli/reference/start.md +14 -4
- package/.agents/skills/clckernel_cli/reference/test.md +1 -1
- package/.agents/skills/clckernel_cli/reference/update.md +19 -19
- package/CHANGELOG.md +250 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/deprecated.js +13 -0
- package/package.json +14 -4
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +23 -38
- package/src/adapters/django.js +14 -13
- package/src/adapters/fastapi.js +16 -15
- package/src/adapters/go.js +11 -9
- package/src/adapters/laravel.js +12 -10
- package/src/adapters/nextjs.js +7 -5
- package/src/adapters/rails.js +13 -11
- package/src/adapters/rust.js +11 -9
- package/src/catalog.js +58 -49
- package/src/config.js +38 -240
- package/src/detector.js +243 -44
- package/src/doctor.js +18 -9
- package/src/generator.js +734 -302
- package/src/gitignore.js +60 -14
- package/src/index.js +257 -18
- package/src/io.js +409 -0
- package/src/known-hashes.json +309 -0
- package/src/onboarding.js +15 -0
- package/src/technologies/detector.js +1 -1
- package/src/technologies/guards/_shared.js +1 -1
- package/src/ui.js +233 -40
- package/src/updater.js +271 -38
- package/tools/_changes.js +279 -0
- package/tools/_changes.py +233 -0
- package/tools/_config.js +294 -0
- package/tools/_config.py +271 -0
- package/tools/_yaml.py +489 -0
- package/tools/audit.js +511 -111
- package/tools/audit.py +506 -128
- package/tools/celery_guard.py +13 -35
- package/tools/check_a11y.js +8 -28
- package/tools/check_api_contracts.js +13 -39
- package/tools/check_architecture.js +35 -44
- package/tools/check_architecture.py +172 -105
- package/tools/check_custom.js +108 -61
- package/tools/check_custom.py +108 -271
- package/tools/check_data_files.js +88 -0
- package/tools/check_db_efficiency.py +80 -65
- package/tools/check_hitl.js +112 -69
- package/tools/check_links.js +138 -0
- package/tools/check_migrations.py +143 -94
- package/tools/check_performance.js +35 -44
- package/tools/check_placeholders.js +104 -0
- package/tools/check_responsive.js +8 -28
- package/tools/check_scope.py +40 -31
- package/tools/check_seo.js +8 -28
- package/tools/check_skills.js +158 -0
- package/tools/check_storybook.js +8 -21
- package/tools/check_symlinks.js +40 -22
- package/tools/check_ui_reuse.js +17 -29
- package/tools/docker_guard.py +19 -41
- package/tools/postgres_guard.py +15 -36
- package/tools/redis_guard.py +13 -35
- package/tools/scan_secrets.js +134 -67
- package/tools/scan_secrets.py +163 -72
- package/tools/verify_tdd.js +867 -0
- package/tools/verify_tdd.py +407 -165
- package/tools/yaml.js +404 -132
- package/tools/check_domain_data.js +0 -100
- package/tools/check_test_calendar_sync.js +0 -111
package/src/config.js
CHANGED
|
@@ -1,250 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLC Kernel -
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* CLC Kernel - .clckernel.yaml for install and update.
|
|
3
|
+
* The schema lives in tools/_config.js, which the guards use too: one
|
|
4
|
+
* validator for the kernel and the project. Invalid config -> ConfigError
|
|
5
|
+
* naming the file and the key; never a silent fallback.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
const { parseYaml } = require('../tools/yaml.js');
|
|
8
8
|
const {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
ConfigError,
|
|
10
|
+
CONFIG_NAMES,
|
|
11
|
+
DEPRECATED_KEYS,
|
|
12
|
+
defaultConfig,
|
|
13
|
+
validateConfig,
|
|
14
|
+
parseConfigText,
|
|
15
|
+
loadConfig: loadProjectConfig,
|
|
16
|
+
globMatcher,
|
|
17
|
+
} = require('../tools/_config.js');
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
+
* Parses config text into its raw mapping ({} for an empty document).
|
|
21
|
+
* @param {string} yamlString
|
|
22
|
+
* @param {string} [file] - Config file name, for messages
|
|
23
|
+
* @returns {object}
|
|
24
|
+
* @throws {ConfigError}
|
|
20
25
|
*/
|
|
21
|
-
const
|
|
22
|
-
'research', 'plan', 'test-scenarios', 'hit', 'tdd-red',
|
|
23
|
-
'green', 'audit', 'rdd-review', 'handover', 'close',
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
const ENV_SEVERITIES = 'CLCKERNEL_SEVERITIES';
|
|
27
|
-
const LEGACY_ENV_SEVERITIES = 'CLC_FORGE_SEVERITIES';
|
|
28
|
-
const DEFAULT_SCOPE = '.';
|
|
29
|
-
|
|
30
|
-
class ConfigError extends Error {
|
|
31
|
-
constructor(path, message, allowed) {
|
|
32
|
-
const list = Array.isArray(allowed) ? allowed : allowed != null ? [allowed] : [];
|
|
33
|
-
super(
|
|
34
|
-
`Invalid .clckernel.yml: key "${path}" — ${message}` +
|
|
35
|
-
(list.length ? ` (allowed: ${list.join(', ')})` : '')
|
|
36
|
-
);
|
|
37
|
-
this.name = 'ConfigError';
|
|
38
|
-
this.path = path;
|
|
39
|
-
this.allowed = list;
|
|
40
|
-
this.fix =
|
|
41
|
-
list.length
|
|
42
|
-
? `Set "${path}" to one of: ${list.join(', ')}. ${message}`
|
|
43
|
-
: `Correct the value of "${path}". ${message}`;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function arrOrThrow(raw, path, what) {
|
|
48
|
-
if (!Array.isArray(raw)) throw new ConfigError(path, `expected an array, got ${raw === null ? 'null' : typeof raw}`, what);
|
|
49
|
-
if (!raw.length) throw new ConfigError(path, 'must not be empty', what);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function strItems(arr, base) {
|
|
53
|
-
arr.forEach((v, i) => {
|
|
54
|
-
if (typeof v !== 'string' || v.trim() === '') {
|
|
55
|
-
throw new ConfigError(`${base}[${i}]`, 'each item must be a non-empty string');
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
return arr;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function listGuards() {
|
|
62
|
-
return SAFEGUARD_CATALOG.map((e) => e.name);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function validateConfig(raw) {
|
|
66
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
67
|
-
throw new ConfigError('<root>', 'expected a YAML mapping (object)');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const { active_guards, gate_mode, severities, phases, scope, exclude_paths, layers, rules } = raw;
|
|
71
|
-
|
|
72
|
-
if (active_guards !== undefined) {
|
|
73
|
-
arrOrThrow(active_guards, 'active_guards', 'a list of catalog guard names');
|
|
74
|
-
active_guards.forEach((name, i) => {
|
|
75
|
-
if (typeof name !== 'string' || name.trim() === '') throw new ConfigError(`active_guards[${i}]`, 'each active guard must be a non-empty string');
|
|
76
|
-
if (!applyTo(name)) throw new ConfigError(`active_guards[${i}]`, `unknown guard "${name}"`, listGuards());
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (gate_mode !== undefined && (typeof gate_mode !== 'string' || !VALID_GATE_MODES.includes(gate_mode))) {
|
|
81
|
-
throw new ConfigError('gate_mode', `invalid value "${gate_mode}"`, VALID_GATE_MODES);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (severities !== undefined) {
|
|
85
|
-
if (!severities || typeof severities !== 'object' || Array.isArray(severities)) {
|
|
86
|
-
throw new ConfigError('severities', 'expected an object mapping guard -> severity');
|
|
87
|
-
}
|
|
88
|
-
for (const [name, severity] of Object.entries(severities)) {
|
|
89
|
-
if (!applyTo(name)) throw new ConfigError(`severities.${name}`, `unknown guard "${name}"`, listGuards());
|
|
90
|
-
if (!VALID_SEVERITIES.includes(severity)) throw new ConfigError(`severities.${name}`, `invalid severity "${severity}"`, VALID_SEVERITIES);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (phases !== undefined) {
|
|
95
|
-
arrOrThrow(phases, 'phases', 'the canonical 10-phase workflow names');
|
|
96
|
-
let last = -1;
|
|
97
|
-
phases.forEach((phase, i) => {
|
|
98
|
-
if (typeof phase !== 'string' || phase.trim() === '') throw new ConfigError(`phases[${i}]`, 'each phase must be a non-empty string');
|
|
99
|
-
const idx = HARNESS_PHASES.indexOf(phase);
|
|
100
|
-
if (idx === -1) throw new ConfigError(`phases[${i}]`, `unknown phase "${phase}"`, HARNESS_PHASES);
|
|
101
|
-
if (idx <= last) throw new ConfigError(`phases[${i}]`, `phase "${phase}" is out of order — canonical order is ${HARNESS_PHASES.join(' → ')}`);
|
|
102
|
-
last = idx;
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (scope !== undefined) {
|
|
107
|
-
const items = typeof scope === 'string' ? [scope] : scope;
|
|
108
|
-
arrOrThrow(items, 'scope', 'a glob string or array of glob strings');
|
|
109
|
-
strItems(items, 'scope');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (exclude_paths !== undefined) { arrOrThrow(exclude_paths, 'exclude_paths', 'an array of glob strings'); strItems(exclude_paths, 'exclude_paths'); }
|
|
113
|
-
if (layers !== undefined) { arrOrThrow(layers, 'layers', 'an array of layer name strings'); strItems(layers, 'layers'); }
|
|
114
|
-
|
|
115
|
-
if (rules !== undefined) {
|
|
116
|
-
arrOrThrow(rules, 'rules', 'an array of legacy rule objects');
|
|
117
|
-
const seen = new Set();
|
|
118
|
-
rules.forEach((rule, i) => {
|
|
119
|
-
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) throw new ConfigError(`rules[${i}]`, 'each rule must be an object');
|
|
120
|
-
if (typeof rule.name !== 'string' || rule.name.trim() === '') throw new ConfigError(`rules[${i}]`, 'each rule requires a non-empty "name"');
|
|
121
|
-
if (seen.has(rule.name)) throw new ConfigError(`rules[${i}].name`, `duplicate rule name "${rule.name}"`);
|
|
122
|
-
seen.add(rule.name);
|
|
123
|
-
if (rule.severity !== undefined && !['error', 'warning'].includes(rule.severity)) {
|
|
124
|
-
throw new ConfigError(`rules[${i}].severity`, `invalid severity "${rule.severity}"`, ['error', 'warning']);
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return raw;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const loadConfig = (yamlString) => {
|
|
133
|
-
if (yamlString === undefined || yamlString === null || yamlString.trim() === '') return {};
|
|
134
|
-
let raw;
|
|
135
|
-
try { raw = parseYaml(yamlString); } catch (err) { throw new ConfigError('<root>', `YAML parse error: ${err.message}`); }
|
|
136
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new ConfigError('<root>', 'expected a YAML mapping (object) at the top level');
|
|
137
|
-
return raw;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/** Env override map (tolerant: malformed JSON/non-object ignored). */
|
|
141
|
-
function envSeverities() {
|
|
142
|
-
const raw =
|
|
143
|
-
process.env[ENV_SEVERITIES] ||
|
|
144
|
-
process.env.CLC_KERNEL_SEVERITIES ||
|
|
145
|
-
process.env[LEGACY_ENV_SEVERITIES];
|
|
146
|
-
if (!raw) return {};
|
|
147
|
-
try {
|
|
148
|
-
const parsed = JSON.parse(raw);
|
|
149
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
150
|
-
} catch { /* ignore malformed env override */ }
|
|
151
|
-
return {};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Catalog names among detected.techTools (design §1.3 JOIN rule). */
|
|
155
|
-
function detectedToolNames(detected) {
|
|
156
|
-
const names = new Set();
|
|
157
|
-
if (!detected || !Array.isArray(detected.techTools)) return names;
|
|
158
|
-
for (const tool of detected.techTools) {
|
|
159
|
-
const name = tool && typeof tool === 'object' ? tool.name : tool;
|
|
160
|
-
const entry = applyTo(name);
|
|
161
|
-
if (entry) names.add(entry.name);
|
|
162
|
-
}
|
|
163
|
-
return names;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** True when a catalog row applies to the detected stack. */
|
|
167
|
-
function rowApplies(row, detected, detectedTools) {
|
|
168
|
-
if (row.applies_when === 'always') return true;
|
|
169
|
-
if (!detected || typeof detected !== 'object') return false;
|
|
170
|
-
if (detectedTools && detectedTools.has(row.name)) return true;
|
|
171
|
-
const deps = new Set(
|
|
172
|
-
[detected.projectType, detected.framework, detected.orm]
|
|
173
|
-
.filter((x) => typeof x === 'string')
|
|
174
|
-
.map((x) => x.toLowerCase())
|
|
175
|
-
);
|
|
176
|
-
for (const alt of row.applies_when.split('|')) {
|
|
177
|
-
for (const tok of alt.split(',')) {
|
|
178
|
-
const t = tok.trim();
|
|
179
|
-
if (t && deps.has(t)) return true;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return false;
|
|
183
|
-
}
|
|
26
|
+
const loadConfig = (yamlString, file) => parseConfigText(yamlString, file);
|
|
184
27
|
|
|
185
28
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
29
|
+
* Reads, validates and defaults the config of the project at dir (fresh
|
|
30
|
+
* read: install and update must see the file as it is now).
|
|
31
|
+
* @param {string} dir - Project root
|
|
32
|
+
* @returns {{ file: string|null, path: string|null, config: object, warnings: string[] }}
|
|
33
|
+
* @throws {ConfigError}
|
|
189
34
|
*/
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const validated = validateConfig(raw);
|
|
205
|
-
const gateMode = validated.gate_mode === undefined ? 'hard' : validated.gate_mode;
|
|
206
|
-
const phases = validated.phases === undefined ? HARNESS_PHASES.slice() : validated.phases;
|
|
207
|
-
const detectedTools = detectedToolNames(detected);
|
|
208
|
-
const base = SAFEGUARD_CATALOG.filter((row) => rowApplies(row, detected, detectedTools));
|
|
209
|
-
const active =
|
|
210
|
-
Array.isArray(validated.active_guards) && validated.active_guards.length
|
|
211
|
-
? validated.active_guards
|
|
212
|
-
: base.map((row) => row.name);
|
|
213
|
-
const configSeverities = validated.severities || {};
|
|
214
|
-
const envMap = envSeverities();
|
|
215
|
-
|
|
216
|
-
const effectiveSeverities = {};
|
|
217
|
-
const __catalog = active.map((name) => {
|
|
218
|
-
const entry = applyTo(name);
|
|
219
|
-
const severity = resolveSeverity(name, configSeverities, envMap);
|
|
220
|
-
effectiveSeverities[name] = severity;
|
|
221
|
-
return {
|
|
222
|
-
name,
|
|
223
|
-
checks: entry ? entry.checks : undefined,
|
|
224
|
-
applies_when: entry ? entry.applies_when : undefined,
|
|
225
|
-
severity,
|
|
226
|
-
gate_mode: gateMode === 'advisory' ? 'advisory' : entry ? entry.gate_mode : 'hard',
|
|
227
|
-
file: entry ? entry.file : null,
|
|
228
|
-
manifest: entry ? entry.manifest : false,
|
|
229
|
-
planned: entry ? entry.planned : false,
|
|
230
|
-
config_keys: entry ? entry.config_keys : [],
|
|
231
|
-
};
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
return {
|
|
235
|
-
activeGuards: active,
|
|
236
|
-
phases,
|
|
237
|
-
gateMode,
|
|
238
|
-
effectiveSeverities,
|
|
239
|
-
layers: validated.layers || [],
|
|
240
|
-
scope: validated.scope === undefined ? DEFAULT_SCOPE : validated.scope,
|
|
241
|
-
excludePaths: validated.exclude_paths || [],
|
|
242
|
-
framework: detected && detected.framework ? detected.framework : 'unknown',
|
|
243
|
-
projectType: detected && detected.projectType ? detected.projectType : 'unknown',
|
|
244
|
-
testRunner: detected && detected.testRunner ? detected.testRunner : 'unknown',
|
|
245
|
-
rules: validated.rules || [],
|
|
246
|
-
__catalog,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
module.exports = { loadConfig, validateConfig, resolveConfig, ConfigError, HARNESS_PHASES, ENV_SEVERITIES, DEFAULT_SCOPE };
|
|
35
|
+
function readProjectConfig(dir) {
|
|
36
|
+
return loadProjectConfig(dir, { cache: false });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
ConfigError,
|
|
41
|
+
CONFIG_NAMES,
|
|
42
|
+
DEPRECATED_KEYS,
|
|
43
|
+
defaultConfig,
|
|
44
|
+
loadConfig,
|
|
45
|
+
validateConfig,
|
|
46
|
+
readProjectConfig,
|
|
47
|
+
globMatcher,
|
|
48
|
+
};
|
package/src/detector.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLC
|
|
2
|
+
* CLC Kernel — Universal Auto-Discovery Engine
|
|
3
3
|
* Inspects repository signatures to auto-match the right polyglot Stack Adapter
|
|
4
4
|
* (Next.js, FastAPI, Django, Astro, Rails, Go, Rust, Laravel).
|
|
5
5
|
*/
|
|
@@ -30,58 +30,257 @@ const adapters = [
|
|
|
30
30
|
new LaravelAdapter()
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Builds the project config for a given adapter, exactly as auto-detection
|
|
35
|
+
* does for a matched adapter. A null adapter yields the generic config.
|
|
36
|
+
* Used by autoDetectStack and by the "Elegir otro stack" prompt, so a stack
|
|
37
|
+
* chosen by hand provisions exactly what a detected one would.
|
|
38
|
+
* @param {string} targetDir - Project root
|
|
39
|
+
* @param {object|null} adapter - One of `adapters`, or null for generic
|
|
40
|
+
* @returns {object} Project config
|
|
41
|
+
*/
|
|
42
|
+
function buildStackConfig(targetDir, adapter) {
|
|
43
|
+
// Phase-1 technologies (Celery, Redis, PostgreSQL, Docker)
|
|
44
|
+
const techDetector = new TechnologyDetector();
|
|
45
|
+
const detectedTechs = techDetector.detect(targetDir);
|
|
46
|
+
const techTools = techDetector.getToolsForTech(detectedTechs);
|
|
47
|
+
const gitHooks = fs.existsSync(path.join(targetDir, '.husky')) ? 'husky' : 'githooks';
|
|
48
|
+
|
|
49
|
+
if (!adapter) {
|
|
50
|
+
// No adapter: report the stack as unknown instead of guessing one; the
|
|
51
|
+
// generator installs a generic, stack-agnostic harness.
|
|
52
|
+
return {
|
|
53
|
+
projectType: 'unknown',
|
|
54
|
+
framework: 'unknown',
|
|
55
|
+
styling: 'none',
|
|
56
|
+
uiLibrary: 'none',
|
|
57
|
+
testRunner: 'unknown',
|
|
58
|
+
gitHooks,
|
|
59
|
+
orm: 'none',
|
|
60
|
+
adapter: null,
|
|
61
|
+
techTools
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
projectType: adapter.projectType,
|
|
67
|
+
framework: adapter.name,
|
|
68
|
+
styling: adapter.projectType === 'frontend' ? 'Tailwind CSS v4' : 'N/A',
|
|
69
|
+
uiLibrary: adapter.projectType === 'frontend' ? 'Component Primitives' : 'N/A',
|
|
70
|
+
testRunner: adapter.testRunner,
|
|
71
|
+
gitHooks,
|
|
72
|
+
orm: 'Framework Native',
|
|
73
|
+
adapter,
|
|
74
|
+
techTools
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Ecosystem of each code adapter: one directory gets at most one part per ecosystem. */
|
|
79
|
+
const ECOSYSTEMS = {
|
|
80
|
+
'Next.js (App Router)': 'node',
|
|
81
|
+
'Astro Framework': 'node',
|
|
82
|
+
'FastAPI Framework': 'python',
|
|
83
|
+
'Django Framework': 'python',
|
|
84
|
+
'Ruby on Rails': 'ruby',
|
|
85
|
+
'Go (Golang)': 'go',
|
|
86
|
+
'Rust Engine': 'rust',
|
|
87
|
+
'PHP / Laravel': 'php',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Conventional part locations: a literal dir or `dir/*` (one level below dir). */
|
|
91
|
+
const CONVENTIONAL_PART_DIRS = [
|
|
92
|
+
'frontend', 'backend', 'web', 'api', 'server', 'client', 'apps/*', 'services/*', 'packages/*',
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** Directories never inspected as parts. */
|
|
96
|
+
const SKIPPED_DIRS = new Set(['node_modules', '.git', 'vendor', 'dist', 'build']);
|
|
97
|
+
|
|
98
|
+
/** Maximum depth of a part below the root (`apps/web` is 2). */
|
|
99
|
+
const MAX_PART_DEPTH = 2;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Part dirs go into the hook inside quotes; only plain relative names are
|
|
103
|
+
* accepted so no quoting or expansion can ever be involved.
|
|
104
|
+
*/
|
|
105
|
+
const SAFE_PART_DIR = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
106
|
+
|
|
107
|
+
function readJson(file) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Workspace patterns declared by the root: package.json `workspaces` (array
|
|
117
|
+
* or `{ packages }`) and pnpm-workspace.yaml `packages:` entries.
|
|
118
|
+
* @param {string} targetDir - Project root
|
|
119
|
+
* @returns {string[]}
|
|
120
|
+
*/
|
|
121
|
+
function workspacePatterns(targetDir) {
|
|
122
|
+
const patterns = [];
|
|
123
|
+
const pkg = readJson(path.join(targetDir, 'package.json'));
|
|
124
|
+
const ws = pkg && pkg.workspaces;
|
|
125
|
+
const list = Array.isArray(ws) ? ws : (ws && Array.isArray(ws.packages) ? ws.packages : []);
|
|
126
|
+
patterns.push(...list.filter((p) => typeof p === 'string'));
|
|
127
|
+
|
|
128
|
+
let yaml = '';
|
|
129
|
+
try {
|
|
130
|
+
yaml = fs.readFileSync(path.join(targetDir, 'pnpm-workspace.yaml'), 'utf-8');
|
|
131
|
+
} catch {
|
|
132
|
+
return patterns;
|
|
133
|
+
}
|
|
134
|
+
let inPackages = false;
|
|
135
|
+
for (const raw of yaml.split(/\r?\n/)) {
|
|
136
|
+
const line = raw.replace(/\s+#.*$/, '').replace(/^#.*$/, '');
|
|
137
|
+
if (/^\S/.test(line)) {
|
|
138
|
+
inPackages = /^packages\s*:\s*$/.test(line);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const item = inPackages && line.match(/^\s+-\s+(['"]?)([^'"]+)\1\s*$/);
|
|
142
|
+
if (item) patterns.push(item[2].trim());
|
|
143
|
+
}
|
|
144
|
+
return patterns;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Directories a pattern names: a literal relative dir, or `dir/*` for every
|
|
149
|
+
* subdirectory of dir. Other glob forms (`**`, `{a,b}`, negations) are
|
|
150
|
+
* ignored: there is no glob engine here, on purpose.
|
|
151
|
+
* @param {string} targetDir - Project root
|
|
152
|
+
* @param {string} pattern - Workspace or conventional pattern
|
|
153
|
+
* @returns {string[]} Relative dirs (posix separators)
|
|
154
|
+
*/
|
|
155
|
+
function expandPattern(targetDir, pattern) {
|
|
156
|
+
const clean = pattern.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
157
|
+
const star = clean.match(/^([^*?[\]{}!]+)\/\*$/);
|
|
158
|
+
if (star) {
|
|
159
|
+
const base = star[1];
|
|
160
|
+
let entries;
|
|
161
|
+
try {
|
|
162
|
+
entries = fs.readdirSync(path.join(targetDir, base), { withFileTypes: true });
|
|
163
|
+
} catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
return entries.filter((e) => e.isDirectory()).map((e) => `${base}/${e.name}`);
|
|
167
|
+
}
|
|
168
|
+
return /[*?[\]{}!]/.test(clean) || !clean ? [] : [clean];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when rel is a directory worth inspecting as a part. */
|
|
172
|
+
function isCandidateDir(targetDir, rel) {
|
|
173
|
+
const segments = rel.split('/');
|
|
174
|
+
if (segments.length > MAX_PART_DEPTH || !SAFE_PART_DIR.test(rel)) return false;
|
|
175
|
+
if (segments.some((s) => SKIPPED_DIRS.has(s) || s === '.' || s === '..')) return false;
|
|
176
|
+
try {
|
|
177
|
+
return fs.lstatSync(path.join(targetDir, rel)).isDirectory();
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Code adapters (never Agent-OS) matching dir, at most one per ecosystem, in list order. */
|
|
184
|
+
function codeAdaptersFor(dir) {
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const found = [];
|
|
35
187
|
for (const adapter of adapters) {
|
|
36
|
-
if (adapter.detect(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
188
|
+
if (adapter.projectType === 'agent-os' || !adapter.detect(dir)) continue;
|
|
189
|
+
const ecosystem = ECOSYSTEMS[adapter.name] || adapter.name;
|
|
190
|
+
if (seen.has(ecosystem)) continue;
|
|
191
|
+
seen.add(ecosystem);
|
|
192
|
+
found.push(adapter);
|
|
193
|
+
}
|
|
194
|
+
return found;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Code parts of a repository (sdds/fullstack-monorepo, decision 1): the root,
|
|
199
|
+
* declared workspaces and conventional dirs, each tested against the code
|
|
200
|
+
* adapters. Agent-OS is never a part. Root parts come first, then subdir
|
|
201
|
+
* parts sorted by dir.
|
|
202
|
+
* @param {string} targetDir - Project root
|
|
203
|
+
* @returns {Array<{dir: string, adapter: object}>}
|
|
204
|
+
*/
|
|
205
|
+
function detectParts(targetDir) {
|
|
206
|
+
const parts = codeAdaptersFor(targetDir).map((adapter) => ({ dir: '.', adapter }));
|
|
207
|
+
const dirs = new Set();
|
|
208
|
+
for (const pattern of [...workspacePatterns(targetDir), ...CONVENTIONAL_PART_DIRS]) {
|
|
209
|
+
for (const rel of expandPattern(targetDir, pattern)) {
|
|
210
|
+
if (isCandidateDir(targetDir, rel)) dirs.add(rel);
|
|
53
211
|
}
|
|
54
212
|
}
|
|
213
|
+
for (const rel of [...dirs].sort()) {
|
|
214
|
+
for (const adapter of codeAdaptersFor(path.join(targetDir, rel))) parts.push({ dir: rel, adapter });
|
|
215
|
+
}
|
|
216
|
+
return parts;
|
|
217
|
+
}
|
|
55
218
|
|
|
56
|
-
|
|
219
|
+
/** Technology tools detected in the root and every part dir, deduplicated by name. */
|
|
220
|
+
function partsTechTools(targetDir, parts) {
|
|
57
221
|
const techDetector = new TechnologyDetector();
|
|
58
|
-
const
|
|
59
|
-
const
|
|
222
|
+
const tools = [];
|
|
223
|
+
const names = new Set();
|
|
224
|
+
const dirs = ['.', ...parts.map((p) => p.dir)];
|
|
225
|
+
for (const rel of [...new Set(dirs)]) {
|
|
226
|
+
for (const tool of techDetector.getToolsForTech(techDetector.detect(path.join(targetDir, rel)))) {
|
|
227
|
+
if (names.has(tool.name)) continue;
|
|
228
|
+
names.add(tool.name);
|
|
229
|
+
tools.push(tool);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return tools;
|
|
233
|
+
}
|
|
60
234
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Config of a multi-part repository. `adapter` and `framework` are the first
|
|
237
|
+
* part's, kept for code that reads one stack; `parts` drives provisioning.
|
|
238
|
+
* projectType is 'monorepo' when no part sits at the root, else 'fullstack'.
|
|
239
|
+
* @param {string} targetDir - Project root
|
|
240
|
+
* @param {Array<{dir: string, adapter: object}>} parts - From detectParts
|
|
241
|
+
* @returns {object} Project config
|
|
242
|
+
*/
|
|
243
|
+
function buildPartsConfig(targetDir, parts) {
|
|
244
|
+
const hasFront = parts.some((p) => p.adapter.projectType === 'frontend');
|
|
245
|
+
return {
|
|
246
|
+
projectType: parts.some((p) => p.dir === '.') ? 'fullstack' : 'monorepo',
|
|
247
|
+
// The first part's name, like `adapter`: code that reads one framework
|
|
248
|
+
// keeps working; `parts` lists all of them.
|
|
249
|
+
framework: parts[0].adapter.name,
|
|
250
|
+
styling: hasFront ? 'Tailwind CSS v4' : 'N/A',
|
|
251
|
+
uiLibrary: hasFront ? 'Component Primitives' : 'N/A',
|
|
252
|
+
testRunner: parts.map((p) => `${p.adapter.testRunner} (${p.dir})`).join(', '),
|
|
67
253
|
gitHooks: fs.existsSync(path.join(targetDir, '.husky')) ? 'husky' : 'githooks',
|
|
68
|
-
orm: '
|
|
69
|
-
adapter:
|
|
70
|
-
|
|
254
|
+
orm: 'Framework Native',
|
|
255
|
+
adapter: parts[0].adapter,
|
|
256
|
+
parts: parts.map((p) => ({
|
|
257
|
+
dir: p.dir,
|
|
258
|
+
adapter: p.adapter,
|
|
259
|
+
framework: p.adapter.name,
|
|
260
|
+
projectType: p.adapter.projectType,
|
|
261
|
+
testRunner: p.adapter.testRunner,
|
|
262
|
+
})),
|
|
263
|
+
techTools: partsTechTools(targetDir, parts),
|
|
71
264
|
};
|
|
265
|
+
}
|
|
72
266
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Detects the project config. One code part at the root (or none) keeps the
|
|
269
|
+
* single-stack config exactly as before: adapters are tried in list order,
|
|
270
|
+
* except that a code stack beats Agent-OS (sdds/agent-os-generic, decision 1).
|
|
271
|
+
* Several parts, or a part in a subdir, yield a multi-part config
|
|
272
|
+
* (sdds/fullstack-monorepo).
|
|
273
|
+
* @param {string} targetDir - Project root
|
|
274
|
+
* @returns {object} Project config
|
|
275
|
+
*/
|
|
276
|
+
function autoDetectStack(targetDir) {
|
|
277
|
+
const parts = detectParts(targetDir);
|
|
278
|
+
if (parts.length > 1 || (parts.length === 1 && parts[0].dir !== '.')) {
|
|
279
|
+
return buildPartsConfig(targetDir, parts);
|
|
82
280
|
}
|
|
83
|
-
|
|
84
|
-
|
|
281
|
+
const matched = adapters.filter((a) => a.detect(targetDir));
|
|
282
|
+
const adapter = matched.find((a) => a.projectType !== 'agent-os') || matched[0] || null;
|
|
283
|
+
return buildStackConfig(targetDir, adapter);
|
|
85
284
|
}
|
|
86
285
|
|
|
87
|
-
module.exports = { autoDetectStack };
|
|
286
|
+
module.exports = { autoDetectStack, buildStackConfig, detectParts, adapters };
|
package/src/doctor.js
CHANGED
|
@@ -6,9 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
-
const {
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
/** Parses the file given as argv[1]; the path never touches a shell. */
|
|
12
|
+
const PY_PARSE_SCRIPT = 'import ast, sys\nast.parse(open(sys.argv[1]).read())';
|
|
10
13
|
const { colors } = require('./ui');
|
|
11
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Runs every health check against targetDir and prints the results.
|
|
17
|
+
* @param {string} targetDir - Project to inspect
|
|
18
|
+
* @returns {number} Number of failed checks (0 means healthy)
|
|
19
|
+
*/
|
|
12
20
|
function runDoctor(targetDir) {
|
|
13
21
|
console.log(`\n${colors.bold}${colors.cyan}CLC KERNEL DOCTOR — HEALTH CHECK${colors.reset}\n`);
|
|
14
22
|
console.log(` Target Repository: ${colors.cyan}${targetDir}${colors.reset}\n`);
|
|
@@ -52,14 +60,14 @@ function runDoctor(targetDir) {
|
|
|
52
60
|
|
|
53
61
|
// Parse-test Python guard scripts via ast.parse()
|
|
54
62
|
if (file.endsWith('.py') && file.startsWith('check_')) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
`python3 -c "import ast; ast.parse(open('${toolPath}').read())"`,
|
|
58
|
-
{ stdio: 'pipe' }
|
|
59
|
-
);
|
|
63
|
+
const res = spawnSync('python3', ['-c', PY_PARSE_SCRIPT, toolPath], { encoding: 'utf-8' });
|
|
64
|
+
if (!res.error && res.status === 0) {
|
|
60
65
|
checks.push({ name: `tool/${file} parses`, status: true });
|
|
61
|
-
}
|
|
62
|
-
|
|
66
|
+
} else {
|
|
67
|
+
const detail = res.error
|
|
68
|
+
? res.error.message
|
|
69
|
+
: (res.stderr || '').trim().split('\n').pop() || `python3 exited ${res.status}`;
|
|
70
|
+
checks.push({ name: `tool/${file} parses`, status: false, detail });
|
|
63
71
|
}
|
|
64
72
|
}
|
|
65
73
|
});
|
|
@@ -116,10 +124,11 @@ function runDoctor(targetDir) {
|
|
|
116
124
|
|
|
117
125
|
console.log('\n---------------------------------------------------------------');
|
|
118
126
|
if (passedCount === checks.length) {
|
|
119
|
-
console.log(` ${colors.bgEmerald} HEALTHY ${colors.reset} ${colors.emerald}${colors.bold}
|
|
127
|
+
console.log(` ${colors.bgEmerald} HEALTHY ${colors.reset} ${colors.emerald}${colors.bold}All ${checks.length} doctor checks passed.${colors.reset}\n`);
|
|
120
128
|
} else {
|
|
121
129
|
console.log(` ${colors.amber} ${checks.length - passedCount} issue(s) detected. Run \`npx clckernel\` to repair.${colors.reset}\n`);
|
|
122
130
|
}
|
|
131
|
+
return checks.length - passedCount;
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
module.exports = { runDoctor };
|