clckernel 1.2.19 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +250 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/deprecated.js +13 -0
  16. package/package.json +14 -4
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +23 -38
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +734 -302
  32. package/src/gitignore.js +60 -14
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -38
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. package/tools/check_test_calendar_sync.js +0 -111
@@ -0,0 +1,867 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Red -> Green TDD Verifier — twin of verify_tdd.py for every non-Python stack:
4
+ * JS/TS (vitest / jest / node:test), Go (go test), Rust (cargo test), Ruby
5
+ * (RSpec / Minitest through bundler) and PHP (Pest / PHPUnit).
6
+ *
7
+ * Proves that a change to production code ships with tests that FAIL without
8
+ * the change (red) and PASS with it (green).
9
+ *
10
+ * Two trees are compared:
11
+ * - something staged: BASE = HEAD, CANDIDATE = the index (staged content)
12
+ * - nothing staged: BASE = HEAD~1, CANDIDATE = HEAD
13
+ * Both trees are materialized from git into temporary directories (the index
14
+ * with `git checkout-index`, commits with `git archive | tar -x`), so the
15
+ * working copy is never evaluated and the caller's index/HEAD/worktree are
16
+ * never touched. The project's installed dependencies are linked into each
17
+ * tree (read-only use; nothing is installed) and only the links are removed:
18
+ * node_modules (JS), vendor/ (PHP: autoload.php, composer/ and bin/ copied so
19
+ * Composer resolves the app from the tree, the packages symlinked), .bundle/
20
+ * and vendor/bundle (Ruby). Cargo builds into a temporary CARGO_TARGET_DIR,
21
+ * never the project's target/; Go uses its global module cache.
22
+ *
23
+ * Each changed file belongs to a language and a project root. A .go / .rs /
24
+ * .rb / .php file belongs to its language when an ancestor (inside the
25
+ * repository) holds go.mod / Cargo.toml / Gemfile / composer.json, the nearest
26
+ * one being its root; without one it is out of scope. A JS/TS file (or one
27
+ * listed in tdd.code) belongs to the nearest ancestor holding a package.json,
28
+ * else the repository root. Files are grouped by (language, root) and each
29
+ * group is verified on its own, with cwd = that root in the temporary tree. A
30
+ * group whose code changed without a changed test in the same group fails.
31
+ * With a single group the result is the same as a whole-repository run.
32
+ *
33
+ * The project config (.clckernel.yaml) widens or narrows what counts as
34
+ * production code: a file matching `tdd.code` is code whatever its language
35
+ * (e.g. Python tools tested by JS tests), a file matching `tdd.ignore` never
36
+ * is. A JS/TS file matching `tdd.code` is handed to verify_tdd.py when it is
37
+ * installed next to this file (it is the code of the Python suite).
38
+ *
39
+ * Self-contained: uses only Node.js built-ins. Commands run with argument
40
+ * arrays, never through a shell (except .cmd/.bat shims on Windows).
41
+ */
42
+ const fs = require('fs');
43
+ const os = require('os');
44
+ const path = require('path');
45
+ const { spawnSync } = require('child_process');
46
+ const { changedFiles, listFiles, pureRenames, projectConfig, globMatcher } = require('./_changes');
47
+
48
+ const NAME = 'verify_tdd';
49
+ const DEFAULT_TIMEOUT = 300;
50
+ const OUTPUT_TAIL_LINES = 40;
51
+ const MAX_BUFFER = 512 * 1024 * 1024;
52
+ const JS_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx'];
53
+ const NODE_TEST_EXTENSIONS = ['.js', '.mjs', '.cjs'];
54
+ const TEST_DIRS = new Set(['test', 'tests']);
55
+ const TEST_SUFFIX_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
56
+ // Collected test-support files: overlaid on BASE, never passed to the runner.
57
+ const SUPPORT_NAME_RE = /(\.config\.[^/]+$)|(^(vitest|jest)\.setup\.[^/]+$)/;
58
+ // Variables that tie git to the caller's repository (set inside hooks); the
59
+ // tests run in a detached tree and must not inherit them.
60
+ const CALLER_GIT_ENV = [
61
+ 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX', 'GIT_COMMON_DIR',
62
+ 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
63
+ ];
64
+
65
+ class GitError extends Error {}
66
+
67
+ function git(repo, args, options = {}) {
68
+ const res = spawnSync('git', args, { cwd: repo, maxBuffer: MAX_BUFFER, ...options });
69
+ if (res.error || res.status !== 0) {
70
+ const detail = res.error ? res.error.message : String(res.stderr || '').trim();
71
+ throw new GitError(`git ${args.join(' ')} failed: ${detail}`);
72
+ }
73
+ return res.stdout;
74
+ }
75
+
76
+ // The other-language verifier: when it is installed next to this one, a JS/TS
77
+ // file listed in tdd.code is its code (e.g. JS scripts tested by pytest).
78
+ const SIBLING_VERIFIER = 'verify_tdd.py';
79
+
80
+ /**
81
+ * What counts as code beyond JS/TS (tdd.code), what never does (tdd.ignore),
82
+ * and which JS/TS files are handed to the sibling verifier: those matching
83
+ * tdd.code when verify_tdd.py is installed next to this file. Listing a JS/TS
84
+ * file in tdd.code has no other purpose, and verify_tdd.py already counts it
85
+ * as code, so each file has exactly one owner.
86
+ * @param {string} repo - Repository root (where the config lives)
87
+ * @returns {{ extra: (rel: string) => boolean, ignore: (rel: string) => boolean, handed: (rel: string) => boolean }}
88
+ */
89
+ function codeRules(repo) {
90
+ const config = projectConfig(repo);
91
+ const extra = globMatcher(config ? config.tdd.code : []);
92
+ const sibling = fs.existsSync(path.join(__dirname, SIBLING_VERIFIER));
93
+ return {
94
+ extra,
95
+ ignore: globMatcher(config ? config.tdd.ignore : []),
96
+ handed: sibling ? (rel) => JS_EXTENSIONS.includes(path.posix.extname(rel)) && extra(rel) : () => false,
97
+ };
98
+ }
99
+
100
+ const NO_RULES = { extra: () => false, ignore: () => false, handed: () => false };
101
+
102
+ /** True for a file the verifier looks at: JS/TS, or listed in tdd.code (minus handed files). */
103
+ function inScope(rel, rules) {
104
+ if (rules.handed(rel)) return false;
105
+ return JS_EXTENSIONS.includes(path.posix.extname(rel)) || rules.extra(rel);
106
+ }
107
+
108
+ /** Absolute path of an executable on PATH (PATHEXT on Windows), or null. */
109
+ function onPath(cmd) {
110
+ const exts = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
111
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
112
+ if (!dir) continue;
113
+ for (const ext of exts) {
114
+ const file = path.join(dir, cmd + ext);
115
+ try {
116
+ fs.accessSync(file, fs.constants.X_OK);
117
+ if (fs.statSync(file).isFile()) return file;
118
+ } catch { /* not here */ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function missingTool(name, label, hint) {
125
+ return { name, error: `${label} is not on PATH: red->green cannot be verified. ${hint}` };
126
+ }
127
+
128
+ /** Sum of the first capture group of every match of re in text; null without a match. */
129
+ function sumMatches(text, re) {
130
+ let total = null;
131
+ for (const m of text.matchAll(re)) total = (total || 0) + Number(m[1]);
132
+ return total;
133
+ }
134
+
135
+ /** Symlink src into dest unless dest exists or src does not; returns the link or null. */
136
+ function linkIfMissing(src, dest) {
137
+ if (!fs.existsSync(src) || fs.existsSync(dest)) return null;
138
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
139
+ fs.symlinkSync(src, dest, process.platform === 'win32' ? 'junction' : 'dir');
140
+ return dest;
141
+ }
142
+
143
+ const RUBY_LOADER = 'ARGV.dup.tap { ARGV.clear }.each { |f| require File.expand_path(f) }';
144
+
145
+ /**
146
+ * Non-JS language profiles. Paths given to classify are relative to the
147
+ * project root; it returns 'test', 'support', 'code' or null (out of scope).
148
+ * runner(ctx) returns { name, steps, env?, count } or { name, error }, where
149
+ * ctx = { repo, root, tree (CANDIDATE root dir), tests (root-relative), tmp }.
150
+ * A step with `local` runs a path relative to the tree it runs in.
151
+ */
152
+ const PROFILES = [
153
+ {
154
+ lang: 'go',
155
+ marker: 'go.mod',
156
+ exts: ['.go'],
157
+ classify(rel) {
158
+ const parts = rel.split('/');
159
+ if (parts.includes('vendor')) return null;
160
+ if (rel.endsWith('_test.go')) return 'test';
161
+ if (parts.slice(0, -1).includes('testdata')) return 'support';
162
+ return 'code';
163
+ },
164
+ runner({ tests }) {
165
+ const go = onPath('go');
166
+ if (!go) return missingTool('go test', 'go', 'Install Go (https://go.dev/dl/).');
167
+ const pkgs = [...new Set(tests.map((t) => {
168
+ const dir = path.posix.dirname(t);
169
+ return dir === '.' ? '.' : `./${dir}`;
170
+ }))];
171
+ return {
172
+ name: 'go test',
173
+ steps: [{ command: go, args: ['test', '-v', '-count=1', ...pkgs] }],
174
+ count: (out) => (out.match(/^\s*--- PASS:/gm) || []).length,
175
+ };
176
+ },
177
+ },
178
+ {
179
+ lang: 'rust',
180
+ marker: 'Cargo.toml',
181
+ exts: ['.rs'],
182
+ classify(rel) {
183
+ const parts = rel.split('/');
184
+ if (parts[0] === 'tests') {
185
+ const target = parts.length === 2 || (parts.length === 3 && parts[2] === 'main.rs');
186
+ return target ? 'test' : 'support';
187
+ }
188
+ return parts[0] === 'src' ? 'code' : null;
189
+ },
190
+ runner({ tests, tmp }) {
191
+ const cargo = onPath('cargo');
192
+ if (!cargo) return missingTool('cargo test', 'cargo', 'Install Rust with rustup (https://rustup.rs).');
193
+ const targets = [...new Set(tests.map((t) => {
194
+ const parts = t.split('/');
195
+ return parts.length === 2 ? path.posix.basename(t, '.rs') : parts[1];
196
+ }))];
197
+ return {
198
+ name: 'cargo test',
199
+ // One target dir per tree, outside it: with a shared one, cargo's
200
+ // freshness check (dep-info mtimes of the other tree's sources) would
201
+ // reuse the green build for the red run.
202
+ env: (cwd) => ({ CARGO_TARGET_DIR: path.join(tmp, 'cargo-target', path.relative(tmp, cwd).replace(/[\\/]/g, '_')) }),
203
+ steps: [{ command: cargo, args: ['test', ...targets.flatMap((t) => ['--test', t])] }],
204
+ count: (out) => sumMatches(out, /^running (\d+) tests?$/gm),
205
+ };
206
+ },
207
+ },
208
+ {
209
+ lang: 'ruby',
210
+ marker: 'Gemfile',
211
+ exts: ['.rb'],
212
+ classify(rel) {
213
+ const parts = rel.split('/');
214
+ if (parts[0] === 'spec') return rel.endsWith('_spec.rb') ? 'test' : 'support';
215
+ if (parts[0] === 'test') return rel.endsWith('_test.rb') ? 'test' : 'support';
216
+ return parts[0] === 'app' || parts[0] === 'lib' ? 'code' : null;
217
+ },
218
+ link(repo, tree, root) {
219
+ return [
220
+ linkIfMissing(path.join(repo, root, '.bundle'), path.join(tree, '.bundle')),
221
+ linkIfMissing(path.join(repo, root, 'vendor', 'bundle'), path.join(tree, 'vendor', 'bundle')),
222
+ ];
223
+ },
224
+ runner({ repo, root, tree, tests }) {
225
+ const specs = tests.filter((t) => t.endsWith('_spec.rb'));
226
+ const minitests = tests.filter((t) => !t.endsWith('_spec.rb'));
227
+ const name = [specs.length ? 'rspec' : null, minitests.length ? 'minitest' : null].filter(Boolean).join('+');
228
+ const bundle = onPath('bundle');
229
+ if (!bundle) return missingTool(name, 'bundle', 'Install Bundler (`gem install bundler`) and run `bundle install`.');
230
+ if (specs.length > 0) {
231
+ const lock = [path.join(tree, 'Gemfile.lock'), path.join(repo, root, 'Gemfile.lock')].find((f) => fs.existsSync(f));
232
+ const text = lock ? fs.readFileSync(lock, 'utf-8') : '';
233
+ if (!/^ {4}rspec-core \(/m.test(text)) {
234
+ return {
235
+ name,
236
+ error: 'Spec files changed but rspec-core is not in Gemfile.lock: red->green cannot be verified. ' +
237
+ 'Add rspec to the Gemfile and run `bundle install`.',
238
+ };
239
+ }
240
+ }
241
+ const steps = [];
242
+ if (specs.length > 0) steps.push({ command: bundle, args: ['exec', 'rspec', ...specs] });
243
+ if (minitests.length > 0) {
244
+ steps.push({ command: bundle, args: ['exec', 'ruby', '-Ilib', '-Itest', '-e', RUBY_LOADER, ...minitests] });
245
+ }
246
+ return {
247
+ name,
248
+ env: (cwd) => ({ BUNDLE_GEMFILE: path.join(cwd, 'Gemfile') }),
249
+ steps,
250
+ count: (out) => {
251
+ const examples = sumMatches(out, /(\d+) examples?, \d+ failures?/g);
252
+ const runs = sumMatches(out, /(\d+) runs?, \d+ assertions?/g);
253
+ return examples === null && runs === null ? null : (examples || 0) + (runs || 0);
254
+ },
255
+ };
256
+ },
257
+ },
258
+ {
259
+ lang: 'php',
260
+ marker: 'composer.json',
261
+ exts: ['.php'],
262
+ classify(rel) {
263
+ const parts = rel.split('/');
264
+ if (parts[0] === 'tests') return rel.endsWith('Test.php') ? 'test' : 'support';
265
+ return parts[0] === 'app' || parts[0] === 'src' ? 'code' : null;
266
+ },
267
+ link(repo, tree, root) {
268
+ return linkVendor(path.join(repo, root, 'vendor'), path.join(tree, 'vendor'));
269
+ },
270
+ runner({ tree, tests }) {
271
+ const bin = ['pest', 'phpunit'].find((b) => fs.existsSync(path.join(tree, 'vendor', 'bin', b)));
272
+ if (!bin) {
273
+ return {
274
+ name: 'phpunit',
275
+ error: 'vendor/bin/phpunit (or vendor/bin/pest) is missing: red->green cannot be verified. ' +
276
+ 'Install the dependencies with `composer install`.',
277
+ };
278
+ }
279
+ const command = path.join('vendor', 'bin', bin);
280
+ // PHPUnit 9 accepts a single test path; Pest takes them all.
281
+ const steps = bin === 'pest'
282
+ ? [{ command, local: true, args: tests }]
283
+ : tests.map((t) => ({ command, local: true, args: [t] }));
284
+ return { name: bin, steps, count: phpTestCount };
285
+ },
286
+ },
287
+ ];
288
+
289
+ /** Tests PHPUnit / Pest ran: "OK (N tests", "Tests: N, ..." or Pest's "Tests: ... N passed". */
290
+ function phpTestCount(out) {
291
+ let total = null;
292
+ for (const line of out.split(/\r?\n/)) {
293
+ const m = line.match(/OK \((\d+) tests?/) || line.match(/Tests:.*?(\d+) passed/) || line.match(/Tests: (\d+),/);
294
+ if (m) total = (total || 0) + Number(m[1]);
295
+ }
296
+ return total;
297
+ }
298
+
299
+ /**
300
+ * The project's Composer vendor/ in a tree: a real directory where
301
+ * autoload.php, composer/ and bin/ are copies (Composer derives the app's base
302
+ * dir from their real path, so a symlink would load the app from the working
303
+ * copy) and every package is a symlink. Returns the links created.
304
+ */
305
+ function linkVendor(src, dest) {
306
+ if (!fs.existsSync(src) || fs.existsSync(dest)) return [];
307
+ fs.mkdirSync(dest, { recursive: true });
308
+ const links = [];
309
+ for (const entry of fs.readdirSync(src)) {
310
+ const from = path.join(src, entry);
311
+ const to = path.join(dest, entry);
312
+ if (['autoload.php', 'composer', 'bin'].includes(entry)) {
313
+ fs.cpSync(from, to, { recursive: true, dereference: true });
314
+ } else {
315
+ fs.symlinkSync(from, to, process.platform === 'win32' ? 'junction' : (fs.statSync(from).isDirectory() ? 'dir' : 'file'));
316
+ links.push(to);
317
+ }
318
+ }
319
+ return links;
320
+ }
321
+
322
+ const PROFILE_BY_LANG = new Map(PROFILES.map((p) => [p.lang, p]));
323
+
324
+ /** Nearest ancestor dir of rel holding marker: '.' for the repository root, null for none. */
325
+ function nearestMarker(rel, files, marker) {
326
+ const dirs = rel.split('/').slice(0, -1);
327
+ for (let depth = dirs.length; depth >= 0; depth -= 1) {
328
+ const root = dirs.slice(0, depth).join('/');
329
+ if (files.has(root ? `${root}/${marker}` : marker)) return root || '.';
330
+ }
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * Language and project root of a changed file, or null when the verifier does
336
+ * not look at it. A non-JS file is in scope when its profile classifies it
337
+ * (or tdd.code lists it) and it is not ignored code.
338
+ * @returns {{ lang: string, root: string }|null}
339
+ */
340
+ function locate(rel, files, rules) {
341
+ const ext = path.posix.extname(rel);
342
+ for (const profile of PROFILES) {
343
+ if (!profile.exts.includes(ext)) continue;
344
+ const root = nearestMarker(rel, files, profile.marker);
345
+ if (root === null) continue;
346
+ return profileKind(profile, rel, root, rules) ? { lang: profile.lang, root } : null;
347
+ }
348
+ if (inScope(rel, rules)) return { lang: 'js', root: projectRoot(rel, files) };
349
+ return null;
350
+ }
351
+
352
+ /** 'test' / 'support' / 'code' of a non-JS file in its root, or null. */
353
+ function profileKind(profile, rel, root, rules) {
354
+ let kind = profile.classify(relative(rel, root));
355
+ if (kind === null && rules.extra(rel)) kind = 'code';
356
+ if (kind === 'code' && rules.ignore(rel)) return null;
357
+ return kind;
358
+ }
359
+
360
+ function revExists(repo, rev) {
361
+ try {
362
+ git(repo, ['rev-parse', '--verify', '--quiet', `${rev}^{commit}`]);
363
+ return true;
364
+ } catch {
365
+ return false;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Pick BASE/CANDIDATE and list the changed files (deletions ignored). Pure
371
+ * renames (identical content moved to a new path) are not changes to verify:
372
+ * they are listed apart in `renamed` and count neither as code nor as tests.
373
+ */
374
+ function resolveTrees(repo, rules = NO_RULES) {
375
+ const all = changedFiles(repo);
376
+ if (all.mode === null) throw new GitError('could not list the changed files');
377
+ const pure = pureRenames(repo, all.mode);
378
+ const files = new Set(listFiles(repo, all.mode));
379
+ const changes = { mode: all.mode, files: all.files.filter((f) => !pure.has(f)) };
380
+ const renamed = all.files.filter((f) => pure.has(f) && locate(f, files, rules) !== null);
381
+ const hasHead = revExists(repo, 'HEAD');
382
+
383
+ if (changes.mode === 'staged') {
384
+ if (!hasHead && changes.files.length === 0) return null; // no commits and nothing staged
385
+ return { mode: 'staged', base: hasHead ? 'HEAD' : null, candidate: null, changed: changes.files, renamed, snapshot: changes.mode, files };
386
+ }
387
+ const base = revExists(repo, 'HEAD~1') ? 'HEAD~1' : null;
388
+ return { mode: 'manual', base, candidate: 'HEAD', changed: changes.files, renamed, snapshot: changes.mode, files };
389
+ }
390
+
391
+ /** Nearest ancestor dir of rel holding a package.json; '.' for the repository root. */
392
+ function projectRoot(rel, files) {
393
+ const dirs = rel.split('/').slice(0, -1);
394
+ for (let depth = dirs.length; depth > 0; depth -= 1) {
395
+ const root = dirs.slice(0, depth).join('/');
396
+ if (files.has(`${root}/package.json`)) return root;
397
+ }
398
+ return '.';
399
+ }
400
+
401
+ /**
402
+ * Changed in-scope files grouped by (language, project root), first seen
403
+ * first: a Map of key -> { lang, root, files }.
404
+ */
405
+ function byRoot(repo, trees, rules = NO_RULES) {
406
+ const files = trees.files || new Set(listFiles(repo, trees.snapshot));
407
+ const roots = new Map();
408
+ for (const rel of trees.changed) {
409
+ const where = locate(rel, files, rules);
410
+ if (where === null) continue;
411
+ const key = `${where.lang}:${where.root}`;
412
+ if (!roots.has(key)) roots.set(key, { ...where, files: [] });
413
+ roots.get(key).files.push(rel);
414
+ }
415
+ return roots;
416
+ }
417
+
418
+ /** classify for a group: the JS rules, or the group's language profile. */
419
+ function classifyGroup(group, rules = NO_RULES) {
420
+ if (group.lang === 'js') return classify(group.files, rules);
421
+ const profile = PROFILE_BY_LANG.get(group.lang);
422
+ const out = { tests: [], support: [], code: [] };
423
+ const lists = { test: out.tests, support: out.support, code: out.code };
424
+ for (const rel of group.files) {
425
+ const kind = profileKind(profile, rel, group.root, rules);
426
+ if (kind) lists[kind].push(rel);
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /** Label of a group in messages: its root for JS (as before), "root (lang)" otherwise. */
432
+ function groupLabel(group) {
433
+ return group.lang === 'js' ? group.root : `${group.root} (${group.lang})`;
434
+ }
435
+
436
+ function relative(rel, root) {
437
+ return root === '.' ? rel : rel.slice(root.length + 1);
438
+ }
439
+
440
+ /**
441
+ * test: *.test.* / *.spec.*, or any file under __tests__/ (jest convention)
442
+ * support: *.config.*, vitest/jest.setup.*, anything under a fixtures/ dir of a
443
+ * test dir, and helpers under test/ or tests/ without a test suffix
444
+ * code: everything else, plus any file matching tdd.code; a file matching
445
+ * tdd.ignore is never code
446
+ */
447
+ function classify(changed, rules = NO_RULES) {
448
+ const tests = [];
449
+ const support = [];
450
+ const code = [];
451
+ for (const rel of changed) {
452
+ if (!inScope(rel, rules)) continue;
453
+ if (rules.extra(rel)) {
454
+ if (!rules.ignore(rel)) code.push(rel);
455
+ continue;
456
+ }
457
+ const parts = rel.split('/');
458
+ const name = parts[parts.length - 1];
459
+ const dirs = parts.slice(0, -1);
460
+ const testDirIdx = dirs.findIndex((d) => TEST_DIRS.has(d) || d === '__tests__');
461
+ const inFixtures = testDirIdx >= 0 && dirs.slice(testDirIdx + 1).includes('fixtures');
462
+ if (SUPPORT_NAME_RE.test(name) || inFixtures) {
463
+ support.push(rel);
464
+ } else if (TEST_SUFFIX_RE.test(name) || dirs.includes('__tests__')) {
465
+ tests.push(rel);
466
+ } else if (testDirIdx >= 0) {
467
+ support.push(rel);
468
+ } else if (!rules.ignore(rel)) {
469
+ code.push(rel);
470
+ }
471
+ }
472
+ return { tests, support, code };
473
+ }
474
+
475
+ /** Write a tree into dest: the index when rev is null, else a commit. */
476
+ function materialize(repo, rev, dest) {
477
+ if (rev === null) {
478
+ // Honors GIT_INDEX_FILE inherited from a hook (e.g. `git commit -a`).
479
+ git(repo, ['checkout-index', '-a', `--prefix=${dest}${path.sep}`]);
480
+ return;
481
+ }
482
+ const archive = git(repo, ['archive', '--format=tar', rev]);
483
+ const res = spawnSync('tar', ['-x', '-f', '-', '-C', dest], { input: archive, maxBuffer: MAX_BUFFER });
484
+ if (res.error || res.status !== 0) {
485
+ const detail = res.error ? res.error.message : String(res.stderr || '').trim();
486
+ throw new GitError(`extracting ${rev} with tar failed: ${detail}`);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Symlink the project's node_modules into a tree; returns the link path or null.
492
+ * For a project root below the repository root, its own node_modules is used
493
+ * when installed, else the repository root's (hoisted workspaces).
494
+ */
495
+ function linkNodeModules(repo, tree, root = '.') {
496
+ const own = path.join(repo, root, 'node_modules');
497
+ const target = root !== '.' && !fs.existsSync(own) ? path.join(repo, 'node_modules') : own;
498
+ const link = path.join(tree, root, 'node_modules');
499
+ if (!fs.existsSync(target) || fs.existsSync(link)) return null;
500
+ fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
501
+ return link;
502
+ }
503
+
504
+ function overlay(srcRoot, destRoot, files) {
505
+ for (const rel of files) {
506
+ const dest = path.join(destRoot, rel);
507
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
508
+ fs.copyFileSync(path.join(srcRoot, rel), dest);
509
+ }
510
+ }
511
+
512
+ function timeoutMs() {
513
+ const value = Number(process.env.CLC_TDD_TIMEOUT);
514
+ return (Number.isFinite(value) && value > 0 ? value : DEFAULT_TIMEOUT) * 1000;
515
+ }
516
+
517
+ function tail(text) {
518
+ return text.split(/\r?\n/).slice(-OUTPUT_TAIL_LINES).join('\n');
519
+ }
520
+
521
+ function readPackageJson(tree) {
522
+ try {
523
+ return JSON.parse(fs.readFileSync(path.join(tree, 'package.json'), 'utf-8'));
524
+ } catch {
525
+ return {};
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Choose the runner from the CANDIDATE package.json.
531
+ * @returns {{ name: string, command: string|null, args: string[], error?: string }}
532
+ */
533
+ function detectRunner(tree, tests) {
534
+ const pkg = readPackageJson(tree);
535
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
536
+ for (const [name, args] of [['vitest', ['run']], ['jest', ['--runTestsByPath']]]) {
537
+ if (!deps[name]) continue;
538
+ const bin = path.join(tree, 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name);
539
+ if (!fs.existsSync(bin)) {
540
+ return {
541
+ name, command: null, args: [],
542
+ error: `${name} is declared in package.json but node_modules/.bin/${name} is missing: ` +
543
+ 'red->green cannot be verified. Install the dependencies with `npm install`.',
544
+ };
545
+ }
546
+ return { name, command: bin, args: [...args, ...tests] };
547
+ }
548
+ const tsTests = tests.filter((t) => !NODE_TEST_EXTENSIONS.includes(path.posix.extname(t)));
549
+ if (tsTests.length > 0) {
550
+ return {
551
+ name: 'node:test', command: null, args: [],
552
+ error: `No runner for TypeScript tests (${tsTests.join(', ')}): add vitest or jest to package.json.`,
553
+ };
554
+ }
555
+ return { name: 'node:test', command: process.execPath, args: ['--test', ...tests], files: tests };
556
+ }
557
+
558
+ /**
559
+ * Number of tests node:test executed, read from its report (spec reporter
560
+ * "✔ name (1ms)" lines, or TAP "ok 1 - name" lines used by Node 18/20 when
561
+ * not on a TTY). The "tests N" summary alone is not enough: a test file that
562
+ * defines no test is itself reported as one passing test named after the
563
+ * file, so results named after a run file are not counted. Skipped and todo
564
+ * tests are not counted either.
565
+ * @returns {number|null} null when the output carries no node:test summary
566
+ */
567
+ function nodeTestCount(output, tree, files) {
568
+ if (!/^\s*(?:ℹ|#)\s*tests\s+\d+\s*$/m.test(output)) return null;
569
+ const roots = [tree];
570
+ try { roots.push(fs.realpathSync(tree)); } catch { /* tree path is enough */ }
571
+ const fileNames = new Set(files.flatMap((f) => [f, ...roots.map((root) => path.join(root, f))]));
572
+ let count = 0;
573
+ for (const line of output.split(/\r?\n/)) {
574
+ if (/#\s*(SKIP|TODO)\b/i.test(line)) continue;
575
+ const match = line.match(/^\s*[✔✖]\s+(.*?)\s+\([\d.]+m?s\)\s*$/) ||
576
+ line.match(/^\s*(?:not )?ok \d+ - (.*?)\s*$/);
577
+ if (match && !fileNames.has(match[1])) count += 1;
578
+ }
579
+ return count;
580
+ }
581
+
582
+ /**
583
+ * Run the runner's commands in tree, stopping at the first that does not exit
584
+ * 0; the result carries the last exit code, the output tail and, for runners
585
+ * that count, the number of tests executed.
586
+ */
587
+ function runTests(tree, runner) {
588
+ const env = { ...process.env, NODE_ENV: 'test' };
589
+ for (const key of CALLER_GIT_ENV) delete env[key];
590
+ // Set when the verifier itself runs under node --test; a child that inherits
591
+ // it skips running its files ("run() is being called recursively").
592
+ delete env.NODE_TEST_CONTEXT;
593
+ // An interactive terminal propagates FORCE_COLOR to child processes; colour
594
+ // codes would break the summary parsing below, so ask runners for plain text.
595
+ env.FORCE_COLOR = '0';
596
+ env.NO_COLOR = '1';
597
+ if (runner.env) Object.assign(env, runner.env(tree));
598
+ const steps = runner.steps || [{ command: runner.command, args: runner.args }];
599
+ let output = '';
600
+ let res = null;
601
+ let timedOut = false;
602
+ for (const step of steps) {
603
+ const command = step.local ? path.join(tree, step.command) : step.command;
604
+ res = spawnSync(command, step.args, {
605
+ cwd: tree, env, timeout: timeoutMs(), maxBuffer: MAX_BUFFER, encoding: 'utf-8',
606
+ shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(command),
607
+ });
608
+ // Strip ANSI escape codes from runners that colour output regardless.
609
+ output += `${res.stdout || ''}${res.stderr || ''}`.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
610
+ timedOut = Boolean(res.error && res.error.code === 'ETIMEDOUT');
611
+ if (res.error && !timedOut) output += res.error.message;
612
+ if (res.error || res.status !== 0) break;
613
+ }
614
+ const result = {
615
+ exit_code: timedOut || res.error ? null : res.status,
616
+ timed_out: timedOut,
617
+ output: tail(output),
618
+ };
619
+ if (runner.name === 'node:test') result.tests = nodeTestCount(output, tree, runner.files);
620
+ else if (runner.count) result.tests = runner.count(output);
621
+ return result;
622
+ }
623
+
624
+ function runLabel(runner, run) {
625
+ if (run.timed_out) return 'timed out';
626
+ return `${runner.name} exit ${run.exit_code}`;
627
+ }
628
+
629
+ function result(exitCode, data, skipped = false) {
630
+ return { exitCode, skipped, name: NAME, data };
631
+ }
632
+
633
+ /** Materialize CANDIDATE and BASE into tmp, with the repository root's node_modules linked. */
634
+ function materializeTrees(repo, trees, tmp, links) {
635
+ const candidateDir = path.join(tmp, 'candidate');
636
+ const baseDir = path.join(tmp, 'base');
637
+ fs.mkdirSync(candidateDir);
638
+ fs.mkdirSync(baseDir);
639
+ materialize(repo, trees.candidate, candidateDir);
640
+ if (trees.base !== null) materialize(repo, trees.base, baseDir);
641
+ for (const dir of [candidateDir, baseDir]) {
642
+ const link = linkNodeModules(repo, dir);
643
+ if (link) links.push(link);
644
+ }
645
+ return { candidateDir, baseDir };
646
+ }
647
+
648
+ /**
649
+ * Green on CANDIDATE, then red on BASE + the changed tests, inside one project
650
+ * root, with the runner of the group's language.
651
+ */
652
+ function redGreen(repo, candidateRoot, baseRoot, root, groups, data, links, lang = 'js', tmp = null) {
653
+ const runFiles = groups.tests.map((f) => relative(f, root));
654
+ const overlayFiles = groups.tests.concat(groups.support).map((f) => relative(f, root));
655
+ const candidateDir = path.join(candidateRoot, root);
656
+ const baseDir = path.join(baseRoot, root);
657
+ if (root !== '.') fs.mkdirSync(baseDir, { recursive: true });
658
+ let runner;
659
+ if (lang === 'js') {
660
+ if (root !== '.') {
661
+ for (const dir of [candidateRoot, baseRoot]) {
662
+ const link = linkNodeModules(repo, dir, root);
663
+ if (link) links.push(link);
664
+ }
665
+ }
666
+ runner = detectRunner(candidateDir, runFiles);
667
+ } else {
668
+ const profile = PROFILE_BY_LANG.get(lang);
669
+ for (const dir of [candidateDir, baseDir]) {
670
+ if (profile.link) links.push(...[].concat(profile.link(repo, dir, root)).filter(Boolean));
671
+ }
672
+ runner = profile.runner({ repo, root, tree: candidateDir, tests: runFiles, tmp });
673
+ }
674
+ data.runner = runner.name;
675
+ if (runner.error) {
676
+ data.message = runner.error;
677
+ return result(1, data);
678
+ }
679
+
680
+ const green = runTests(candidateDir, runner);
681
+ data.green = green;
682
+ if (green.exit_code !== 0) {
683
+ data.message = `Changed tests do not pass with the change (${runLabel(runner, green)}).`;
684
+ return result(1, data);
685
+ }
686
+ if ((runner.name === 'node:test' || runner.count) && !(green.tests > 0)) {
687
+ data.message = 'Changed tests do not pass with the change (no tests executed).';
688
+ return result(1, data);
689
+ }
690
+
691
+ overlay(candidateDir, baseDir, overlayFiles);
692
+ const red = runTests(baseDir, runner);
693
+ data.red = red;
694
+ if (red.exit_code !== null && red.exit_code !== 0) {
695
+ data.message =
696
+ `TDD red->green verified: ${runFiles.length} test file(s) fail without the change ` +
697
+ 'and pass with it.';
698
+ return result(0, data);
699
+ }
700
+ if (red.exit_code === 0) {
701
+ data.message = 'Changed tests pass without the change: they do not prove the new behavior.';
702
+ return result(1, data);
703
+ }
704
+ data.message = `Red run on BASE did not fail cleanly (${runLabel(runner, red)}); see red output.`;
705
+ return result(1, data);
706
+ }
707
+
708
+ /**
709
+ * Runs fn(candidateDir, baseDir, links) over freshly materialized trees and
710
+ * cleans up: the links first (never their targets; rmSync does not follow
711
+ * symlinks either, this only makes the intent explicit), then the trees.
712
+ */
713
+ function withTrees(repo, trees, fn) {
714
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clc-verify-tdd-'));
715
+ const links = [];
716
+ try {
717
+ const { candidateDir, baseDir } = materializeTrees(repo, trees, tmp, links);
718
+ return fn(candidateDir, baseDir, links, tmp);
719
+ } finally {
720
+ for (const link of links) {
721
+ try { fs.unlinkSync(link); } catch { /* already gone */ }
722
+ }
723
+ fs.rmSync(tmp, { recursive: true, force: true });
724
+ }
725
+ }
726
+
727
+ function verify(repo, trees, groups, data, root = '.', lang = 'js') {
728
+ return withTrees(repo, trees, (candidateDir, baseDir, links, tmp) =>
729
+ redGreen(repo, candidateDir, baseDir, root, groups, data, links, lang, tmp));
730
+ }
731
+
732
+ /** Outcome decided without running tests, or null when red/green must run. */
733
+ function precheck(groups, data) {
734
+ if (groups.code.length === 0) {
735
+ if (groups.tests.length > 0) {
736
+ data.message = 'Only tests changed: characterization tests allowed.';
737
+ data.warning = 'No production code changed, so red->green was not verified.';
738
+ } else if (data.pure_renames && data.pure_renames.length > 0) {
739
+ data.message = 'Only pure renames: nothing to verify.';
740
+ } else {
741
+ data.message = 'No production code changed: nothing to verify.';
742
+ }
743
+ return result(0, data);
744
+ }
745
+ if (groups.tests.length === 0) {
746
+ data.message =
747
+ `No test evidence: ${groups.code.length} production code file(s) changed ` +
748
+ 'without any changed test file.';
749
+ return result(1, data);
750
+ }
751
+ return null;
752
+ }
753
+
754
+ /** Several groups (language, root): each passes on its own, or the change fails. */
755
+ function verifyRoots(repo, trees, roots, data, rules = NO_RULES) {
756
+ const entries = [...roots.values()].map((group) => {
757
+ const { root, lang } = group;
758
+ const groups = classifyGroup(group, rules);
759
+ const gdata = {
760
+ root, language: lang, changed_tests: groups.tests, changed_support: groups.support, changed_code: groups.code,
761
+ };
762
+ const outcome = precheck(groups, gdata);
763
+ if (outcome && outcome.exitCode === 1) {
764
+ gdata.message =
765
+ `No test evidence: ${groups.code.length} production code file(s) changed in ${root} ` +
766
+ `without any changed test file in ${root}.`;
767
+ }
768
+ return { root, lang, label: groupLabel(group), groups, gdata, outcome };
769
+ });
770
+
771
+ const pending = entries.filter((e) => e.outcome === null);
772
+ if (pending.length > 0) {
773
+ try {
774
+ withTrees(repo, trees, (candidateDir, baseDir, links, tmp) => {
775
+ for (const entry of pending) {
776
+ entry.outcome = redGreen(
777
+ repo, candidateDir, baseDir, entry.root, entry.groups, entry.gdata, links, entry.lang, tmp);
778
+ }
779
+ });
780
+ } catch (err) {
781
+ data.message = `Could not materialize the trees to verify: ${err.message}`;
782
+ return result(1, data);
783
+ }
784
+ }
785
+
786
+ data.groups = entries.map((e) => ({ ...e.gdata, exit_code: e.outcome.exitCode }));
787
+ const labelled = entries.map((e, i) => ({ label: e.label, group: data.groups[i] }));
788
+ const failed = labelled.filter((e) => e.group.exit_code !== 0);
789
+ data.message = (failed.length > 0 ? failed : labelled).map((e) => `[${e.label}] ${e.group.message}`).join(' ');
790
+ return result(failed.length > 0 ? 1 : 0, data);
791
+ }
792
+
793
+ /** Every group's tests / support / code, in the order the files changed. */
794
+ function classifyChanged(changed, roots, rules) {
795
+ const kinds = new Map();
796
+ for (const group of roots.values()) {
797
+ const groups = classifyGroup(group, rules);
798
+ for (const kind of ['tests', 'support', 'code']) for (const rel of groups[kind]) kinds.set(rel, kind);
799
+ }
800
+ const out = { tests: [], support: [], code: [] };
801
+ for (const rel of changed) if (kinds.has(rel)) out[kinds.get(rel)].push(rel);
802
+ return out;
803
+ }
804
+
805
+ /**
806
+ * Verify red->green for the current change.
807
+ * @param {string} [targetDir] - Directory inside the repository (default: cwd)
808
+ * @returns {{ exitCode: number, skipped: boolean, name: string, data: object }}
809
+ * data includes mode, base, candidate, changed_tests, changed_support,
810
+ * changed_code, message and, when tests ran, runner and green/red results.
811
+ */
812
+ function check(targetDir) {
813
+ const dir = targetDir || process.cwd();
814
+ let repo;
815
+ try {
816
+ repo = git(dir, ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' }).trim();
817
+ } catch {
818
+ return result(0, { message: 'Not a git repository: nothing to verify' }, true);
819
+ }
820
+ // An invalid config throws here: never verify against a config we cannot read.
821
+ const rules = codeRules(repo);
822
+ let trees;
823
+ try {
824
+ trees = resolveTrees(repo, rules);
825
+ } catch (err) {
826
+ if (!(err instanceof GitError)) throw err;
827
+ return result(0, { message: 'Not a git repository: nothing to verify' }, true);
828
+ }
829
+ if (trees === null) {
830
+ return result(0, { message: 'No commits and nothing staged: nothing to verify' }, true);
831
+ }
832
+
833
+ const roots = byRoot(repo, trees, rules);
834
+ const groups = classifyChanged(trees.changed, roots, rules);
835
+ const data = {
836
+ mode: trees.mode,
837
+ base: trees.base || '(empty tree)',
838
+ candidate: trees.candidate || '(index)',
839
+ changed_tests: groups.tests,
840
+ changed_support: groups.support,
841
+ changed_code: groups.code,
842
+ };
843
+ if (trees.renamed.length > 0) data.pure_renames = trees.renamed;
844
+
845
+ if (roots.size > 1) return verifyRoots(repo, trees, roots, data, rules);
846
+ const only = roots.size === 1 ? [...roots.values()][0] : { root: '.', lang: 'js' };
847
+ if (only.root !== '.') data.root = only.root;
848
+ if (only.lang !== 'js') data.language = only.lang;
849
+
850
+ const early = precheck(groups, data);
851
+ if (early) return early;
852
+
853
+ try {
854
+ return verify(repo, trees, groups, data, only.root, only.lang);
855
+ } catch (err) {
856
+ data.message = `Could not materialize the trees to verify: ${err.message}`;
857
+ return result(1, data);
858
+ }
859
+ }
860
+
861
+ module.exports = { check };
862
+
863
+ if (require.main === module) {
864
+ const res = check(process.argv[2]);
865
+ console.log(JSON.stringify(res, null, 2));
866
+ process.exitCode = res.exitCode;
867
+ }