codehere 1.0.2 → 2.0.0-beta.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.
@@ -1,81 +0,0 @@
1
- /**
2
- * Did the agent name a test file that is not in the repo?
3
- *
4
- * "The webhook tests passed" while the file never existed is a lie a green re-run cannot see:
5
- * the suite passes because it never contained the test. Given the agent's own words, this lists
6
- * the test files it named that exist nowhere in the checkout. A file counts as present when any
7
- * file with the same name exists (so a wrong folder is not flagged). Deterministic, no model.
8
- * It cannot tell that a named test never ran, only that it is not there.
9
- */
10
- import { readdirSync } from 'node:fs';
11
- import { basename, join } from 'node:path';
12
- const TEST_FILE = /^(?:[\w.@-]+\/)*(?:test_[\w.-]+\.py|[\w.-]+_test\.(?:py|go)|[\w.-]+\.(?:test|spec)\.[cm]?[jt]sx?|[\w-]+Test\.php)$/;
13
- const SKIP_DIRS = new Set([
14
- 'node_modules',
15
- '.git',
16
- 'dist',
17
- 'build',
18
- '.venv',
19
- 'venv',
20
- '__pycache__',
21
- 'vendor',
22
- 'target',
23
- ]);
24
- const MAX_DEPTH = 8;
25
- const MAX_ENTRIES = 50_000;
26
- /** Separators: whitespace, quotes, backticks, brackets of every kind, markdown emphasis and table pipes. */
27
- const SEPARATORS = /[\s`'"(),;[\]<>{}|*]+/;
28
- /** `tests/test_x.py::test_y` → `tests/test_x.py`; `a.spec.ts:42:3` → `a.spec.ts`; `_a.test.js_` → `a.test.js`; trailing punctuation dropped. */
29
- function cleanToken(raw) {
30
- let t = raw.replace(/^\.\//, '').replace(/::.*$/, '');
31
- t = t.replace(/[.:…!?]+$/, '').replace(/(:\d+)+$/, '');
32
- if (t.length > 2 && t.startsWith('_') && t.endsWith('_'))
33
- t = t.slice(1, -1);
34
- return t;
35
- }
36
- export function extractClaimedTestFiles(text) {
37
- const found = [];
38
- for (const raw of text.split(SEPARATORS)) {
39
- const token = cleanToken(raw);
40
- if (token.startsWith('/') || token.split('/').includes('..'))
41
- continue;
42
- if (TEST_FILE.test(token) && !found.includes(token))
43
- found.push(token);
44
- }
45
- return found;
46
- }
47
- function namesInRepo(cwd) {
48
- const names = new Set();
49
- let seen = 0;
50
- const walk = (dir, depth) => {
51
- if (depth > MAX_DEPTH || seen > MAX_ENTRIES)
52
- return;
53
- let entries;
54
- try {
55
- entries = readdirSync(dir, { withFileTypes: true });
56
- }
57
- catch {
58
- return;
59
- }
60
- for (const e of entries) {
61
- seen++;
62
- if (e.isDirectory()) {
63
- if (!SKIP_DIRS.has(e.name))
64
- walk(join(dir, e.name), depth + 1);
65
- }
66
- else {
67
- names.add(e.name);
68
- }
69
- }
70
- };
71
- walk(cwd, 0);
72
- return names;
73
- }
74
- export function findMissingClaimedTests(cwd, claim) {
75
- const claimed = extractClaimedTestFiles(claim);
76
- if (!claimed.length)
77
- return [];
78
- const present = namesInRepo(cwd);
79
- return claimed.filter((p) => !present.has(basename(p)));
80
- }
81
- //# sourceMappingURL=check-claimed-tests.js.map
@@ -1,237 +0,0 @@
1
- /**
2
- * Did the change make a test pass without checking anything?
3
- *
4
- * A green re-run proves nothing when the agent got there by editing the test: swapping a failing
5
- * assertion for `assert True`, skipping the test, commenting the assertion out, or returning above
6
- * it. This reads `git diff` against HEAD and flags such lines added to test files that already
7
- * existed. It is a deterministic diff read (no model), so it is measured evidence (DESIGN.md §1).
8
- * By default it reads uncommitted changes against HEAD. With a base ref it reads everything since
9
- * the branch left that base (committed or not), which is the view a pull request needs.
10
- */
11
- import { execFileSync } from 'node:child_process';
12
- import { existsSync, readFileSync } from 'node:fs';
13
- import { dirname, join, resolve } from 'node:path';
14
- const TEST_PATH = /(^|\/)(test_[^/]*\.py|[^/]*_test\.py|[^/]*\.(test|spec)\.[cm]?[jt]sx?|[^/]*_test\.go|[^/]*Test\.php)$|(^|\/)(__tests__|tests?)\//;
15
- const TRIVIAL = [
16
- // `assert True`, `assert(True)`, `assert True, "message"`
17
- /^assert\s*\(?\s*(True|1|not\s+False)\s*\)?\s*(,.*)?(#.*)?$/,
18
- /^assert\s+([\w.'"]+)\s*==\s*\1\s*(#.*)?$/,
19
- /\bexpect\(\s*(true|1)\s*\)\s*\.\s*(toBe|toEqual|toStrictEqual)\(\s*(true|1)\s*\)/,
20
- /\bexpect\(\s*([\w.'"]+)\s*\)\s*\.\s*(toBe|toEqual|toStrictEqual)\(\s*\1\s*\)/,
21
- /\bexpect\(\s*true\s*\)\s*\.\s*toBeTruthy\(\)/,
22
- /\bassert(\.ok)?\(\s*true\s*\)/,
23
- /\bassert\.(equal|strictEqual|deepEqual|deepStrictEqual)\(\s*([\w.'"]+)\s*,\s*\2\s*\)/,
24
- /\bassert(Equal|Equals|Same)\(\s*([\w.'"]+)\s*,\s*\2\s*\)/,
25
- /\bassertTrue\(\s*(true|True)\s*\)/,
26
- ];
27
- const SKIP = [
28
- /@pytest\.mark\.(skip|xfail)\b/,
29
- /@pytest\.mark\.skipif\(\s*True\b/,
30
- /\bpytest\.(skip|xfail)\(/,
31
- /@unittest\.skip/,
32
- /\b(it|test|describe)\.(skip|todo)\(/,
33
- /\b(xit|xtest|xdescribe)\(/,
34
- /\bmarkTest(Skipped|Incomplete)\(/,
35
- /\bt\.Skip(f|Now)?\(/,
36
- // node:test options: { skip: true } / { skip: 'reason' } / { todo: true }; a conditional skip is left alone
37
- /\b(skip|todo)\s*:\s*(true\b|['"`])/,
38
- ];
39
- /** `.only` makes jest, mocha and Playwright skip every other test: the original pattern, on any test file. */
40
- const ONLY = /\b(it|test|describe)\.only\(/;
41
- /** `fit` and `fdescribe` do the same, but `fit` is also an ordinary function name: JS, TS and Vue files, at a statement
42
- * start, after `=>`, or after `void`, `await` or `return`. */
43
- const JS_FILE = /\.([cm]?[jt]sx?|vue)$/i;
44
- const JS_FOCUS = /(^|[;{}]\s*|=>\s*|\b(void|await|return)\s+)(fit|fdescribe)\(/;
45
- /** The line with quoted text emptied and a trailing `//` comment dropped, so neither can pose as a call. */
46
- const jsCode = (text) => text.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""').replace(/\/\/.*$/, '');
47
- const ASSERTION = /\b(assert\w*|expect|should|self\.assert\w*|this->assert\w*|t\.(Error|Fatal)\w*)\s*[.(]|^\s*assert\s+\S/;
48
- const BARE_RETURN = /^return\s*;?$/;
49
- /** A line that opens a branch: a return under it is conditional, not an unconditional skip. */
50
- const CONDITIONAL = /^(\}\s*)?(if|else|for|while|switch|case|default|catch|try|finally)\b/;
51
- /** How far below a bare return an assertion still counts as the one it hides. */
52
- const RETURN_LOOKAHEAD = 8;
53
- function uncomment(text) {
54
- const m = /^(?:\/\/+|#+|\/\*+|\*)\s?(.*?)\s*(?:\*\/)?$/.exec(text);
55
- return m ? m[1].trim() : null;
56
- }
57
- /** The commented text is an assertion that the same change removed (alone on its line, or inside a one-line test). */
58
- function wasCommentedOut(body, removed) {
59
- const core = body.replace(/;$/, '');
60
- return core.length >= 12 && ASSERTION.test(core) && removed.some((r) => r.includes(core));
61
- }
62
- /** `b/path` or git's C-quoted `"b/p\"ath"` form → `path`. */
63
- function headerPath(raw) {
64
- // git appends a tab to the header when the path contains a space.
65
- const v = raw.slice(4).replace(/\t$/, '');
66
- if (v.startsWith('b/'))
67
- return v.slice(2);
68
- if (v.startsWith('"b/') && v.endsWith('"')) {
69
- return v.slice(3, -1).replace(/\\(["\\tn])/g, (_, c) => (c === 't' ? '\t' : c === 'n' ? '\n' : c));
70
- }
71
- return null;
72
- }
73
- export function parseTestWeakening(diff, readLines) {
74
- const found = [];
75
- let file = null;
76
- let existing = false;
77
- let line = 0;
78
- let removed = [];
79
- // File headers (`--- a/...`, `+++ b/...`) only appear between `diff --git` and the first hunk, so an added line
80
- // whose text starts with `++ ` cannot pose as a header.
81
- let inHeader = false;
82
- for (const raw of diff.split('\n')) {
83
- if (raw.startsWith('diff --git ')) {
84
- file = null;
85
- existing = true;
86
- removed = [];
87
- inHeader = true;
88
- }
89
- else if (inHeader && raw.startsWith('--- ')) {
90
- existing = raw !== '--- /dev/null';
91
- }
92
- else if (inHeader && raw.startsWith('+++ ')) {
93
- const path = headerPath(raw);
94
- file = path && existing && TEST_PATH.test(path) ? path : null;
95
- }
96
- else if (raw.startsWith('@@')) {
97
- inHeader = false;
98
- const m = /\+(\d+)/.exec(raw);
99
- line = m ? Number(m[1]) : 0;
100
- removed = [];
101
- }
102
- else if (raw.startsWith('-')) {
103
- removed.push(raw.slice(1).trim());
104
- }
105
- else if (raw.startsWith('+') && file) {
106
- const text = raw.slice(1).trim();
107
- const body = uncomment(text);
108
- let why = null;
109
- if (TRIVIAL.some((re) => re.test(text)))
110
- why = 'trivial-assertion';
111
- else if (SKIP.some((re) => re.test(text)) ||
112
- ONLY.test(jsCode(text)) ||
113
- (JS_FILE.test(file) && JS_FOCUS.test(jsCode(text))))
114
- why = 'skipped';
115
- else if (body !== null && wasCommentedOut(body, removed))
116
- why = 'commented-out';
117
- else if (BARE_RETURN.test(text)) {
118
- const lines = readLines?.(file);
119
- const above = (lines?.[line - 2] ?? '').trim();
120
- const below = lines?.slice(line, line + RETURN_LOOKAHEAD) ?? [];
121
- if (!CONDITIONAL.test(above) && below.some((l) => ASSERTION.test(l)))
122
- why = 'unreachable-assertion';
123
- }
124
- if (why)
125
- found.push({ file, line, why, text: text.slice(0, 80) });
126
- line++;
127
- }
128
- }
129
- return found;
130
- }
131
- /** The commit the current branch left `base` at, so changes made on `base` since are not blamed on the branch. */
132
- function resolveBase(cwd, base) {
133
- if (base.startsWith('-'))
134
- throw new Error(`Invalid --base "${base}": a ref cannot start with "-".`);
135
- try {
136
- return execFileSync('git', ['merge-base', base, 'HEAD'], {
137
- cwd,
138
- encoding: 'utf8',
139
- stdio: ['ignore', 'pipe', 'ignore'],
140
- }).trim();
141
- }
142
- catch {
143
- throw new Error(`Unknown --base "${base}": not a ref in this repository.`);
144
- }
145
- }
146
- const MAX_DIFF = 64 * 1024 * 1024;
147
- /** git with literal paths and unquoted non-ASCII names. */
148
- function git(cwd, args) {
149
- // GIT_DIFF_OPTS overrides --unified=0, and context lines would shift every line number after them.
150
- const env = { ...process.env };
151
- delete env.GIT_DIFF_OPTS;
152
- return execFileSync('git', ['-c', 'core.quotepath=false', '--literal-pathspecs', ...args], {
153
- cwd,
154
- env,
155
- encoding: 'utf8',
156
- stdio: ['ignore', 'pipe', 'ignore'],
157
- maxBuffer: MAX_DIFF,
158
- });
159
- }
160
- function hasGitAbove(cwd) {
161
- for (let d = resolve(cwd);; d = dirname(d)) {
162
- if (existsSync(join(d, '.git')))
163
- return true;
164
- if (dirname(d) === d)
165
- return false;
166
- }
167
- }
168
- /**
169
- * Changes in `cwd` against HEAD, or against where the branch left `base` when it is given. Only test files are
170
- * diffed, so a large change elsewhere cannot push the test diff past the read limit.
171
- */
172
- export function scanTestWeakening(cwd, base) {
173
- try {
174
- git(cwd, ['rev-parse', '--is-inside-work-tree']);
175
- }
176
- catch (e) {
177
- if (e.code === 'ENOENT')
178
- return { findings: [], error: 'git is not installed' };
179
- // Skipped only when there is no repository at all. Any other git failure (GIT_DIR, ownership checks, a broken
180
- // .git) means the scan should have run and did not; with --base a repository is required.
181
- if (!base && !hasGitAbove(cwd))
182
- return { findings: [], skipped: 'not a git repository' };
183
- return { findings: [], error: 'git could not read this repository; run `git status` here to see why' };
184
- }
185
- if (!base) {
186
- try {
187
- git(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD']);
188
- }
189
- catch {
190
- return { findings: [], skipped: 'no commit to compare against yet' };
191
- }
192
- }
193
- const from = base ? resolveBase(cwd, base) : 'HEAD';
194
- // Fixed diff options, so a user's or the change's own git config cannot alter what the parser reads: paths
195
- // relative to `cwd` (a package folder inside a repository), a/ and b/ prefixes, no external diff or textconv,
196
- // binary attributes ignored, renames paired.
197
- const opts = ['--relative', '--no-ext-diff', '--no-textconv', '--text', '--src-prefix=a/', '--dst-prefix=b/', '-M'];
198
- let tests;
199
- try {
200
- // -z name-status: status, path (and for renames and copies a second path), NUL-separated. Both paths of a
201
- // rename go into the pathspec so git can pair them; a test renamed and weakened is still an existing test.
202
- const fields = git(cwd, ['diff', ...opts, '--name-status', '-z', from]).split('\0');
203
- tests = [];
204
- for (let i = 0; i < fields.length - 1;) {
205
- const status = fields[i++];
206
- const paths = /^[RC]/.test(status) ? [fields[i++], fields[i++]] : [fields[i++]];
207
- if (paths.some((p) => p && TEST_PATH.test(p)))
208
- tests.push(...paths.filter(Boolean));
209
- }
210
- }
211
- catch {
212
- return { findings: [], error: 'could not read the diff (the list of changed files)' };
213
- }
214
- if (!tests.length)
215
- return { findings: [] };
216
- let diff;
217
- try {
218
- diff = git(cwd, ['diff', ...opts, '--unified=0', '--no-color', from, '--', ...tests]);
219
- }
220
- catch {
221
- return {
222
- findings: [],
223
- error: `could not read the diff of ${tests.length} changed test file(s); it may be larger than 64 MB`,
224
- };
225
- }
226
- return {
227
- findings: parseTestWeakening(diff, (f) => {
228
- try {
229
- return readFileSync(join(cwd, f), 'utf8').split('\n');
230
- }
231
- catch {
232
- return null;
233
- }
234
- }),
235
- };
236
- }
237
- //# sourceMappingURL=detect-test-weakening.js.map
@@ -1,329 +0,0 @@
1
- /**
2
- * Tier 1.1 (verdict-trust punch list, 2026-06-09) · RE-RUN the tests, don't
3
- * trust the agent's stdout.
4
- *
5
- * The gap this closes: `artifacts.tests` was regex-scraped from the AGENT's own
6
- * captured stdout (`parseTestSummary(textAccum)`) and never re-executed — so
7
- * "18/18 passed" was verified against the agent's own claim. A skeptic waves
8
- * that off instantly. We already check FILE claims against ground truth
9
- * (`git diff`); this extends the same "observe reality, don't trust the
10
- * narration" rule to tests: codehere independently runs the project's test
11
- * command on the real on-disk state and reads the REAL exit code.
12
- *
13
- * The authoritative signal is the EXIT CODE (a test runner exits non-zero on
14
- * failure), not the scraped count — counts are display-only. So a report that
15
- * claims "all tests pass" while the re-run exits non-zero becomes a caught lie
16
- * (verify-claim flags it `broken` once `failed > 0`).
17
- *
18
- * Safe by construction: async (never blocks the daemon), timeout-bounded,
19
- * opt-out via CODEHERE_RERUN_TESTS=0, and only ever runs the repo's OWN test
20
- * script — strictly inside the trust boundary codehere already crosses by
21
- * spawning agents that execute code in this repo.
22
- */
23
- import { spawn } from 'node:child_process';
24
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
- import { join } from 'node:path';
26
- import { parseTestSummary } from '../../infrastructure/orchestration/artifacts-extractor.js';
27
- const TIMEOUT_SETTING = Number(process.env.CODEHERE_RERUN_TIMEOUT_MS);
28
- /**
29
- * Whole milliseconds that a timer can hold; anything else falls back to two minutes. Node fires a NaN, fractional
30
- * or over-2^31 timer after 1 ms, which would time out every run.
31
- */
32
- const DEFAULT_TIMEOUT_MS = Number.isInteger(TIMEOUT_SETTING) && TIMEOUT_SETTING > 0 && TIMEOUT_SETTING <= 2 ** 31 - 1
33
- ? TIMEOUT_SETTING
34
- : 120_000;
35
- /** 500 → "0.5s", 120000 → "120s". */
36
- const seconds = (ms) => `${Number.isInteger(ms / 1000) ? ms / 1000 : (ms / 1000).toFixed(1)}s`;
37
- /** True when node's test runner printed a summary and every summary says 0 tests, or when go test found no test files. `node --test`
38
- * exits 0 for a misnamed file (`calc.tests.js`), so the exit code alone reads as a pass.
39
- * pytest already exits 5 in that case. */
40
- export function reportedZeroTests(out) {
41
- const counts = [...out.matchAll(/^[ \t]*(?:ℹ|#)[ \t]+tests[ \t]+(\d+)[ \t]*$/gm)].map((m) => Number(m[1]));
42
- if (counts.length > 0)
43
- return counts.every((n) => n === 0);
44
- // `go test ./...` exits 0 and prints `[no test files]` for every package when the module has no _test.go files.
45
- return /^\?[ \t]+.*\[no test files\]/m.test(out) && !/^(ok|FAIL|---)[ \t]/m.test(out);
46
- }
47
- /** True when the runner's own summary says tests were skipped and none passed or failed: node:test (`pass 0`,
48
- * `skipped N`), pytest's final line, and the jest or vitest `Tests` line. A run like that tested nothing. */
49
- export function reportedOnlySkipped(out) {
50
- const last = (re) => [...out.matchAll(re)].pop();
51
- const node = (k) => {
52
- const m = last(new RegExp(`^[ \\t]*(?:ℹ|#)[ \\t]+${k}[ \\t]+(\\d+)[ \\t]*$`, 'gm'));
53
- return m ? Number(m[1]) : null;
54
- };
55
- const pass = node('pass');
56
- const skipped = node('skipped');
57
- if (pass !== null && skipped !== null)
58
- return pass === 0 && (node('fail') ?? 0) === 0 && skipped > 0;
59
- const py = last(/^=+ (.*) in [\d.]+s(?: \([^)]*\))? =+$/gm);
60
- if (py)
61
- return /\b\d+ skipped\b/.test(py[1]) && !/\b\d+ (passed|failed|errors?|xpassed)\b/.test(py[1]);
62
- const js = last(/^[ \t]*Tests:?[ \t]+(.*)$/gm);
63
- if (js)
64
- return /\b\d+ skipped\b/.test(js[1]) && !/\b\d+ (passed|failed)\b/.test(js[1]);
65
- return false;
66
- }
67
- /** True if `file` exists in `cwd` and its text contains `needle` (case-insensitive). */
68
- function fileMentions(cwd, file, needle) {
69
- try {
70
- const p = join(cwd, file);
71
- return existsSync(p) && readFileSync(p, 'utf8').toLowerCase().includes(needle.toLowerCase());
72
- }
73
- catch {
74
- return false;
75
- }
76
- }
77
- /** True if `cwd` (top level, or a test/ or tests/ dir) holds a Node built-in
78
- * test file — `*.test.{mjs,cjs,js}`. Lets codehere observe reality in a plain
79
- * Node repo that uses `node --test` with no npm script or runner config (a
80
- * common setup that previously left the verifier blind — DISCOVERED via a real
81
- * V3 run, 2026-06-14). Bounded: top level + two conventional dirs, never a
82
- * recursive walk. */
83
- function hasNodeTestFiles(cwd) {
84
- const isTest = (f) => /\.test\.(mjs|cjs|js)$/.test(f);
85
- for (const dir of [cwd, join(cwd, 'test'), join(cwd, 'tests')]) {
86
- try {
87
- if (readdirSync(dir).some(isTest))
88
- return true;
89
- }
90
- catch {
91
- /* dir absent / unreadable → keep looking */
92
- }
93
- }
94
- return false;
95
- }
96
- /**
97
- * Detect a runnable test command in `cwd`, across ecosystems — so "codehere
98
- * re-runs your tests and catches a lying report" holds for Python/Go/Rust repos,
99
- * not only npm. Conservative: each branch keys off an UNAMBIGUOUS marker; we
100
- * return null (never guess) when there's nothing known to run.
101
- *
102
- * Precedence: explicit override → Node (npm test) → Go → Rust → Python (pytest)
103
- * → a JS runner config without an npm script. First match wins.
104
- */
105
- export function detectTestCommand(cwd) {
106
- // 0. Explicit per-repo override — the escape hatch for anything we can't infer
107
- // (monorepos, custom runners). e.g. CODEHERE_TEST_CMD="make test". Run via
108
- // execFile (no shell), so args are whitespace-split — a quoted arg with
109
- // spaces (CODEHERE_TEST_CMD='pytest -k "a b"') is NOT supported; use a
110
- // wrapper script for that case.
111
- const override = process.env.CODEHERE_TEST_CMD?.trim();
112
- if (override) {
113
- const [cmd, ...args] = override.split(/\s+/);
114
- return { cmd, args, display: override };
115
- }
116
- // 1. Node — the project's declared `npm test` (dominant case + this repo).
117
- const pj = join(cwd, 'package.json');
118
- if (existsSync(pj)) {
119
- try {
120
- const json = JSON.parse(readFileSync(pj, 'utf8'));
121
- const test = json.scripts?.test;
122
- // npm's default placeholder script is not a real suite — skip it.
123
- if (typeof test === 'string' && test.trim() && !/no test specified/i.test(test)) {
124
- return { cmd: 'npm', args: ['test', '--silent'], display: 'npm test' };
125
- }
126
- }
127
- catch {
128
- /* unreadable package.json → fall through to other ecosystems */
129
- }
130
- }
131
- // 2. Go — go.mod present → the standard whole-module test run.
132
- if (existsSync(join(cwd, 'go.mod'))) {
133
- return { cmd: 'go', args: ['test', './...'], display: 'go test ./...' };
134
- }
135
- // 3. Rust — Cargo.toml → `cargo test`.
136
- if (existsSync(join(cwd, 'Cargo.toml'))) {
137
- return { cmd: 'cargo', args: ['test'], display: 'cargo test' };
138
- }
139
- // 4. Python — pytest is the dominant runner. Key off pytest-specific markers
140
- // (conftest.py / pytest.ini are unambiguous). For pyproject/setup.cfg, match
141
- // the actual pytest CONFIG SECTION header — not a bare "pytest" substring,
142
- // which would fire on a comment or an unrelated dependency string and run
143
- // pytest on a project that doesn't use it.
144
- if (existsSync(join(cwd, 'pytest.ini')) ||
145
- existsSync(join(cwd, 'conftest.py')) ||
146
- fileMentions(cwd, 'pyproject.toml', '[tool.pytest') ||
147
- fileMentions(cwd, 'setup.cfg', '[tool:pytest]')) {
148
- return { cmd: 'pytest', args: ['-q'], display: 'pytest' };
149
- }
150
- // 5. A JS test runner with config but no npm `test` script.
151
- //
152
- // SECURITY (2026-08-08) · `--no-install` is load-bearing on THIS path above all
153
- // others. Bare `npx vitest` falls back to the public registry when the runner
154
- // is not installed locally, so codehere would DOWNLOAD a test runner and
155
- // execute it against the user's repo — and then report the result as
156
- // "verifier re-ran the suite", the strongest claim this product makes. The
157
- // same fallback let `npx graphify-ts` fetch an unrelated stranger's package
158
- // (fixed 86c7893); here the blast radius is the evidence itself.
159
- //
160
- // Refusing is also the semantically right answer: a runner the project does
161
- // not have installed is not the project's test suite, so re-running with it
162
- // proves nothing. When this returns nothing runnable, applyTestObservation
163
- // records an honest `testsUnobserved` reason instead — which is exactly the
164
- // abstention the eval harness scores 2/2 on.
165
- if (existsSync(join(cwd, 'vitest.config.ts')) || existsSync(join(cwd, 'vitest.config.js'))) {
166
- return { cmd: 'npx', args: ['--no-install', 'vitest', 'run'], display: 'npx vitest run' };
167
- }
168
- if (existsSync(join(cwd, 'jest.config.js')) || existsSync(join(cwd, 'jest.config.ts'))) {
169
- return { cmd: 'npx', args: ['--no-install', 'jest'], display: 'npx jest' };
170
- }
171
- // 6. Node built-in test runner — `*.test.{mjs,cjs,js}` present but no npm
172
- // script or runner config above. `node --test` self-discovers them. This is
173
- // the plain-Node case that left codehere blind to test reality (the verifier
174
- // can't catch a "tests pass" lie it never observed). Lowest precedence so a
175
- // declared script/runner always wins.
176
- if (hasNodeTestFiles(cwd)) {
177
- return { cmd: 'node', args: ['--test'], display: 'node --test' };
178
- }
179
- return null;
180
- }
181
- /**
182
- * The one command that would make the suite runnable — returned as a STRING to
183
- * SHOW the user, NEVER executed. The owner's call (2026-08-17): guide, never execute —
184
- * auto-installing runs arbitrary postinstall code, which is exactly the posture
185
- * a trust tool must not take (the same reason detectTestCommand keeps
186
- * `--no-install`). Mirrors detectTestCommand's marker-probing: pick the
187
- * ecosystem, name its install. Returns undefined when there's nothing to suggest.
188
- */
189
- export function detectRemedy(cwd) {
190
- const has = (f) => existsSync(join(cwd, f));
191
- // Node — deps missing / runner not installed → install from the lockfile.
192
- if (has('package.json')) {
193
- if (has('pnpm-lock.yaml'))
194
- return 'pnpm install';
195
- if (has('yarn.lock'))
196
- return 'yarn install';
197
- if (has('package-lock.json'))
198
- return 'npm ci';
199
- return 'npm install';
200
- }
201
- // Python — set up a venv and install, then re-check.
202
- if (has('requirements.txt')) {
203
- return 'python -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt';
204
- }
205
- if (has('pyproject.toml') || has('setup.py') || has('setup.cfg')) {
206
- return 'python -m venv .venv && . .venv/bin/activate && pip install -e .';
207
- }
208
- if (has('pytest.ini') || has('conftest.py'))
209
- return 'pip install pytest';
210
- if (has('go.mod'))
211
- return 'go mod download';
212
- if (has('Cargo.toml'))
213
- return 'cargo fetch';
214
- return undefined;
215
- }
216
- /**
217
- * Re-run the project's tests on the real on-disk state. Async + timeout-bounded.
218
- * Resolves with `ran:false` + a reason when there's nothing to run or it's
219
- * disabled — never throws.
220
- */
221
- export function rerunTests(cwd) {
222
- if (process.env.CODEHERE_RERUN_TESTS === '0') {
223
- return Promise.resolve({ ran: false, kind: 'disabled', skippedReason: 'disabled (CODEHERE_RERUN_TESTS=0)' });
224
- }
225
- const detected = detectTestCommand(cwd);
226
- if (!detected) {
227
- return Promise.resolve({ ran: false, kind: 'no-command', skippedReason: 'no known test command in this project' });
228
- }
229
- return new Promise((resolve) => {
230
- const start = Date.now();
231
- let out = '';
232
- let settled = false;
233
- const done = (r) => {
234
- if (settled)
235
- return;
236
- settled = true;
237
- resolve(r);
238
- };
239
- let child;
240
- try {
241
- child = spawn(detected.cmd, detected.args, {
242
- cwd,
243
- env: { ...process.env, CI: '1', FORCE_COLOR: '0' },
244
- stdio: ['ignore', 'pipe', 'pipe'],
245
- // Own process group so a timeout can kill the whole TREE — `npm test`
246
- // forks the real runner, and killing just the npm wrapper orphaned the
247
- // grandchild, which kept running past the timeout (self-audit 2026-06-09).
248
- detached: true,
249
- });
250
- }
251
- catch (e) {
252
- done({
253
- ran: false,
254
- kind: 'command-not-found',
255
- command: detected.display,
256
- skippedReason: `could not spawn test command: ${String(e).slice(0, 80)}`,
257
- remedy: detectRemedy(cwd),
258
- });
259
- return;
260
- }
261
- const timer = setTimeout(() => {
262
- try {
263
- // Negative pid = kill the whole process group (the detached tree), so the
264
- // forked test runner dies too, not just the npm wrapper.
265
- if (typeof child.pid === 'number')
266
- process.kill(-child.pid, 'SIGKILL');
267
- else
268
- child.kill('SIGKILL');
269
- }
270
- catch {
271
- try {
272
- child.kill('SIGKILL');
273
- }
274
- catch {
275
- /* best-effort */
276
- }
277
- }
278
- done({
279
- ran: false,
280
- kind: 'timed-out',
281
- command: detected.display,
282
- skippedReason: `test re-run timed out after ${seconds(DEFAULT_TIMEOUT_MS)}`,
283
- });
284
- }, DEFAULT_TIMEOUT_MS);
285
- const cap = (chunk) => {
286
- out += String(chunk);
287
- // bound memory — keep only the tail the parser reads
288
- if (out.length > 200_000)
289
- out = out.slice(-100_000);
290
- };
291
- child.stdout?.on('data', cap);
292
- child.stderr?.on('data', cap);
293
- child.on('error', (e) => {
294
- clearTimeout(timer);
295
- done({
296
- ran: false,
297
- kind: 'command-not-found',
298
- command: detected.display,
299
- skippedReason: `test command errored: ${String(e.message).slice(0, 80)}`,
300
- remedy: detectRemedy(cwd),
301
- });
302
- });
303
- child.on('close', (code) => {
304
- clearTimeout(timer);
305
- const ec = code ?? -1;
306
- const parsed = parseTestSummary(out);
307
- // 126/127 = the wrapper launched but the runner/deps aren't installed —
308
- // nothing was tested, so this is NOT a failing suite. Classify it as
309
- // runner-missing (with the one-command remedy) so no surface renders a
310
- // red measured-fail for an unrunnable suite.
311
- const allSkipped = ec === 0 && reportedOnlySkipped(out);
312
- const noTests = ec === 0 && (reportedZeroTests(out) || allSkipped);
313
- const kind = ec === 126 || ec === 127 ? 'runner-missing' : ec === 0 && !noTests ? 'passed' : 'failed';
314
- done({
315
- ran: true,
316
- kind,
317
- command: detected.display,
318
- exitCode: ec,
319
- passed: parsed?.passed,
320
- failed: parsed?.failed,
321
- ...(noTests ? { noTests: true } : {}),
322
- ...(allSkipped ? { allSkipped: true } : {}),
323
- durationMs: Date.now() - start,
324
- ...(kind === 'runner-missing' ? { remedy: detectRemedy(cwd) } : {}),
325
- });
326
- });
327
- });
328
- }
329
- //# sourceMappingURL=rerun-tests.js.map