codehere 1.0.3 → 2.0.0-beta.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/src/signals.js ADDED
@@ -0,0 +1,225 @@
1
+ // Deterministic signals. No AI in the decision path.
2
+
3
+ const ERROR_LINE =
4
+ /(error|exception|traceback|failed|failure|fatal|cannot|can't|not found|undefined is not|is not defined|is not a function|segmentation fault|ERR!|panic:|denied|refused|unexpected token|syntaxerror|typeerror|referenceerror|modulenotfound|no such file)/i;
5
+
6
+ const NOISE = /^(\s*at\s|\s*\^+\s*$|\s*$)/;
7
+ // A passing test whose name mentions an error ("✔ returns an error for bad input"),
8
+ // or a summary that says zero failed ("ℹ fail 0"), is not a failure.
9
+ const PASSING = /^\s*(✔|✓|√|ok \d+\b|PASS\b)|^\s*(ℹ\s*)?(fail|failed|failures|errors)\s*[:=]?\s*0\s*$/i;
10
+
11
+ export function stripAnsi(s) {
12
+ return String(s).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
13
+ }
14
+
15
+ // Pull text out of whatever shape the tool response has.
16
+ export function responseText(resp) {
17
+ if (resp == null) return '';
18
+ if (typeof resp === 'string') return resp;
19
+ const parts = [];
20
+ for (const key of ['stderr', 'stdout', 'output', 'error', 'content', 'result']) {
21
+ const v = resp[key];
22
+ if (typeof v === 'string') parts.push(v);
23
+ else if (Array.isArray(v)) parts.push(v.map((x) => (typeof x === 'string' ? x : x?.text || '')).join('\n'));
24
+ }
25
+ return parts.length ? parts.join('\n') : JSON.stringify(resp);
26
+ }
27
+
28
+ export function errorLines(text, max = 12) {
29
+ const lines = stripAnsi(text).split('\n');
30
+ const hits = [];
31
+ for (let i = 0; i < lines.length && hits.length < max; i++) {
32
+ if (ERROR_LINE.test(lines[i]) && !NOISE.test(lines[i]) && !PASSING.test(lines[i])) hits.push(lines[i].trim());
33
+ }
34
+ return hits;
35
+ }
36
+
37
+ // A stable fingerprint for "the same error again".
38
+ export function normalize(line) {
39
+ return stripAnsi(line)
40
+ .replace(/(['"`]).*?\1/g, "'…'")
41
+ .replace(/(?:[A-Za-z]:)?(?:[\w.-]*[\\/])+([\w.-]+)/g, '$1')
42
+ .replace(/0x[0-9a-f]+/gi, 'N')
43
+ .replace(/-?\d+(\.\d+)?/g, 'N')
44
+ .replace(/\s+/g, ' ')
45
+ .trim()
46
+ .slice(0, 160);
47
+ }
48
+
49
+ export function signature(text) {
50
+ const lines = errorLines(text, 3);
51
+ if (!lines.length) return null;
52
+ return normalize(lines[0]);
53
+ }
54
+
55
+ // The name of the failing test, from node --test / TAP, jest, pytest or go test output.
56
+ export function failingTest(text) {
57
+ const t = stripAnsi(text);
58
+ const m =
59
+ t.match(/^\s*(?:✖|×|✗|not ok \d+ -)\s+(?!failing tests)(.+?)(?:\s+\(?\d[\d.]*\s?m?s\)?)?\s*$/m) ||
60
+ t.match(/^\s*●\s+(.+?)\s*$/m) ||
61
+ t.match(/^FAILED\s+(\S+)/m) ||
62
+ t.match(/^--- FAIL:\s+(\S+)/m);
63
+ return m ? m[1].trim().slice(0, 120) : null;
64
+ }
65
+
66
+ // ---------- prompts (UserPromptSubmit) ----------
67
+
68
+ const words = (p) => String(p || '').trim().split(/\s+/).filter(Boolean);
69
+ // A file path, inline code, a pasted error line or a number: the prompt carries its own
70
+ // information. The bare word "error" does not count ("still error", "masih error").
71
+ const SPECIFIC = /`|[\w-]+\/[\w./-]+|\.\w{1,5}\b|\w*(error|exception)\s*[:[(]|\b(traceback|line \d+|expected|got)\b|\d{3,}/i;
72
+ // English and Indonesian words that ask for a repair. Thanks and "works now" never count.
73
+ const REPAIR =
74
+ /\b(still|again|broken|fix|fail|fails|failing|failed|wrong|same|bug|error|errors|wtf|ugh|stuck|nope|masih|lagi|rusak|gagal|eror|salah|belum|tolong|perbaiki|benerin|betulin)\b|\b(not|doesn'?t|didn'?t|isn'?t|won'?t|can'?t|gak|nggak|ngga|ga|tidak|blm)\s+(work|working|run|running|pass|passing|load|loading|jalan|bisa|berhasil)\b/i;
75
+ const POSITIVE = /\b(thanks|thank you|thx|works now|working now|great|nice|good|perfect|awesome|mantap|makasih|terima kasih|sip|berhasil|sudah bisa|udah bisa)\b/i;
76
+
77
+ // "still broken", "fix it", "masih error": short, no specifics, asks for a repair.
78
+ export function lowInfoPrompt(p) {
79
+ const w = words(p);
80
+ if (!w.length || w.length > 8 || SPECIFIC.test(p) || POSITIVE.test(p)) return false;
81
+ return REPAIR.test(p);
82
+ }
83
+
84
+ const BUILD_START =
85
+ /^\s*(please\s+|tolong\s+|can you\s+|could you\s+)?(build|create|make|add|implement|develop|design|set up|setup|buat|buatkan|bikin|bikinin|tambah|tambahkan|bangun)\b/i;
86
+ const BIG = /\b(app|application|feature|system|platform|website|site|dashboard|service|bot|game|aplikasi|fitur|sistem|situs|web)\b/i;
87
+ const SMALL = /\b(make sure|pastikan|instead|still|again|fix|bug|error|rename|change|ubah|ganti|refactor|button|color|colour|font|style)\b/i;
88
+ const CHECKABLE =
89
+ /\b(tests?|spec|expect|assert|should (return|show|be|print)|when i|given|acceptance|example|input|output|returns?|tes|uji|contoh|harus|kalau|jika)\b/i;
90
+
91
+ // "build me a todo app with login": a new feature with nothing that says how to check it.
92
+ // Questions and small edits ("make the button blue") do not count.
93
+ export function vagueFeature(p) {
94
+ return (
95
+ words(p).length >= 6 &&
96
+ BUILD_START.test(p) &&
97
+ BIG.test(p) &&
98
+ !SMALL.test(p) &&
99
+ !CHECKABLE.test(p) &&
100
+ !SPECIFIC.test(p) &&
101
+ !/\?\s*$/.test(p)
102
+ );
103
+ }
104
+
105
+ function tokens(s) {
106
+ return new Set(
107
+ String(s)
108
+ .toLowerCase()
109
+ .split(/[^a-z0-9_]+/)
110
+ .filter((w) => w.length > 2 && w !== 'n')
111
+ );
112
+ }
113
+
114
+ export function similarity(a, b) {
115
+ const A = tokens(a);
116
+ const B = tokens(b);
117
+ if (!A.size || !B.size) return 0;
118
+ let inter = 0;
119
+ for (const t of A) if (B.has(t)) inter++;
120
+ return inter / (A.size + B.size - inter);
121
+ }
122
+
123
+ // ---------- test tampering ----------
124
+
125
+ const TEST_FILE = /(^|\/)(test|tests|__tests__|spec)\/|\.(test|spec)\.[a-z]+$|_test\.(go|py)$|(^|\/)test_[^/]+\.py$/i;
126
+
127
+ const TAMPER_ADDED = [
128
+ { re: /\b(it|test|describe)\.skip\s*\(/, why: 'a test was skipped (.skip)' },
129
+ { re: /\b(xit|xdescribe|xtest)\s*\(/, why: 'a test was disabled (xit/xdescribe)' },
130
+ { re: /@pytest\.mark\.skip|unittest\.skip/, why: 'a Python test was skipped' },
131
+ { re: /@ts-nocheck/, why: 'type checking was turned off (@ts-nocheck)' },
132
+ { re: /--no-verify/, why: 'git hooks were bypassed (--no-verify)' },
133
+ { re: /expect\([^)]*\)\.(toBe|toEqual)\(\s*(true|1)\s*\)\s*;?\s*\/\/\s*(todo|temp|hack)/i, why: 'an assertion was replaced with a placeholder' },
134
+ ];
135
+
136
+ export function tamperFindings(nameStatus, diff) {
137
+ const found = [];
138
+ for (const line of nameStatus.split('\n')) {
139
+ const [status, file] = line.split(/\s+/);
140
+ if (status === 'D' && file && TEST_FILE.test(file)) found.push(`test file deleted: ${file}`);
141
+ }
142
+ let current = '';
143
+ let removedAsserts = 0;
144
+ let addedAsserts = 0;
145
+ for (const line of diff.split('\n')) {
146
+ if (line.startsWith('+++ ')) {
147
+ current = line.replace(/^\+\+\+ (b\/)?/, '');
148
+ continue;
149
+ }
150
+ if (line.startsWith('--- ')) continue;
151
+ if (line.startsWith('+')) {
152
+ for (const t of TAMPER_ADDED) if (t.re.test(line)) found.push(`${t.why} in ${current}`);
153
+ if (TEST_FILE.test(current) && /\b(expect|assert)\w*\s*\(/.test(line)) addedAsserts++;
154
+ } else if (line.startsWith('-')) {
155
+ if (TEST_FILE.test(current) && /\b(expect|assert)\w*\s*\(/.test(line)) removedAsserts++;
156
+ }
157
+ }
158
+ if (removedAsserts - addedAsserts >= 2) {
159
+ found.push(`${removedAsserts - addedAsserts} assertions removed from test files`);
160
+ }
161
+ return [...new Set(found)];
162
+ }
163
+
164
+ // ---------- redaction for anything that leaves the machine ----------
165
+
166
+ const SECRETS = [
167
+ /sk-[A-Za-z0-9_-]{16,}/g,
168
+ /sk-ant-[A-Za-z0-9_-]{16,}/g,
169
+ /gh[pousr]_[A-Za-z0-9]{20,}/g,
170
+ /AKIA[0-9A-Z]{16}/g,
171
+ /xox[abprs]-[A-Za-z0-9-]{10,}/g,
172
+ /AIza[0-9A-Za-z_-]{30,}/g,
173
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
174
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
175
+ /\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g,
176
+ ];
177
+
178
+ export function redact(text) {
179
+ let out = String(text);
180
+ for (const re of SECRETS) out = out.replace(re, '[redacted]');
181
+ out = out.replace(
182
+ /\b([A-Z0-9_]*(SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*[:=]\s*\S+/gi,
183
+ '$1=[redacted]'
184
+ );
185
+ return out;
186
+ }
187
+
188
+ // Passed and failed counts from a test runner's own summary line, or null. Exactly one
189
+ // summary must be found: with two runners in one command, or a runner that prints one per
190
+ // package, a count would cover only part of the run, so the finish line says "exit 0".
191
+ const num = (s, re) => {
192
+ const m = s.match(re);
193
+ return m ? Number(m[1]) : null;
194
+ };
195
+ const counts = (passed, failed) => (passed == null && failed == null ? null : { passed: passed || 0, failed: failed || 0 });
196
+ const SUMMARIES = [
197
+ // node --test, spec and TAP reporters: "ℹ tests 3", "ℹ pass 2", "ℹ fail 1"
198
+ { re: /^(?:ℹ|#) tests \d+$/gm, read: (t) => counts(num(t, /^(?:ℹ|#) pass (\d+)$/m), num(t, /^(?:ℹ|#) fail (\d+)$/m)) },
199
+ // jest "Tests: 2 failed, 46 passed, 48 total", vitest " Tests 2 failed | 46 passed (48)"
200
+ { re: /^\s*Tests:?\s+.*\d+ (?:passed|failed)\b.*$/gm, read: (t, line) => counts(num(line, /(\d+) passed/), num(line, /(\d+) failed/)) },
201
+ // pytest "===== 1 failed, 1 passed in 0.02s ====="
202
+ {
203
+ re: /^[=\s]*(?:\d+ \w+(?:, )?)+ in [\d.]+s\b.*$/gm,
204
+ read: (t, line) => counts(num(line, /(\d+) passed/), (num(line, /(\d+) failed/) || 0) + (num(line, /(\d+) errors?/) || 0)),
205
+ },
206
+ // mocha "46 passing" and "2 failing"
207
+ { re: /^\s*\d+ passing\b.*$/gm, read: (t) => counts(num(t, /^\s*(\d+) passing\b/m), num(t, /^\s*(\d+) failing\b/m)) },
208
+ // python unittest "Ran 5 tests in 0.001s", then "OK" or "FAILED (failures=2, errors=1)"
209
+ {
210
+ re: /^Ran \d+ tests? in [\d.]+s$/gm,
211
+ read: (t) => {
212
+ if (/expected failures|unexpected successes/.test(t)) return null; // neither passed nor failed
213
+ const failed = (num(t, /failures=(\d+)/) || 0) + (num(t, /errors=(\d+)/) || 0);
214
+ return counts(num(t, /^Ran (\d+) tests?/m) - failed - (num(t, /skipped=(\d+)/) || 0), failed);
215
+ },
216
+ },
217
+ // cargo "test result: ok. 4 passed; 0 failed", one line per test binary
218
+ { re: /^test result: \w+\. \d+ passed; \d+ failed\b.*$/gm, read: (t, line) => counts(num(line, /(\d+) passed/), num(line, /(\d+) failed/)) },
219
+ ];
220
+
221
+ export function testCounts(output) {
222
+ const t = stripAnsi(output);
223
+ const found = SUMMARIES.flatMap((s) => [...t.matchAll(s.re)].map((m) => ({ s, line: m[0] })));
224
+ return found.length === 1 ? found[0].s.read(t, found[0].line) : null;
225
+ }
@@ -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