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/src/config.js CHANGED
@@ -1,250 +1,48 @@
1
1
  /**
2
- * CLC Kernel - extended .clckernel.yml / .clc-forge.yml loader / validator / resolver.
3
- * Backbone = src/catalog.js. Invalid config -> actionable ConfigError;
4
- * never a silent fallback.
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
- SAFEGUARD_CATALOG,
10
- VALID_SEVERITIES,
11
- VALID_GATE_MODES,
12
- applyTo,
13
- severityFor,
14
- } = require('./catalog.js');
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
- * Canonical 10-phase workflow order (design §3.1). NOTE: catalog VALID_PHASES
18
- * is the per-entry `phase` field, NOT this workflow — the `phases` config key
19
- * validates against this list.
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 HARNESS_PHASES = [
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
- * Generation-time severity precedence: env CLC_FORGE_SEVERITIES > config
187
- * severities > catalog default > 'error'. (Guard-result still wins at runtime
188
- * audit — slice C.)
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 resolveSeverity(name, configSeverities, envMap) {
191
- if (envMap[name] && VALID_SEVERITIES.includes(envMap[name])) return envMap[name];
192
- if (configSeverities[name] && VALID_SEVERITIES.includes(configSeverities[name])) return configSeverities[name];
193
- const cat = severityFor(name);
194
- if (cat && VALID_SEVERITIES.includes(cat)) return cat;
195
- return 'error';
196
- }
197
-
198
- /**
199
- * Effective resolved config (catalog defaults merged with overrides).
200
- * @param {object} raw - validated raw config.
201
- * @param {object} [detected] - autoDetectStack output (optional; {} when unknown).
202
- */
203
- function resolveConfig(raw, detected) {
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 Forge — Universal Auto-Discovery Engine
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
- function autoDetectStack(targetDir) {
34
- // 1. Check registered polyglot adapters
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(targetDir)) {
37
- // Detect Phase-1 technologies (Celery, Redis, PostgreSQL, Docker)
38
- const techDetector = new TechnologyDetector();
39
- const detectedTechs = techDetector.detect(targetDir);
40
- const techTools = techDetector.getToolsForTech(detectedTechs);
41
-
42
- return {
43
- projectType: adapter.projectType,
44
- framework: adapter.name,
45
- styling: adapter.projectType === 'frontend' ? 'Tailwind CSS v4' : 'N/A',
46
- uiLibrary: adapter.projectType === 'frontend' ? 'Component Primitives' : 'N/A',
47
- testRunner: adapter.testRunner,
48
- gitHooks: fs.existsSync(path.join(targetDir, '.husky')) ? 'husky' : 'githooks',
49
- orm: 'Framework Native',
50
- adapter,
51
- techTools
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
- // 2. Fallback detection
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 detectedTechs = techDetector.detect(targetDir);
59
- const techTools = techDetector.getToolsForTech(detectedTechs);
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
- const config = {
62
- projectType: 'unknown',
63
- framework: 'unknown',
64
- styling: 'none',
65
- uiLibrary: 'none',
66
- testRunner: 'unknown',
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: 'none',
69
- adapter: null,
70
- techTools
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
- if (fs.existsSync(path.join(targetDir, 'src', 'app')) || fs.existsSync(path.join(targetDir, 'src', 'pages'))) {
74
- config.projectType = 'frontend';
75
- config.framework = 'Next.js';
76
- config.testRunner = 'Vitest';
77
- config.styling = 'Tailwind CSS v4';
78
- } else {
79
- config.projectType = 'backend';
80
- config.framework = 'FastAPI';
81
- config.testRunner = 'Pytest';
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
- return config;
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 { execSync } = require('child_process');
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
- try {
56
- execSync(
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
- } catch (e) {
62
- checks.push({ name: `tool/${file} parses`, status: false, detail: e.message });
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}Repository meets 100% of the CLC Kernel standard.${colors.reset}\n`);
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 };