clckernel 1.2.19 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/clckernel_cli/SKILL.md +23 -6
- package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
- package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
- package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
- package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
- package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
- package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
- package/.agents/skills/clckernel_cli/reference/start.md +14 -4
- package/.agents/skills/clckernel_cli/reference/test.md +1 -1
- package/.agents/skills/clckernel_cli/reference/update.md +19 -19
- package/CHANGELOG.md +250 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/deprecated.js +13 -0
- package/package.json +14 -4
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +23 -38
- package/src/adapters/django.js +14 -13
- package/src/adapters/fastapi.js +16 -15
- package/src/adapters/go.js +11 -9
- package/src/adapters/laravel.js +12 -10
- package/src/adapters/nextjs.js +7 -5
- package/src/adapters/rails.js +13 -11
- package/src/adapters/rust.js +11 -9
- package/src/catalog.js +58 -49
- package/src/config.js +38 -240
- package/src/detector.js +243 -44
- package/src/doctor.js +18 -9
- package/src/generator.js +734 -302
- package/src/gitignore.js +60 -14
- package/src/index.js +257 -18
- package/src/io.js +409 -0
- package/src/known-hashes.json +309 -0
- package/src/onboarding.js +15 -0
- package/src/technologies/detector.js +1 -1
- package/src/technologies/guards/_shared.js +1 -1
- package/src/ui.js +233 -40
- package/src/updater.js +271 -38
- package/tools/_changes.js +279 -0
- package/tools/_changes.py +233 -0
- package/tools/_config.js +294 -0
- package/tools/_config.py +271 -0
- package/tools/_yaml.py +489 -0
- package/tools/audit.js +511 -111
- package/tools/audit.py +506 -128
- package/tools/celery_guard.py +13 -35
- package/tools/check_a11y.js +8 -28
- package/tools/check_api_contracts.js +13 -39
- package/tools/check_architecture.js +35 -44
- package/tools/check_architecture.py +172 -105
- package/tools/check_custom.js +108 -61
- package/tools/check_custom.py +108 -271
- package/tools/check_data_files.js +88 -0
- package/tools/check_db_efficiency.py +80 -65
- package/tools/check_hitl.js +112 -69
- package/tools/check_links.js +138 -0
- package/tools/check_migrations.py +143 -94
- package/tools/check_performance.js +35 -44
- package/tools/check_placeholders.js +104 -0
- package/tools/check_responsive.js +8 -28
- package/tools/check_scope.py +40 -31
- package/tools/check_seo.js +8 -28
- package/tools/check_skills.js +158 -0
- package/tools/check_storybook.js +8 -21
- package/tools/check_symlinks.js +40 -22
- package/tools/check_ui_reuse.js +17 -29
- package/tools/docker_guard.py +19 -41
- package/tools/postgres_guard.py +15 -36
- package/tools/redis_guard.py +13 -35
- package/tools/scan_secrets.js +134 -67
- package/tools/scan_secrets.py +163 -72
- package/tools/verify_tdd.js +867 -0
- package/tools/verify_tdd.py +407 -165
- package/tools/yaml.js +404 -132
- package/tools/check_domain_data.js +0 -100
- package/tools/check_test_calendar_sync.js +0 -111
|
@@ -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
|
+
}
|