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.
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 +244 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/cli.js +8 -0
  16. package/package.json +12 -2
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +27 -46
  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 +737 -309
  32. package/src/gitignore.js +144 -0
  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 -36
  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/updater.js CHANGED
@@ -1,18 +1,198 @@
1
1
  /**
2
2
  * CLC Kernel — Non-Destructive Harness Updater
3
- * Safely updates core AST tools, skills, playbooks, and IDE symlinks in an existing
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
- * - Custom AST guards (tools/guards/)
7
- * - Project configuration and custom thresholds (.clckernel.yaml / .clc-forge.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: fs.existsSync(path.join(resolvedDir, '.clckernel.yaml')) || fs.existsSync(path.join(resolvedDir, '.clc-forge.yaml')),
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
- generateHarness(resolvedDir, detected);
35
-
36
- // 4. Ensure AGENTS.md documents the update command in its command table.
37
- // Non-destructive: only inject into a file that already carries the CLC
38
- // Kernel command table, and only when the row is missing (idempotent).
39
- // A project-customized AGENTS.md without that structure is left untouched.
40
- const agentsPath = path.join(resolvedDir, 'AGENTS.md');
41
- if (fs.existsSync(agentsPath)) {
42
- let agentsContent = fs.readFileSync(agentsPath, 'utf-8');
43
- if (!agentsContent.includes('clckernel update')) {
44
- if (agentsContent.includes('| `clckernel start`')) {
45
- agentsContent = agentsContent.replace(
46
- '| `clckernel start`',
47
- '| `clckernel update` | Maintenance | Non-destructive update: refresh core tools, playbooks & symlinks (preserves SDDs & custom guards). |\n| `clckernel start`'
48
- );
49
- fs.writeFileSync(agentsPath, agentsContent, 'utf-8');
50
- } else if (agentsContent.includes('## 3. Universal Command Interface')) {
51
- agentsContent += '\n| `clckernel update` | Maintenance | Non-destructive update: refresh core tools, playbooks & symlinks (preserves SDDs & custom guards). |\n';
52
- fs.writeFileSync(agentsPath, agentsContent, 'utf-8');
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
- async function runUpdate(targetDir = process.cwd()) {
71
- const { colors, printBanner } = require('./ui');
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
- console.log(`${colors.green}✔ Salvaguardas estándar actualizadas en tools/${colors.reset}`);
80
- console.log(`${colors.green}✔ Playbooks y Skills agénticos actualizados en .agents/skills/${colors.reset}`);
81
- console.log(`${colors.green}✔ Enlaces simbióticos de IDEs actualizados (Cursor, Claude, Copilot, Antigravity)${colors.reset}`);
82
- console.log(`${colors.green}✔ Hooks de git sincronizados${colors.reset}`);
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
- console.log(`${colors.green}✨ ¡Proyecto actualizado con éxito a la última versión de CLC Kernel!${colors.reset}\n`);
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 };