clckernel 1.2.18 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +244 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/cli.js +8 -0
- package/package.json +12 -2
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +27 -46
- 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 +737 -309
- package/src/gitignore.js +144 -0
- 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 -36
- 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/updater.js
CHANGED
|
@@ -1,18 +1,198 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLC Kernel — Non-Destructive Harness Updater
|
|
3
|
-
* Safely updates core
|
|
3
|
+
* Safely updates core guard tools, skills, playbooks, and IDE symlinks in an existing
|
|
4
4
|
* project while guaranteeing 100% preservation of:
|
|
5
5
|
* - Local specifications (sdds/)
|
|
6
|
-
* -
|
|
7
|
-
* - Project configuration and custom thresholds (.clckernel.yaml
|
|
6
|
+
* - Project guards (tools/guards/, and guard files in tools/ the kernel does not ship)
|
|
7
|
+
* - Project configuration and custom thresholds (.clckernel.yaml, or an older config name)
|
|
8
8
|
* - Application source code and domain models
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { autoDetectStack } = require('./detector');
|
|
14
|
-
const { generateHarness } = require('./generator');
|
|
14
|
+
const { generateHarness, manifestGuardNames } = require('./generator');
|
|
15
|
+
const { createIO, runWithIO, currentIO, sha256, NEW_SUFFIX } = require('./io');
|
|
16
|
+
const { CONFIG_NAMES, readProjectConfig, globMatcher } = require('./config');
|
|
15
17
|
|
|
18
|
+
// Artifacts written by releases that still used the old project name.
|
|
19
|
+
const SKILLS_REL = path.join('.agents', 'skills');
|
|
20
|
+
const LEGACY_SKILL = 'clc_forge_cli';
|
|
21
|
+
const CURRENT_SKILL = 'clckernel_cli';
|
|
22
|
+
const LEGACY_CURSOR_RULE = '.cursor/rules/clc_forge_context.mdc';
|
|
23
|
+
const LEGACY_RESOLVED_JSON = '.clc-forge.resolved.json';
|
|
24
|
+
const BUNDLED_SKILL_DIR = path.join(__dirname, '..', SKILLS_REL, CURRENT_SKILL);
|
|
25
|
+
|
|
26
|
+
// Guard file names the audit orchestrators discover at the top of tools/
|
|
27
|
+
// (sdds/unified-discovery): check_*, scan_*, verify_* or *_guard, .js or .py.
|
|
28
|
+
const GUARD_FILE_RE = /^tools\/(?:(?:check|scan|verify)_[^/]*|[^/]*_guard)\.(?:js|py)$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read every regular file under `dir` into a map of relative path -> bytes.
|
|
32
|
+
* Anything that is not a directory or a regular file (e.g. a symlink) is
|
|
33
|
+
* recorded under `others`, since it cannot have come from us.
|
|
34
|
+
*/
|
|
35
|
+
function snapshotFiles(dir) {
|
|
36
|
+
const files = new Map();
|
|
37
|
+
const others = [];
|
|
38
|
+
if (!fs.existsSync(dir)) return { files, others };
|
|
39
|
+
const walk = (current) => {
|
|
40
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
41
|
+
const full = path.join(current, entry.name);
|
|
42
|
+
const rel = path.relative(dir, full).split(path.sep).join('/');
|
|
43
|
+
if (entry.isDirectory()) walk(full);
|
|
44
|
+
else if (entry.isFile()) files.set(rel, fs.readFileSync(full));
|
|
45
|
+
else others.push(rel);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(dir);
|
|
49
|
+
return { files, others };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why the legacy skill dir is not provably ours, or null when it is: every
|
|
54
|
+
* file must be byte-identical to the same file in the bundled skill or in the
|
|
55
|
+
* project's clckernel_cli as it was before this update (older releases wrote
|
|
56
|
+
* both directories with the same bytes).
|
|
57
|
+
*/
|
|
58
|
+
function legacySkillMismatch(legacyDir, references) {
|
|
59
|
+
const { files, others } = snapshotFiles(legacyDir);
|
|
60
|
+
if (others.length) return `not a regular file: ${others[0]}`;
|
|
61
|
+
for (const [rel, bytes] of files) {
|
|
62
|
+
const known = references.some((ref) => ref.has(rel) && ref.get(rel).equals(bytes));
|
|
63
|
+
if (!known) {
|
|
64
|
+
const listed = references.some((ref) => ref.has(rel));
|
|
65
|
+
return listed ? `modified file: ${rel}` : `unknown file: ${rel}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Remove artifacts left by releases that used the old name, only when they
|
|
73
|
+
* are provably generated by us. Anything else is kept and reported.
|
|
74
|
+
* @param {string} dir - Project root
|
|
75
|
+
* @param {Map<string, Buffer>} previousSkill - clckernel_cli files before this update
|
|
76
|
+
* @returns {{ removed: string[], kept: {path: string, reason: string}[] }}
|
|
77
|
+
*/
|
|
78
|
+
function cleanupLegacyArtifacts(dir, previousSkill) {
|
|
79
|
+
const removed = [];
|
|
80
|
+
const kept = [];
|
|
81
|
+
const lstat = (p) => {
|
|
82
|
+
try { return fs.lstatSync(p); } catch { return null; }
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const skillDir = path.join(dir, SKILLS_REL, LEGACY_SKILL);
|
|
86
|
+
const skillLabel = `.agents/skills/${LEGACY_SKILL}/`;
|
|
87
|
+
const skillStat = lstat(skillDir);
|
|
88
|
+
if (skillStat) {
|
|
89
|
+
if (!skillStat.isDirectory()) {
|
|
90
|
+
kept.push({ path: skillLabel, reason: 'not a directory' });
|
|
91
|
+
} else {
|
|
92
|
+
const bundled = snapshotFiles(BUNDLED_SKILL_DIR).files;
|
|
93
|
+
const mismatch = legacySkillMismatch(skillDir, [bundled, previousSkill]);
|
|
94
|
+
if (mismatch) {
|
|
95
|
+
kept.push({ path: skillLabel, reason: mismatch });
|
|
96
|
+
} else if (currentIO(dir).remove(skillDir) === 'excluded') {
|
|
97
|
+
kept.push({ path: skillLabel, reason: 'excluded by config (update.exclude)' });
|
|
98
|
+
} else {
|
|
99
|
+
removed.push(skillLabel);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rulePath = path.join(dir, LEGACY_CURSOR_RULE);
|
|
105
|
+
const ruleStat = lstat(rulePath);
|
|
106
|
+
if (ruleStat) {
|
|
107
|
+
const pointsToAgents =
|
|
108
|
+
ruleStat.isSymbolicLink() &&
|
|
109
|
+
path.resolve(path.dirname(rulePath), fs.readlinkSync(rulePath)) === path.join(dir, 'AGENTS.md');
|
|
110
|
+
if (pointsToAgents && currentIO(dir).remove(rulePath) === 'excluded') {
|
|
111
|
+
kept.push({ path: LEGACY_CURSOR_RULE, reason: 'excluded by config (update.exclude)' });
|
|
112
|
+
} else if (pointsToAgents) {
|
|
113
|
+
removed.push(LEGACY_CURSOR_RULE);
|
|
114
|
+
} else {
|
|
115
|
+
kept.push({
|
|
116
|
+
path: LEGACY_CURSOR_RULE,
|
|
117
|
+
reason: ruleStat.isSymbolicLink() ? 'symlink does not point to AGENTS.md' : 'not a symlink to AGENTS.md',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const jsonPath = path.join(dir, LEGACY_RESOLVED_JSON);
|
|
123
|
+
const jsonStat = lstat(jsonPath);
|
|
124
|
+
if (jsonStat) {
|
|
125
|
+
if (jsonStat.isFile() && currentIO(dir).remove(jsonPath) === 'excluded') {
|
|
126
|
+
kept.push({ path: LEGACY_RESOLVED_JSON, reason: 'excluded by config (update.exclude)' });
|
|
127
|
+
} else if (jsonStat.isFile()) {
|
|
128
|
+
removed.push(LEGACY_RESOLVED_JSON);
|
|
129
|
+
} else {
|
|
130
|
+
kept.push({ path: LEGACY_RESOLVED_JSON, reason: 'not a regular file' });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { removed, kept };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Kernel guards this update retired (sdds/unified-discovery, decision 6): a
|
|
139
|
+
* guard file in tools/ that the kernel wrote before — listed in the prior
|
|
140
|
+
* registry, or byte-identical to a version it published — and that this run
|
|
141
|
+
* did not write because the current manifest no longer ships it. Nothing is
|
|
142
|
+
* moved or deleted: the file stays where it is (siblings it requires keep
|
|
143
|
+
* resolving), leaves the registry (rebuilt from what this run wrote), and the
|
|
144
|
+
* audit orchestrators keep running it as a project guard.
|
|
145
|
+
* @param {object} io - The update's io session (after generateHarness)
|
|
146
|
+
* @param {string} dir - Project root
|
|
147
|
+
* @param {object} knownHashes - { path: [sha256] } of published versions
|
|
148
|
+
* @returns {string[]} Root-relative paths, sorted
|
|
149
|
+
*/
|
|
150
|
+
function retiredKernelGuards(io, dir, knownHashes) {
|
|
151
|
+
const plan = io.plan();
|
|
152
|
+
const decided = new Set([
|
|
153
|
+
...io.managedEntries().map((entry) => entry.path),
|
|
154
|
+
...plan.protected.map((entry) => entry.path),
|
|
155
|
+
...plan.excluded,
|
|
156
|
+
]);
|
|
157
|
+
const candidates = new Set([...io.registry.paths, ...Object.keys(knownHashes)]);
|
|
158
|
+
const retired = [];
|
|
159
|
+
for (const rel of candidates) {
|
|
160
|
+
if (!GUARD_FILE_RE.test(rel) || decided.has(rel)) continue;
|
|
161
|
+
const bytes = io.read(path.join(dir, rel));
|
|
162
|
+
if (!bytes) continue;
|
|
163
|
+
const ours = io.registry.paths.includes(rel) || (knownHashes[rel] || []).includes(sha256(bytes));
|
|
164
|
+
if (ours) retired.push(rel);
|
|
165
|
+
}
|
|
166
|
+
return retired.sort();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Checks --add-guards against the stack's manifest.
|
|
171
|
+
* @param {string[]|'all'|undefined} requested - Guard names, or 'all'
|
|
172
|
+
* @param {string[]} offered - Guard names of the stack's manifest
|
|
173
|
+
* @returns {string[]|'all'} The guards to add
|
|
174
|
+
* @throws {Error} When a name is not a guard of this stack
|
|
175
|
+
*/
|
|
176
|
+
function validateAddGuards(requested, offered) {
|
|
177
|
+
if (requested === undefined || requested === null) return [];
|
|
178
|
+
if (requested === 'all') return 'all';
|
|
179
|
+
const unknown = requested.filter((name) => !offered.includes(name));
|
|
180
|
+
if (unknown.length > 0) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Unknown guard(s) for --add-guards: ${unknown.join(', ')}. ` +
|
|
183
|
+
`This stack offers: ${offered.join(', ') || '(none)'}. Nothing was written.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return [...requested];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Refreshes the harness of an installed project. It updates what the project
|
|
191
|
+
* has and never adds guards or mirrors it does not have
|
|
192
|
+
* (sdds/update-no-additions); options.addGuards opts guards in.
|
|
193
|
+
* @param {string} [targetDir] - Project root
|
|
194
|
+
* @param {{ dryRun?: boolean, addGuards?: string[]|'all', knownHashes?: object }} [options]
|
|
195
|
+
*/
|
|
16
196
|
function updateHarness(targetDir = process.cwd(), options = {}) {
|
|
17
197
|
const resolvedDir = path.resolve(targetDir);
|
|
18
198
|
|
|
@@ -20,44 +200,57 @@ function updateHarness(targetDir = process.cwd(), options = {}) {
|
|
|
20
200
|
throw new Error(`Target directory "${resolvedDir}" does not exist.`);
|
|
21
201
|
}
|
|
22
202
|
|
|
203
|
+
// The project config is read before anything is written: an invalid one
|
|
204
|
+
// throws its ConfigError and the update writes nothing (sdds/config-foundation).
|
|
205
|
+
const projectConfig = readProjectConfig(resolvedDir);
|
|
206
|
+
|
|
23
207
|
// 1. Detect Stack
|
|
24
208
|
const detected = autoDetectStack(resolvedDir);
|
|
25
209
|
|
|
210
|
+
// --add-guards is checked before anything is written: an unknown name
|
|
211
|
+
// fails the whole update (sdds/update-no-additions, decision 1).
|
|
212
|
+
const addGuards = validateAddGuards(options.addGuards, manifestGuardNames(resolvedDir, detected));
|
|
213
|
+
|
|
26
214
|
// 2. Identify existing preserved assets
|
|
27
215
|
const preserved = {
|
|
28
216
|
sdds: fs.existsSync(path.join(resolvedDir, 'sdds')),
|
|
29
217
|
customGuards: fs.existsSync(path.join(resolvedDir, 'tools', 'guards')),
|
|
30
|
-
configYaml:
|
|
218
|
+
configYaml: CONFIG_NAMES.some((name) => fs.existsSync(path.join(resolvedDir, name))),
|
|
31
219
|
};
|
|
32
220
|
|
|
33
221
|
// 3. Run harness generation (refreshes tools, hooks, skills, and symlinks)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
}
|
|
222
|
+
// inside one io session (sdds/safe-update): every write follows the
|
|
223
|
+
// ownership rule, and a dry run only records the plan.
|
|
224
|
+
const { loadKnownHashes } = require('./io');
|
|
225
|
+
const knownHashes = options.knownHashes || loadKnownHashes();
|
|
226
|
+
const io = createIO({
|
|
227
|
+
root: resolvedDir,
|
|
228
|
+
dryRun: !!options.dryRun,
|
|
229
|
+
knownHashes,
|
|
230
|
+
mode: 'update',
|
|
231
|
+
exclude: globMatcher(projectConfig.config.update.exclude),
|
|
232
|
+
});
|
|
233
|
+
const { retiredGuards, legacyCleanup } = runWithIO(io, () => {
|
|
234
|
+
// Snapshot the installed skill first: the legacy copy is compared against it.
|
|
235
|
+
const previousSkill = snapshotFiles(path.join(resolvedDir, SKILLS_REL, CURRENT_SKILL)).files;
|
|
236
|
+
generateHarness(resolvedDir, detected, { addGuards, projectConfig });
|
|
237
|
+
const retired = retiredKernelGuards(io, resolvedDir, knownHashes);
|
|
238
|
+
const cleanup = cleanupLegacyArtifacts(resolvedDir, previousSkill);
|
|
239
|
+
const { ensureGitignoreEntries } = require('./gitignore');
|
|
240
|
+
ensureGitignoreEntries(resolvedDir);
|
|
241
|
+
injectUpdateRow(io, resolvedDir);
|
|
242
|
+
return { retiredGuards: retired, legacyCleanup: cleanup };
|
|
243
|
+
});
|
|
56
244
|
|
|
57
245
|
return {
|
|
58
246
|
success: true,
|
|
59
247
|
targetDir: resolvedDir,
|
|
248
|
+
dryRun: io.dryRun,
|
|
60
249
|
preserved,
|
|
250
|
+
configWarnings: projectConfig.warnings,
|
|
251
|
+
legacyCleanup,
|
|
252
|
+
retiredGuards,
|
|
253
|
+
plan: io.plan(),
|
|
61
254
|
refreshed: {
|
|
62
255
|
tools: true,
|
|
63
256
|
skills: true,
|
|
@@ -67,24 +260,66 @@ function updateHarness(targetDir = process.cwd(), options = {}) {
|
|
|
67
260
|
};
|
|
68
261
|
}
|
|
69
262
|
|
|
70
|
-
|
|
71
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Ensures AGENTS.md documents the update command in its command table.
|
|
265
|
+
* Non-destructive: only inject into a file that already carries the CLC
|
|
266
|
+
* Kernel command table, and only when the row is missing (idempotent).
|
|
267
|
+
* A project-customized AGENTS.md without that structure is left untouched.
|
|
268
|
+
*/
|
|
269
|
+
function injectUpdateRow(io, resolvedDir) {
|
|
270
|
+
const agentsPath = path.join(resolvedDir, 'AGENTS.md');
|
|
271
|
+
const current = io.read(agentsPath);
|
|
272
|
+
if (!current) return;
|
|
273
|
+
let agentsContent = current.toString('utf-8');
|
|
274
|
+
if (agentsContent.includes('clckernel update')) return;
|
|
275
|
+
if (agentsContent.includes('| `clckernel start`')) {
|
|
276
|
+
agentsContent = agentsContent.replace(
|
|
277
|
+
'| `clckernel start`',
|
|
278
|
+
'| `clckernel update` | Maintenance | Non-destructive update: run `npx clckernel update [dir]` (`--dry-run` previews, `--add-guards <a,b\\|all>` adds guards); refreshes kernel tools, hook, playbooks & symlinks (preserves SDDs, project guards, edited files & `.clckernel.yaml`). |\n| `clckernel start`'
|
|
279
|
+
);
|
|
280
|
+
io.writeFile(agentsPath, agentsContent);
|
|
281
|
+
} else if (agentsContent.includes('## 3. Universal Command Interface')) {
|
|
282
|
+
agentsContent += '\n| `clckernel update` | Maintenance | Non-destructive update: run `npx clckernel update [dir]` (`--dry-run` previews, `--add-guards <a,b\\|all>` adds guards); refreshes kernel tools, hook, playbooks & symlinks (preserves SDDs, project guards, edited files & `.clckernel.yaml`). |\n';
|
|
283
|
+
io.writeFile(agentsPath, agentsContent);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function runUpdate(targetDir = process.cwd(), options = {}) {
|
|
288
|
+
const { colors, printBanner, printPlan } = require('./ui');
|
|
289
|
+
const dryRun = !!options.dryRun;
|
|
72
290
|
printBanner();
|
|
73
291
|
|
|
74
|
-
console.log(`\n${colors.cyan}🔄 Actualizando CLC Kernel en:${colors.reset} ${targetDir}\n`);
|
|
292
|
+
console.log(`\n${colors.cyan}🔄 Actualizando CLC Kernel en:${colors.reset} ${targetDir}${dryRun ? ' (dry run)' : ''}\n`);
|
|
75
293
|
|
|
76
294
|
try {
|
|
77
|
-
const result = updateHarness(targetDir);
|
|
295
|
+
const result = updateHarness(targetDir, { dryRun, addGuards: options.addGuards });
|
|
78
296
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
297
|
+
for (const warning of result.configWarnings) {
|
|
298
|
+
console.log(`${colors.amber}⚠ ${warning}${colors.reset}`);
|
|
299
|
+
}
|
|
300
|
+
for (const retiredPath of result.retiredGuards) {
|
|
301
|
+
console.log(`${colors.amber}⚠ ${retiredPath}: retired kernel guard kept as project guard (the kernel no longer ships it; the hook and the audit still run it)${colors.reset}`);
|
|
302
|
+
}
|
|
303
|
+
for (const { path: keptPath, reason } of result.legacyCleanup.kept) {
|
|
304
|
+
console.log(`${colors.amber}⚠ Kept legacy artifact ${keptPath} (${reason}); review and remove it manually${colors.reset}`);
|
|
305
|
+
}
|
|
306
|
+
console.log('');
|
|
307
|
+
printPlan(result.plan, { dryRun });
|
|
83
308
|
console.log(`\n${colors.bold}🔒 ACTIVOS PRESERVADOS AL 100%:${colors.reset}`);
|
|
84
309
|
console.log(` • ${colors.emerald}sdds/${colors.reset} (Especificaciones locales intactas)`);
|
|
85
310
|
console.log(` • ${colors.emerald}tools/guards/${colors.reset} (Reglas y salvaguardas personalizadas intactas)`);
|
|
86
311
|
console.log(` • ${colors.emerald}.clckernel.yaml${colors.reset} (Configuraciones de equipo intactas)\n`);
|
|
87
|
-
|
|
312
|
+
if (dryRun) {
|
|
313
|
+
console.log(`${colors.cyan}Dry run complete. Run without --dry-run to apply this plan.${colors.reset}\n`);
|
|
314
|
+
} else if (result.plan.protected.length > 0) {
|
|
315
|
+
console.log(`${colors.amber}✨ Proyecto actualizado, con ${result.plan.protected.length} archivo(s) protegido(s) para revisar (*${NEW_SUFFIX}).${colors.reset}\n`);
|
|
316
|
+
} else {
|
|
317
|
+
console.log(`${colors.emerald}✨ ¡Proyecto actualizado con éxito a la última versión de CLC Kernel!${colors.reset}\n`);
|
|
318
|
+
}
|
|
319
|
+
// sdds/onboarding: a short Next steps block, only when something needs attention.
|
|
320
|
+
const { updateNextStepsLines } = require('./ui');
|
|
321
|
+
const steps = dryRun ? [] : updateNextStepsLines(result.plan);
|
|
322
|
+
if (steps.length > 0) console.log(`${steps.join('\n')}\n`);
|
|
88
323
|
return result;
|
|
89
324
|
} catch (err) {
|
|
90
325
|
console.error(`\n❌ Error actualizando el arnés: ${err.message}`);
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot helpers shared by the diff-based guards (twin of _changes.py).
|
|
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
|
+
* - null: 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). changedFiles drops every listed path,
|
|
13
|
+
* so no guard evaluates the harness's own code, unless the guard passes
|
|
14
|
+
* `includeManaged` (check_hitl: the kernel's own skill must obey its rules).
|
|
15
|
+
* Without a registry (e.g. the clckernel repository, where tools/ is source
|
|
16
|
+
* code) nothing is excluded.
|
|
17
|
+
*
|
|
18
|
+
* The project's `exclude_paths` (.clckernel.yaml) are dropped too, for every
|
|
19
|
+
* guard. The config is read through the sibling _config.js, which install
|
|
20
|
+
* ships next to this file; an older install without it fails closed when a
|
|
21
|
+
* config file appears (run `clckernel update`). An invalid config
|
|
22
|
+
* throws its ConfigError: a guard never runs on a config it cannot read.
|
|
23
|
+
*
|
|
24
|
+
* Self-contained: uses only Node.js built-ins. Git runs with argument arrays,
|
|
25
|
+
* never through a shell; paths are read NUL-separated (-z), so spaces and
|
|
26
|
+
* non-ASCII names survive.
|
|
27
|
+
*/
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const { spawnSync } = require('child_process');
|
|
31
|
+
|
|
32
|
+
const BINARY_PROBE_BYTES = 8000;
|
|
33
|
+
const MANAGED_REGISTRY = 'tools/.clckernel-managed.json';
|
|
34
|
+
const MAX_BUFFER = 512 * 1024 * 1024;
|
|
35
|
+
const CONFIG_NAMES = ['.clckernel.yaml', '.clckernel.yml', '.clc-forge.yaml', '.clc-forge.yml'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The project's validated config (defaults filled in), or null when the
|
|
39
|
+
* project has no config file. Deprecation warnings go to stderr once.
|
|
40
|
+
* @param {string} [dir] - Project root (default: cwd)
|
|
41
|
+
* @returns {object|null}
|
|
42
|
+
* @throws {Error} ConfigError on an invalid config; a plain Error when
|
|
43
|
+
* tools/_config.js is missing
|
|
44
|
+
*/
|
|
45
|
+
function projectConfig(dir) {
|
|
46
|
+
const root = dir || process.cwd();
|
|
47
|
+
if (!CONFIG_NAMES.some(name => fs.existsSync(path.join(root, name)))) return null;
|
|
48
|
+
let configModule;
|
|
49
|
+
try {
|
|
50
|
+
configModule = require('./_config.js');
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err && err.code === 'MODULE_NOT_FOUND') {
|
|
53
|
+
throw new Error(`The project config needs tools/_config.js and tools/yaml.js (${err.message.split('\n')[0]}): run \`clckernel update\` so the guards can read it`);
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
const loaded = configModule.loadConfig(root);
|
|
58
|
+
configModule.reportWarnings(loaded);
|
|
59
|
+
return loaded.config;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A predicate for root-relative paths matching one of the globs (false for
|
|
64
|
+
* every path when there are none).
|
|
65
|
+
* @param {string[]} globs
|
|
66
|
+
* @returns {(rel: string) => boolean}
|
|
67
|
+
*/
|
|
68
|
+
function globMatcher(globs) {
|
|
69
|
+
if (!Array.isArray(globs) || globs.length === 0) return () => false;
|
|
70
|
+
return require('./_config.js').globMatcher(globs);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Runs git in dir; stdout Buffer on success, null on any failure. */
|
|
74
|
+
function git(dir, args) {
|
|
75
|
+
const res = spawnSync('git', args, { cwd: dir, maxBuffer: MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
76
|
+
if (res.error || res.status !== 0) return null;
|
|
77
|
+
return res.stdout;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function splitNul(buffer) {
|
|
81
|
+
return buffer.toString('utf-8').split('\0').filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasCommit(dir, rev) {
|
|
85
|
+
return git(dir, ['rev-parse', '--verify', '--quiet', `${rev}^{commit}`]) !== null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Lists the files added, copied, modified or renamed in the snapshot, minus
|
|
90
|
+
* the registry-listed files and the config's exclude_paths.
|
|
91
|
+
* @param {string} dir - Directory inside the repository
|
|
92
|
+
* @param {{ extensions?: string[], includeManaged?: boolean }} [options] - Keep only
|
|
93
|
+
* paths ending in one of `extensions`; `includeManaged` keeps registry-listed paths too
|
|
94
|
+
* @returns {{ mode: 'staged'|'last-commit'|null, files: string[] }}
|
|
95
|
+
*/
|
|
96
|
+
function changedFiles(dir, { extensions, includeManaged = false } = {}) {
|
|
97
|
+
const cwd = dir || process.cwd();
|
|
98
|
+
const anyStaged = git(cwd, ['diff', '--cached', '--name-only', '-z']);
|
|
99
|
+
if (anyStaged === null) return { mode: null, files: [] };
|
|
100
|
+
|
|
101
|
+
let mode;
|
|
102
|
+
let out;
|
|
103
|
+
if (anyStaged.length > 0) {
|
|
104
|
+
mode = 'staged';
|
|
105
|
+
out = git(cwd, ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR']);
|
|
106
|
+
} else if (!hasCommit(cwd, 'HEAD')) {
|
|
107
|
+
// No commits and nothing staged: the index holds nothing to evaluate.
|
|
108
|
+
return { mode: 'staged', files: [] };
|
|
109
|
+
} else {
|
|
110
|
+
mode = 'last-commit';
|
|
111
|
+
out = hasCommit(cwd, 'HEAD~1')
|
|
112
|
+
? git(cwd, ['diff', '--name-only', '-z', '--diff-filter=ACMR', 'HEAD~1', 'HEAD'])
|
|
113
|
+
: git(cwd, ['ls-tree', '-r', '-z', '--name-only', '--full-tree', 'HEAD']);
|
|
114
|
+
}
|
|
115
|
+
if (out === null) return { mode: null, files: [] };
|
|
116
|
+
|
|
117
|
+
const managed = includeManaged ? new Set() : managedFiles(cwd, mode);
|
|
118
|
+
const config = projectConfig(cwd);
|
|
119
|
+
const excluded = globMatcher(config ? config.exclude_paths : []);
|
|
120
|
+
let files = splitNul(out).filter(f => !managed.has(f) && !excluded(f));
|
|
121
|
+
if (Array.isArray(extensions) && extensions.length > 0) {
|
|
122
|
+
files = files.filter(f => extensions.some(ext => f.endsWith(ext)));
|
|
123
|
+
}
|
|
124
|
+
return { mode, files };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* New paths of the PURE renames in the snapshot: files whose content is
|
|
129
|
+
* identical to a file removed in the same change (same blob, git R100). The
|
|
130
|
+
* pair compared is the one changedFiles uses: HEAD -> index when staged,
|
|
131
|
+
* HEAD~1 -> HEAD in last-commit mode; a root commit (or an index with no HEAD)
|
|
132
|
+
* has no renames. A rename with any content change is not listed.
|
|
133
|
+
* @param {string} dir - Directory inside the repository
|
|
134
|
+
* @param {'staged'|'last-commit'|null} mode - Snapshot (from changedFiles)
|
|
135
|
+
* @returns {Set<string>}
|
|
136
|
+
*/
|
|
137
|
+
function pureRenames(dir, mode) {
|
|
138
|
+
const cwd = dir || process.cwd();
|
|
139
|
+
const raw = ['--raw', '-z', '--no-abbrev', '-M', '--diff-filter=R'];
|
|
140
|
+
let out = null;
|
|
141
|
+
if (mode === 'staged' && hasCommit(cwd, 'HEAD')) {
|
|
142
|
+
out = git(cwd, ['diff', '--cached', ...raw, 'HEAD']);
|
|
143
|
+
} else if (mode === 'last-commit' && hasCommit(cwd, 'HEAD~1')) {
|
|
144
|
+
out = git(cwd, ['diff', ...raw, 'HEAD~1', 'HEAD']);
|
|
145
|
+
}
|
|
146
|
+
const renamed = new Set();
|
|
147
|
+
if (out === null) return renamed;
|
|
148
|
+
// Each record: ":<src mode> <dst mode> <src blob> <dst blob> R<score>" NUL old NUL new NUL.
|
|
149
|
+
const parts = splitNul(out);
|
|
150
|
+
for (let i = 0; i + 2 < parts.length; i += 3) {
|
|
151
|
+
const fields = parts[i].replace(/^:/, '').split(' ');
|
|
152
|
+
if (fields.length === 5 && fields[2] === fields[3]) renamed.add(parts[i + 2]);
|
|
153
|
+
}
|
|
154
|
+
return renamed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Paths listed in the harness registry: read from the snapshot being evaluated,
|
|
159
|
+
* falling back to the working copy at the repository root. A missing or
|
|
160
|
+
* malformed registry excludes nothing.
|
|
161
|
+
* @returns {Set<string>}
|
|
162
|
+
*/
|
|
163
|
+
function managedFiles(cwd, mode) {
|
|
164
|
+
let text = readFile(cwd, MANAGED_REGISTRY, mode);
|
|
165
|
+
if (text === null && mode !== null) {
|
|
166
|
+
const top = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
167
|
+
text = readFile(top ? top.toString('utf-8').trim() : cwd, MANAGED_REGISTRY, null);
|
|
168
|
+
}
|
|
169
|
+
if (text === null) return new Set();
|
|
170
|
+
try {
|
|
171
|
+
const data = JSON.parse(text);
|
|
172
|
+
// Older installs wrote a list of paths; current ones { kernelVersion, files: [{ path, sha256 }] }.
|
|
173
|
+
if (Array.isArray(data)) return new Set(data.filter(f => typeof f === 'string'));
|
|
174
|
+
if (data && Array.isArray(data.files)) {
|
|
175
|
+
const paths = data.files.map(f => f && f.path).filter(f => typeof f === 'string');
|
|
176
|
+
return new Set([...paths, MANAGED_REGISTRY]);
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Malformed registry: exclude nothing.
|
|
180
|
+
}
|
|
181
|
+
return new Set();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Object name of a path in the snapshot (paths are relative to the repo root). */
|
|
185
|
+
function objectName(file, mode) {
|
|
186
|
+
return mode === 'staged' ? `:0:${file}` : `HEAD:${file}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reads a file from the snapshot.
|
|
191
|
+
* With mode null (no git) the working copy is the only source there is.
|
|
192
|
+
* @returns {string|null} UTF-8 content; null when missing or binary
|
|
193
|
+
*/
|
|
194
|
+
function readFile(dir, file, mode) {
|
|
195
|
+
const cwd = dir || process.cwd();
|
|
196
|
+
let buffer;
|
|
197
|
+
if (mode === null || mode === undefined) {
|
|
198
|
+
try {
|
|
199
|
+
buffer = fs.readFileSync(path.join(cwd, file));
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
buffer = git(cwd, ['cat-file', 'blob', objectName(file, mode)]);
|
|
205
|
+
if (buffer === null) return null;
|
|
206
|
+
}
|
|
207
|
+
if (buffer.subarray(0, BINARY_PROBE_BYTES).includes(0)) return null;
|
|
208
|
+
return buffer.toString('utf-8');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* True when the path exists in the snapshot (the working copy when mode is null).
|
|
213
|
+
* @returns {boolean}
|
|
214
|
+
*/
|
|
215
|
+
function exists(dir, file, mode) {
|
|
216
|
+
const cwd = dir || process.cwd();
|
|
217
|
+
if (mode === null || mode === undefined) return fs.existsSync(path.join(cwd, file));
|
|
218
|
+
return git(cwd, ['cat-file', '-e', objectName(file, mode)]) !== null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Lists every file of the snapshot, for whole-repository facts (e.g. a name
|
|
223
|
+
* that must be unique, a link target that must exist): the index in staged
|
|
224
|
+
* mode, HEAD in last-commit mode. Paths are relative to the repository root
|
|
225
|
+
* and sorted; nothing is excluded. With mode null (no git) nothing is listed.
|
|
226
|
+
* @param {string} dir - Directory inside the repository
|
|
227
|
+
* @param {'staged'|'last-commit'|null} mode - Snapshot (from changedFiles)
|
|
228
|
+
* @returns {string[]}
|
|
229
|
+
*/
|
|
230
|
+
function listFiles(dir, mode) {
|
|
231
|
+
const cwd = dir || process.cwd();
|
|
232
|
+
if (mode !== 'staged' && mode !== 'last-commit') return [];
|
|
233
|
+
const out = mode === 'staged'
|
|
234
|
+
? git(cwd, ['ls-files', '-z', '--cached', '--full-name', '--', ':/'])
|
|
235
|
+
: git(cwd, ['ls-tree', '-r', '-z', '--name-only', '--full-tree', 'HEAD']);
|
|
236
|
+
if (out === null) return [];
|
|
237
|
+
return [...new Set(splitNul(out))].sort();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Dependencies of the nearest package.json of a repository path, read from the
|
|
242
|
+
* snapshot: the file's directory, then each parent up to the repository root
|
|
243
|
+
* (a monorepo part's own package.json wins over the root one). Results are
|
|
244
|
+
* memoized per directory in `cache` when one is passed.
|
|
245
|
+
* @param {string} dir - Directory inside the repository
|
|
246
|
+
* @param {string} file - Path relative to the repository root
|
|
247
|
+
* @param {'staged'|'last-commit'|null} mode - Snapshot (from changedFiles)
|
|
248
|
+
* @param {Map<string, object|null>} [cache] - Directory -> dependencies
|
|
249
|
+
* @returns {object|null} dependencies + devDependencies; null without a package.json
|
|
250
|
+
*/
|
|
251
|
+
function nearestPackageDeps(dir, file, mode, cache = new Map()) {
|
|
252
|
+
const visited = [];
|
|
253
|
+
let current = path.posix.dirname(file);
|
|
254
|
+
let deps = null;
|
|
255
|
+
for (;;) {
|
|
256
|
+
const key = current === '.' ? '' : current;
|
|
257
|
+
if (cache.has(key)) {
|
|
258
|
+
deps = cache.get(key);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
visited.push(key);
|
|
262
|
+
const raw = readFile(dir, key ? `${key}/package.json` : 'package.json', mode);
|
|
263
|
+
if (raw !== null) {
|
|
264
|
+
try {
|
|
265
|
+
const pkg = JSON.parse(raw);
|
|
266
|
+
deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
267
|
+
} catch {
|
|
268
|
+
deps = {};
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
if (!key) break;
|
|
273
|
+
current = path.posix.dirname(key);
|
|
274
|
+
}
|
|
275
|
+
for (const key of visited) cache.set(key, deps);
|
|
276
|
+
return deps;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { changedFiles, pureRenames, readFile, exists, listFiles, nearestPackageDeps, projectConfig, globMatcher };
|