atris 3.74.23 → 3.74.25
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/README.md +33 -0
- package/bin/atris.js +10 -0
- package/commands/gm.js +5 -3
- package/commands/rating.js +152 -0
- package/commands/scorecard.js +297 -0
- package/lib/atris-rating-combine.json +77 -0
- package/lib/atris-rating.js +179 -0
- package/lib/gm-bet.js +2 -2
- package/lib/gm-season.js +5 -3
- package/lib/known-commands.js +1 -1
- package/lib/repo-scorecard.js +1135 -0
- package/lib/team-lineup.js +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris scorecard: which AI can do this team's work, measured on the team's
|
|
4
|
+
// own past changes.
|
|
5
|
+
//
|
|
6
|
+
// Finding tasks needs no AI. A past change that touched code and tests
|
|
7
|
+
// becomes a task when its tests fail on the change's parent and pass on the
|
|
8
|
+
// change itself. The ask is what the team wrote for that change.
|
|
9
|
+
//
|
|
10
|
+
// Each try starts from the parent in a throwaway clone with no remote and
|
|
11
|
+
// none of the repo's branches, stash or config, and every push from it is
|
|
12
|
+
// pointed at an address that cannot work. Every `claude` run uses the
|
|
13
|
+
// person's own plan login: pay-per-use keys and cloud billing switches are
|
|
14
|
+
// removed first, and a run refuses to start without a plan login. The
|
|
15
|
+
// change's own tests, which the AI never sees, grade the try.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const { spawn, execFileSync } = require('child_process');
|
|
22
|
+
const { checkClaudeSpend } = require('./claude-spend-guard');
|
|
23
|
+
const { claudeModelId } = require('./model-names');
|
|
24
|
+
const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
|
|
25
|
+
|
|
26
|
+
const DEFAULT_MODELS = Object.freeze([DEFAULT_CLAUDE_RUNNER_MODEL]);
|
|
27
|
+
const SCAN = 300;
|
|
28
|
+
const MAX_TASKS = 12;
|
|
29
|
+
// A change bigger than this is a project, not a task one ask can carry.
|
|
30
|
+
const MAX_FILES = 20;
|
|
31
|
+
const MAX_LINES = 1500;
|
|
32
|
+
const MAX_ASK = 2000;
|
|
33
|
+
const TEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
34
|
+
// Finding tasks checks many changes, so a test that hangs costs less there.
|
|
35
|
+
const FIND_TIMEOUT_MS = 2 * 60 * 1000;
|
|
36
|
+
// Opus and Fable work slower and finish real tasks given 20 minutes; the rest
|
|
37
|
+
// finish inside 10 (measured on the Atris loop bench, 2026-10-28).
|
|
38
|
+
const SLOW_MODELS = /opus|fable/i;
|
|
39
|
+
const LINK_NAMES = Object.freeze(['venv', '.venv', 'node_modules']);
|
|
40
|
+
// A plan that has run out stops every later try the same way, so the run
|
|
41
|
+
// stops instead of marking the rest as errors.
|
|
42
|
+
const OUT_OF_PLAN = /usage limit|rate limit|limit reached|out of extra usage|credit balance/i;
|
|
43
|
+
|
|
44
|
+
// Any of these in a child's environment can bill someone: a pay-per-use key,
|
|
45
|
+
// a cloud provider login, or a switch that moves `claude` off the plan login.
|
|
46
|
+
const PAID_KEY = /(^|_)(API_KEY|SECRET_ACCESS_KEY|SESSION_TOKEN|BEARER_TOKEN|ACCESS_KEY|AUTH_TOKEN)$|^(ANTHROPIC|OPENAI|AZURE_OPENAI|OPENROUTER|XAI|GROK|GEMINI|GOOGLE_API|MISTRAL|COHERE|TOGETHER|GROQ|DEEPSEEK|FIREWORKS|PERPLEXITY|REPLICATE|ELEVENLABS|EXA|FIRECRAWL|AWS)_/;
|
|
47
|
+
const BILLING_SWITCHES = Object.freeze(['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY',
|
|
48
|
+
'GH_TOKEN', 'GITHUB_TOKEN']);
|
|
49
|
+
const REPO_POINTERS = Object.freeze(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
|
|
50
|
+
'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_PREFIX']);
|
|
51
|
+
|
|
52
|
+
const PUSH_PREFIXES = Object.freeze(['https://', 'http://', 'ssh://', 'git@', 'file://', '/']);
|
|
53
|
+
const ENGINE_DENY = Object.freeze(['Bash(git push:*)', 'Bash(gh:*)', 'Bash(pip install:*)', 'Bash(pip3 install:*)',
|
|
54
|
+
'Bash(python -m pip install:*)', 'Bash(python3 -m pip install:*)', 'Bash(uv pip install:*)',
|
|
55
|
+
'Bash(npm install:*)', 'Bash(npm i:*)', 'Bash(npm publish:*)', 'Bash(pnpm add:*)', 'Bash(yarn add:*)']);
|
|
56
|
+
|
|
57
|
+
// The work prompt the Atris loop bench kept after 12 rounds of measured
|
|
58
|
+
// changes (its recipe-8), with the ask in front.
|
|
59
|
+
const WORK_PROMPT = `{ask}
|
|
60
|
+
|
|
61
|
+
You are working in a fresh checkout of this repo. Make the change, check it with the repo's own tests, and stop when it works. Do not commit or push.
|
|
62
|
+
|
|
63
|
+
Work in this order:
|
|
64
|
+
|
|
65
|
+
1. Turn the request into a short checklist. List every route, function, flag, field, output line, error message, status code and ordering it names. Tests you cannot see will check this work.
|
|
66
|
+
2. Find code nearby that already does the same kind of thing and read its tests. Match its pattern, its order of checks and its error messages. Use the same helpers it uses for lookups, auth, ids, time, subprocesses and rollback, so tests that fake those helpers still control your code.
|
|
67
|
+
3. If the code already looks correct, reproduce the reported problem under the condition the request describes before you decide nothing needs to change.
|
|
68
|
+
4. Make the core change early, then refine it. Do not spend most of your time reading.
|
|
69
|
+
5. When the change adds an access or permission check, put it on every route or action the request names. Run it before input validation, lookups or any other work, so a caller without access learns nothing. Callers who were allowed before must get exactly the same result as before.
|
|
70
|
+
6. Run only the test files for the code you touched, not the whole suite. Check each checklist item, including the success path and any existing behavior the request says should stay as it is.
|
|
71
|
+
7. Before you stop, look at the full diff. Undo changes to files you did not mean to touch. Confirm the diff covers every checklist item.
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
// ---- small helpers --------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function withoutRepoPointers(env) {
|
|
77
|
+
const out = { ...env };
|
|
78
|
+
for (const name of REPO_POINTERS) delete out[name];
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function git(args, cwd) {
|
|
83
|
+
return execFileSync('git', args, {
|
|
84
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024,
|
|
85
|
+
env: withoutRepoPointers(process.env),
|
|
86
|
+
}).trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function shellQuote(value) {
|
|
90
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function repoRoot(cwd = process.cwd()) {
|
|
94
|
+
return git(['rev-parse', '--show-toplevel'], cwd);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The repo's own name and folder, the same from any of its worktrees.
|
|
98
|
+
function repoHome(root) {
|
|
99
|
+
let common = '';
|
|
100
|
+
try { common = path.resolve(root, git(['rev-parse', '--git-common-dir'], root)); } catch {}
|
|
101
|
+
return common && path.basename(common) === '.git' ? path.dirname(common) : path.resolve(root);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function repoName(root) {
|
|
105
|
+
return path.basename(repoHome(root));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function repoSlug(root) {
|
|
109
|
+
const home = repoHome(root);
|
|
110
|
+
const hash = crypto.createHash('sha1').update(home).digest('hex').slice(0, 8);
|
|
111
|
+
return `${path.basename(home).replace(/[^a-zA-Z0-9._-]/g, '-')}-${hash}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function storeDir(root, env = process.env) {
|
|
115
|
+
return path.join(env.HOME || os.homedir(), '.atris', 'scorecard', repoSlug(root));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function workDir(root) {
|
|
119
|
+
return path.join(os.tmpdir(), 'atris-scorecard', `${repoSlug(root)}-${process.pid}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function minutesFor(model, override) {
|
|
123
|
+
if (Number(override) > 0) return Number(override);
|
|
124
|
+
return SLOW_MODELS.test(String(model)) ? 20 : 10;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeModels(list) {
|
|
128
|
+
const raw = Array.isArray(list) ? list : String(list || '').split(',');
|
|
129
|
+
const models = raw.map((m) => claudeModelId(String(m).trim())).filter(Boolean);
|
|
130
|
+
return models.length ? [...new Set(models)] : [...DEFAULT_MODELS];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The full path of an executable on PATH, so a `claude` that a repo installs
|
|
134
|
+
// into its own node_modules or venv is never the one that runs.
|
|
135
|
+
function findBin(name, env = process.env) {
|
|
136
|
+
if (name.includes('/')) return name;
|
|
137
|
+
for (const dir of String(env.PATH || '').split(path.delimiter)) {
|
|
138
|
+
if (!dir) continue;
|
|
139
|
+
const candidate = path.join(dir, name);
|
|
140
|
+
try {
|
|
141
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
142
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
143
|
+
} catch {}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- environments ---------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
// A copy of env with every billing key and switch removed. Unlike the spend
|
|
151
|
+
// guard's claudeChildEnv, no override keeps the key: a scorecard runs on the
|
|
152
|
+
// plan login or not at all.
|
|
153
|
+
function stripBilling(env = process.env) {
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const [name, value] of Object.entries(env)) {
|
|
156
|
+
if (PAID_KEY.test(name) || BILLING_SWITCHES.includes(name) || REPO_POINTERS.includes(name)) continue;
|
|
157
|
+
out[name] = value;
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function testEnv(copyDir, env = process.env, extra = {}) {
|
|
163
|
+
const out = stripBilling(env);
|
|
164
|
+
delete out.VIRTUAL_ENV;
|
|
165
|
+
delete out.NODE_TEST_CONTEXT;
|
|
166
|
+
const bins = ['venv', '.venv'].map((name) => path.join(copyDir, name, 'bin')).filter((dir) => fs.existsSync(dir));
|
|
167
|
+
const nodeBin = path.join(copyDir, 'node_modules', '.bin');
|
|
168
|
+
if (fs.existsSync(nodeBin)) bins.push(nodeBin);
|
|
169
|
+
out.PATH = [...bins, out.PATH || ''].filter(Boolean).join(path.delimiter);
|
|
170
|
+
out.CI = '1';
|
|
171
|
+
out.ATRIS_NO_UPDATE_CHECK = '1';
|
|
172
|
+
out.ATRIS_SKIP_UPDATE_CHECK = '1';
|
|
173
|
+
for (const [name, value] of Object.entries(extra || {})) {
|
|
174
|
+
if (!PAID_KEY.test(name) && !BILLING_SWITCHES.includes(name)) out[name] = String(value);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The engine's environment: the test environment with the task's test
|
|
180
|
+
// settings, so its own test runs work, plus every push URL rewritten to one
|
|
181
|
+
// that cannot work and no GitHub login.
|
|
182
|
+
function engineEnv(copyDir, scratch, env = process.env, extra = {}) {
|
|
183
|
+
const out = testEnv(copyDir, env, extra);
|
|
184
|
+
out.GIT_TERMINAL_PROMPT = '0';
|
|
185
|
+
out.GH_CONFIG_DIR = path.join(scratch, '.no-gh');
|
|
186
|
+
out.GIT_CONFIG_COUNT = String(PUSH_PREFIXES.length);
|
|
187
|
+
PUSH_PREFIXES.forEach((prefix, n) => {
|
|
188
|
+
out[`GIT_CONFIG_KEY_${n}`] = 'url.no-push://atris-scorecard-blocked/.pushInsteadOf';
|
|
189
|
+
out[`GIT_CONFIG_VALUE_${n}`] = prefix;
|
|
190
|
+
});
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The Claude Code login this computer would use. Returns
|
|
195
|
+
// { ok, plan, extraUsage, message }.
|
|
196
|
+
function planLogin(env = process.env) {
|
|
197
|
+
if (env.CLAUDE_CODE_OAUTH_TOKEN) return { ok: true, plan: 'Claude plan', extraUsage: false };
|
|
198
|
+
const file = path.join(env.HOME || os.homedir(), '.claude.json');
|
|
199
|
+
let account = null;
|
|
200
|
+
try {
|
|
201
|
+
account = JSON.parse(fs.readFileSync(file, 'utf8')).oauthAccount || null;
|
|
202
|
+
} catch {}
|
|
203
|
+
if (!account || !account.accountUuid) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
message: 'atris scorecard runs on your Claude plan login and never on an API key. '
|
|
207
|
+
+ 'log in first: run `claude`, then /login with your Claude account.',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const kind = String(account.organizationType || '').replace(/^claude_/, '').replace(/_/g, ' ').trim();
|
|
211
|
+
return { ok: true, plan: kind ? `Claude ${kind} plan` : 'Claude plan', extraUsage: account.hasExtraUsageEnabled === true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- finding tasks --------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
const CODE_FILE = /\.(py|m?[jt]sx?|cjs)$/i;
|
|
217
|
+
const TEST_DIR = /(^|\/)(tests?|__tests__|spec)\//i;
|
|
218
|
+
const PY_TEST = /(^|\/)(test_[^/]*|[^/]*_test)\.py$/i;
|
|
219
|
+
const JS_TEST = /\.(test|spec)\.(m?[jt]sx?|cjs)$/i;
|
|
220
|
+
const HELPER_DIR = /(^|\/)(fixtures?|helpers?|__fixtures__|__mocks__|support|utils?|data)\//i;
|
|
221
|
+
|
|
222
|
+
function isTestPath(file) {
|
|
223
|
+
return TEST_DIR.test(file) || PY_TEST.test(file) || JS_TEST.test(file);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isRunnableTest(file) {
|
|
227
|
+
if (PY_TEST.test(file) || JS_TEST.test(file)) return true;
|
|
228
|
+
return TEST_DIR.test(file) && /\.(m?js|cjs)$/i.test(file) && !HELPER_DIR.test(file);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// name-status lines -> { hidden, runnable, source, deletedTest }. Every test
|
|
232
|
+
// path the change touched is hidden from the AI and put back for grading;
|
|
233
|
+
// only real test files go to the test runner.
|
|
234
|
+
function classifyChange(lines) {
|
|
235
|
+
const hidden = [];
|
|
236
|
+
const runnable = [];
|
|
237
|
+
const source = [];
|
|
238
|
+
let deletedTest = false;
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
const parts = String(line).split('\t');
|
|
241
|
+
const status = parts[0] || '';
|
|
242
|
+
const files = status.startsWith('R') || status.startsWith('C') ? parts.slice(1) : [parts[1]];
|
|
243
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
244
|
+
const file = files[i];
|
|
245
|
+
if (!file) continue;
|
|
246
|
+
const removed = status === 'D' || (status.startsWith('R') && i === 0);
|
|
247
|
+
if (isTestPath(file)) {
|
|
248
|
+
if (removed) { deletedTest = true; continue; }
|
|
249
|
+
if (!hidden.includes(file)) hidden.push(file);
|
|
250
|
+
if (isRunnableTest(file) && !runnable.includes(file)) runnable.push(file);
|
|
251
|
+
} else if (CODE_FILE.test(file) && !removed) {
|
|
252
|
+
source.push(file);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { hidden, runnable, source, deletedTest };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cleanMessage(text) {
|
|
260
|
+
return String(text || '').split('\n')
|
|
261
|
+
.filter((line) => !/^\s*(co-authored-by|signed-off-by|reviewed-by|change-id)\s*:/i.test(line))
|
|
262
|
+
.filter((line) => !/^\s*(🤖 )?generated with \[?claude code/i.test(line))
|
|
263
|
+
.filter((line) => !/^\s*merge (pull request|branch|remote-tracking branch)\b/i.test(line))
|
|
264
|
+
.join('\n')
|
|
265
|
+
.replace(/\s*\(#\d+\)/g, '')
|
|
266
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
267
|
+
.trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function wordCount(text) {
|
|
271
|
+
return String(text || '').split(/\s+/).filter(Boolean).length;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// What the team wrote for a change. A merged pull request is its title plus
|
|
275
|
+
// the messages of the commits it brought in, oldest first.
|
|
276
|
+
function askFor(root, change) {
|
|
277
|
+
const own = cleanMessage(change.body);
|
|
278
|
+
if (!change.merge) return own.slice(0, MAX_ASK);
|
|
279
|
+
const raw = git(['log', '--no-merges', '--reverse', '--format=%B%x1e', '-n10', `${change.base}..${change.fix}`], root);
|
|
280
|
+
const messages = raw.split('\x1e').map(cleanMessage).filter(Boolean);
|
|
281
|
+
const title = own.split('\n')[0].trim();
|
|
282
|
+
// A commit that opens with the pull request's title already carries it.
|
|
283
|
+
const parts = own && !messages.some((message) => message.split('\n')[0].trim() === title) ? [own] : [];
|
|
284
|
+
for (const message of messages) {
|
|
285
|
+
if (!parts.includes(message)) parts.push(message);
|
|
286
|
+
}
|
|
287
|
+
return parts.join('\n\n').slice(0, MAX_ASK).trim();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Past changes on the current branch, newest first: one per first-parent
|
|
291
|
+
// commit, so a merged pull request is one change.
|
|
292
|
+
function listChanges(root, scan = SCAN) {
|
|
293
|
+
const raw = git(['log', '--first-parent', `-n${scan}`, '--format=%H%x00%P%x00%B%x1e', 'HEAD'], root);
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const record of raw.split('\x1e')) {
|
|
296
|
+
const [sha, parents, body] = record.replace(/^\s+/, '').split('\x00');
|
|
297
|
+
if (!sha || !parents) continue;
|
|
298
|
+
const parentList = parents.trim().split(/\s+/).filter(Boolean);
|
|
299
|
+
if (!parentList.length) continue;
|
|
300
|
+
out.push({ fix: sha.trim(), base: parentList[0], body: body || '', merge: parentList.length > 1 });
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const PY_MARKERS = Object.freeze(['pyproject.toml', 'setup.py', 'setup.cfg', 'pytest.ini', 'tox.ini', 'requirements.txt']);
|
|
306
|
+
|
|
307
|
+
// The nearest folder above file that holds one of markers, '' for the repo root.
|
|
308
|
+
function projectDir(root, file, markers) {
|
|
309
|
+
let dir = path.posix.dirname(file);
|
|
310
|
+
for (;;) {
|
|
311
|
+
const here = dir === '.' ? '' : dir;
|
|
312
|
+
if (markers.some((name) => fs.existsSync(path.join(root, here, name)))) return here;
|
|
313
|
+
if (!here) return '';
|
|
314
|
+
dir = path.posix.dirname(dir);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function readConfig(root, configPath) {
|
|
319
|
+
const file = configPath ? path.resolve(configPath) : path.join(root, '.atris', 'scorecard.json');
|
|
320
|
+
let text;
|
|
321
|
+
try {
|
|
322
|
+
text = fs.readFileSync(file, 'utf8');
|
|
323
|
+
} catch {
|
|
324
|
+
if (configPath) throw new Error(`could not read ${file}`);
|
|
325
|
+
return {};
|
|
326
|
+
}
|
|
327
|
+
let parsed;
|
|
328
|
+
try {
|
|
329
|
+
parsed = JSON.parse(text);
|
|
330
|
+
} catch {
|
|
331
|
+
throw new Error(`${file} is not valid json`);
|
|
332
|
+
}
|
|
333
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// How to run a task's tests: the first configured entry whose pattern
|
|
337
|
+
// matches every file, else pytest, vitest, jest or node --test from the
|
|
338
|
+
// nearest project folder. null when the files need different runners.
|
|
339
|
+
function resolveRunner(root, runnable, config = {}) {
|
|
340
|
+
for (const entry of config.tests || []) {
|
|
341
|
+
if (!entry || !entry.cmd) continue;
|
|
342
|
+
let pattern;
|
|
343
|
+
try { pattern = new RegExp(entry.match || '.'); } catch { continue; }
|
|
344
|
+
if (runnable.every((file) => pattern.test(file))) {
|
|
345
|
+
return {
|
|
346
|
+
kind: 'custom',
|
|
347
|
+
dir: String(entry.dir || '').replace(/^\.\/?/, '').replace(/\/$/, ''),
|
|
348
|
+
cmd: String(entry.cmd),
|
|
349
|
+
env: entry.env && typeof entry.env === 'object' ? entry.env : {},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const py = runnable.filter((file) => file.endsWith('.py'));
|
|
354
|
+
const js = runnable.filter((file) => !file.endsWith('.py'));
|
|
355
|
+
if (!runnable.length || (py.length && js.length)) return null;
|
|
356
|
+
if (py.length) {
|
|
357
|
+
const dirs = [...new Set(py.map((file) => projectDir(root, file, PY_MARKERS)))];
|
|
358
|
+
return dirs.length === 1 ? { kind: 'pytest', dir: dirs[0] } : null;
|
|
359
|
+
}
|
|
360
|
+
const dirs = [...new Set(js.map((file) => projectDir(root, file, ['package.json'])))];
|
|
361
|
+
if (dirs.length !== 1) return null;
|
|
362
|
+
let pkg = {};
|
|
363
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(root, dirs[0], 'package.json'), 'utf8')); } catch {}
|
|
364
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
365
|
+
if (deps.vitest) return { kind: 'vitest', dir: dirs[0] };
|
|
366
|
+
if (deps.jest) return { kind: 'jest', dir: dirs[0] };
|
|
367
|
+
if (js.some((file) => /\.(c|m)?tsx?$/.test(file))) return null;
|
|
368
|
+
return { kind: 'node', dir: dirs[0] };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function pythonFor(copyDir, dir) {
|
|
372
|
+
for (const base of [dir, '']) {
|
|
373
|
+
for (const name of ['venv', '.venv']) {
|
|
374
|
+
const candidate = path.join(copyDir, base || '', name, 'bin', 'python');
|
|
375
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return 'python3';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function runnerCommand(runner, files, copyDir) {
|
|
382
|
+
const cwd = path.join(copyDir, runner.dir || '');
|
|
383
|
+
const rel = files.map((file) => (runner.dir ? path.posix.relative(runner.dir, file) : file));
|
|
384
|
+
if (runner.kind === 'custom') {
|
|
385
|
+
const quoted = rel.map(shellQuote).join(' ');
|
|
386
|
+
const cmd = runner.cmd.includes('{files}') ? runner.cmd.split('{files}').join(quoted) : `${runner.cmd} ${quoted}`;
|
|
387
|
+
return { cmd: '/bin/sh', args: ['-c', cmd], cwd };
|
|
388
|
+
}
|
|
389
|
+
if (runner.kind === 'pytest') {
|
|
390
|
+
return { cmd: pythonFor(copyDir, runner.dir), args: ['-m', 'pytest', '-q', '-p', 'no:cacheprovider', ...rel], cwd };
|
|
391
|
+
}
|
|
392
|
+
if (runner.kind === 'vitest') return { cmd: 'npx', args: ['--no-install', 'vitest', 'run', ...rel], cwd };
|
|
393
|
+
if (runner.kind === 'jest') return { cmd: 'npx', args: ['--no-install', 'jest', ...rel], cwd };
|
|
394
|
+
// node 22 prints TAP when the output is not a terminal; ask for the readable report.
|
|
395
|
+
return { cmd: process.execPath, args: ['--test', '--test-reporter=spec', ...rel], cwd };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function runnerLabel(runner) {
|
|
399
|
+
if (!runner) return 'unknown';
|
|
400
|
+
if (runner.kind === 'custom') return 'your test command';
|
|
401
|
+
return runner.kind === 'node' ? 'node --test' : runner.kind;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- processes ------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
const LIVE = new Set();
|
|
407
|
+
|
|
408
|
+
function stopAll() {
|
|
409
|
+
for (const child of LIVE) {
|
|
410
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch {}
|
|
411
|
+
}
|
|
412
|
+
LIVE.clear();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Run one command in its own process group so a time limit stops everything
|
|
416
|
+
// it started. With onLine, stdout goes to it line by line instead of being
|
|
417
|
+
// kept, and onLine returning 'stop' ends the run. Resolves
|
|
418
|
+
// { code, stdout, stderr, timedOut, stopped, ms }.
|
|
419
|
+
function runProcess(cmd, args, { cwd, env, input, timeoutMs, onLine }) {
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
const started = Date.now();
|
|
422
|
+
let stdout = '';
|
|
423
|
+
let stderr = '';
|
|
424
|
+
let pending = '';
|
|
425
|
+
let timedOut = false;
|
|
426
|
+
let stopped = false;
|
|
427
|
+
let child;
|
|
428
|
+
try {
|
|
429
|
+
child = spawn(cmd, args, { cwd, env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
430
|
+
} catch (err) {
|
|
431
|
+
resolve({ code: 127, stdout: '', stderr: String(err.message || err), timedOut: false, ms: 0 });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
LIVE.add(child);
|
|
435
|
+
const timer = setTimeout(() => {
|
|
436
|
+
timedOut = true;
|
|
437
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch {}
|
|
438
|
+
}, Math.max(1000, Number(timeoutMs) || TEST_TIMEOUT_MS));
|
|
439
|
+
const feed = (line) => {
|
|
440
|
+
if (stopped || !line.trim() || onLine(line) !== 'stop') return;
|
|
441
|
+
stopped = true;
|
|
442
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch {}
|
|
443
|
+
};
|
|
444
|
+
child.stdout.on('data', (chunk) => {
|
|
445
|
+
if (!onLine) {
|
|
446
|
+
stdout += chunk;
|
|
447
|
+
if (stdout.length > 8e6) stdout = stdout.slice(-4e6);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
pending += chunk;
|
|
451
|
+
let nl = pending.indexOf('\n');
|
|
452
|
+
while (nl >= 0) {
|
|
453
|
+
feed(pending.slice(0, nl));
|
|
454
|
+
pending = pending.slice(nl + 1);
|
|
455
|
+
nl = pending.indexOf('\n');
|
|
456
|
+
}
|
|
457
|
+
if (pending.length > 16e6) pending = '';
|
|
458
|
+
});
|
|
459
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8e6) stderr = stderr.slice(-4e6); });
|
|
460
|
+
child.on('error', (err) => { stderr += String(err.message || err); });
|
|
461
|
+
child.on('close', (code) => {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
LIVE.delete(child);
|
|
464
|
+
if (onLine) feed(pending);
|
|
465
|
+
resolve({ code: code === null ? 137 : code, stdout, stderr, timedOut, stopped, ms: Date.now() - started });
|
|
466
|
+
});
|
|
467
|
+
child.stdin.on('error', () => {});
|
|
468
|
+
child.stdin.end(input === undefined ? '' : String(input));
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// The first thing a failing test run says it wanted: pytest's short summary
|
|
473
|
+
// reason or its first E line, else the first missing module, error or
|
|
474
|
+
// assertion message.
|
|
475
|
+
function failureReason(output) {
|
|
476
|
+
const lines = String(output || '').split('\n').map((line) => line.trim());
|
|
477
|
+
let unnamed = '';
|
|
478
|
+
for (const line of lines) {
|
|
479
|
+
const match = /^(?:FAILED|ERROR) (\S+)(?: - (.+))?$/.exec(line);
|
|
480
|
+
if (match && match[2]) return match[2].slice(0, 200);
|
|
481
|
+
if (match && !unnamed) unnamed = `${match[1]} could not run`;
|
|
482
|
+
}
|
|
483
|
+
for (const line of lines) {
|
|
484
|
+
const match = /^E\s+(\w*(?:Error|Exception)\w*: .+)$/.exec(line);
|
|
485
|
+
if (match) return match[1].slice(0, 200);
|
|
486
|
+
}
|
|
487
|
+
for (const line of lines) {
|
|
488
|
+
const match = /Cannot find module '[^']+'/.exec(line);
|
|
489
|
+
if (match) return match[0];
|
|
490
|
+
}
|
|
491
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
492
|
+
// node 24 writes `TypeError [Error]: ...`; the bracket tells a reader nothing.
|
|
493
|
+
const match = /(AssertionError|TypeError|ReferenceError|SyntaxError|RangeError|Error)(?: \[[^\]]*\])?(: .+)/.exec(lines[i]);
|
|
494
|
+
if (!match) continue;
|
|
495
|
+
let reason = `${match[1]}${match[2]}`;
|
|
496
|
+
// node's assertion headline ends in a colon; the values come next.
|
|
497
|
+
for (let j = i + 1; reason.endsWith(':') && j < lines.length && j <= i + 6; j += 1) {
|
|
498
|
+
if (lines[j] && !/^[+-]\s*(actual|expected)/i.test(lines[j])) reason = `${reason} ${lines[j]}`;
|
|
499
|
+
}
|
|
500
|
+
return reason.replace(/\s+/g, ' ').slice(0, 200);
|
|
501
|
+
}
|
|
502
|
+
// node's TAP report, which a custom test command may still print.
|
|
503
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
504
|
+
const match = /^error: '(.+)'$/.exec(lines[i]);
|
|
505
|
+
if (!match) continue;
|
|
506
|
+
const name = lines.slice(i + 1, i + 4).map((line) => /^name: '(\w+)'$/.exec(line)).find(Boolean);
|
|
507
|
+
return `${name ? `${name[1]}: ` : ''}${match[1]}`.slice(0, 200);
|
|
508
|
+
}
|
|
509
|
+
for (const line of lines) {
|
|
510
|
+
const match = /^expected: (.+?),?$/.exec(line);
|
|
511
|
+
if (match) return `expected ${match[1]}`.slice(0, 200);
|
|
512
|
+
}
|
|
513
|
+
if (lines.some((line) => line.includes('MODULE_NOT_FOUND') || line.startsWith('requireStack:'))) {
|
|
514
|
+
return 'a module the tests load does not exist yet';
|
|
515
|
+
}
|
|
516
|
+
return unnamed;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function runTests(task, copyDir, env = process.env, timeoutMs = TEST_TIMEOUT_MS) {
|
|
520
|
+
const { cmd, args, cwd } = runnerCommand(task.runner, task.runnable, copyDir);
|
|
521
|
+
const res = await runProcess(cmd, args, {
|
|
522
|
+
cwd, env: testEnv(copyDir, env, task.runner.env), timeoutMs,
|
|
523
|
+
});
|
|
524
|
+
const output = `${res.stdout}\n${res.stderr}`.trim();
|
|
525
|
+
const ok = res.code === 0 && !res.timedOut;
|
|
526
|
+
return {
|
|
527
|
+
ok,
|
|
528
|
+
timedOut: res.timedOut,
|
|
529
|
+
tail: output.split('\n').slice(-25).join('\n'),
|
|
530
|
+
reason: ok ? '' : (res.timedOut ? 'the tests ran past their time limit' : failureReason(output)),
|
|
531
|
+
seconds: Math.round(res.ms / 1000),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---- throwaway copies -----------------------------------------------------
|
|
536
|
+
|
|
537
|
+
function excludeInCopy(dest, paths) {
|
|
538
|
+
if (!paths.length) return;
|
|
539
|
+
const info = path.join(dest, '.git', 'info');
|
|
540
|
+
fs.mkdirSync(info, { recursive: true });
|
|
541
|
+
fs.appendFileSync(path.join(info, 'exclude'), `\n${paths.map((p) => `/${p}`).join('\n')}\n`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function topDirs(root) {
|
|
545
|
+
const dirs = [''];
|
|
546
|
+
try {
|
|
547
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
548
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && !LINK_NAMES.includes(entry.name)) dirs.push(entry.name);
|
|
549
|
+
}
|
|
550
|
+
} catch {}
|
|
551
|
+
return dirs;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Installed packages are linked in from the real checkout so tests can
|
|
555
|
+
// import them.
|
|
556
|
+
function linkInstalled(root, dest) {
|
|
557
|
+
const linked = [];
|
|
558
|
+
for (const dir of topDirs(root)) {
|
|
559
|
+
for (const name of LINK_NAMES) {
|
|
560
|
+
const src = path.join(root, dir, name);
|
|
561
|
+
const dst = path.join(dest, dir, name);
|
|
562
|
+
if (!fs.existsSync(src) || fs.existsSync(dst) || !fs.existsSync(path.dirname(dst))) continue;
|
|
563
|
+
try {
|
|
564
|
+
fs.symlinkSync(src, dst);
|
|
565
|
+
linked.push(path.posix.join(dir, name));
|
|
566
|
+
} catch {}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
excludeInCopy(dest, linked);
|
|
570
|
+
return linked;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// .env files hold settings tests may need. Copied only when asked, read-only,
|
|
574
|
+
// with every billing key left out.
|
|
575
|
+
function copyEnvFiles(root, dest) {
|
|
576
|
+
const copied = [];
|
|
577
|
+
for (const dir of topDirs(root)) {
|
|
578
|
+
const src = path.join(root, dir, '.env');
|
|
579
|
+
const dst = path.join(dest, dir, '.env');
|
|
580
|
+
if (!fs.existsSync(src) || fs.existsSync(dst) || !fs.existsSync(path.dirname(dst))) continue;
|
|
581
|
+
const kept = fs.readFileSync(src, 'utf8').split('\n').filter((line) => {
|
|
582
|
+
const name = line.split('=')[0].replace(/^\s*export\s+/, '').trim();
|
|
583
|
+
return !name || name.startsWith('#') || !(PAID_KEY.test(name) || BILLING_SWITCHES.includes(name));
|
|
584
|
+
});
|
|
585
|
+
fs.writeFileSync(dst, kept.join('\n'), { mode: 0o400 });
|
|
586
|
+
copied.push(path.posix.join(dir, '.env'));
|
|
587
|
+
}
|
|
588
|
+
excludeInCopy(dest, copied);
|
|
589
|
+
return copied;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// A clone that borrows the repo's objects but shares none of its branches,
|
|
593
|
+
// stash or config, and has no remote.
|
|
594
|
+
function makeCopy(root, ref, dest, opts = {}) {
|
|
595
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
596
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
597
|
+
git(['clone', '-q', '--shared', '--no-checkout', root, dest], path.dirname(dest));
|
|
598
|
+
git(['remote', 'remove', 'origin'], dest);
|
|
599
|
+
git(['checkout', '-q', '--detach', ref], dest);
|
|
600
|
+
linkInstalled(root, dest);
|
|
601
|
+
if (opts.withEnv) copyEnvFiles(root, dest);
|
|
602
|
+
return dest;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ---- mining ---------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
function taskId(change) {
|
|
608
|
+
const words = change.ask.split('\n')[0].toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
609
|
+
return `${change.fix.slice(0, 7)}-${words.slice(0, 40).replace(/-$/, '') || 'change'}`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ---- the names a change's tests call --------------------------------------
|
|
613
|
+
|
|
614
|
+
const JS_FILE = /\.(m?[jt]sx?|cjs)$/i;
|
|
615
|
+
const NAME = /^[A-Za-z_$][\w$]*$/;
|
|
616
|
+
const MAX_NAMED_FILES = 6;
|
|
617
|
+
const MAX_NAMES = 10;
|
|
618
|
+
|
|
619
|
+
function escapeRe(text) {
|
|
620
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function uncomment(code) {
|
|
624
|
+
return String(code).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// What other code can reach in a file: a JS file's exports, or a Python
|
|
628
|
+
// file's functions, classes and top-level names.
|
|
629
|
+
function publicNames(file, text) {
|
|
630
|
+
const out = new Set();
|
|
631
|
+
const add = (name) => {
|
|
632
|
+
const clean = String(name || '').trim();
|
|
633
|
+
if (NAME.test(clean) && clean.length > 1 && !/^__\w+__$/.test(clean)) out.add(clean);
|
|
634
|
+
};
|
|
635
|
+
const code = String(text || '');
|
|
636
|
+
if (JS_FILE.test(file)) {
|
|
637
|
+
for (const m of code.matchAll(/(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g)) add(m[1]);
|
|
638
|
+
for (const m of code.matchAll(/^export\s+(?:default\s+)?(?:async\s+)?(?:function\s*\*?|class|const|let|var)\s*([A-Za-z_$][\w$]*)/gm)) add(m[1]);
|
|
639
|
+
for (const m of code.matchAll(/module\.exports\s*=\s*\{([^}]*)\}/g)) {
|
|
640
|
+
for (const part of uncomment(m[1]).split(',')) add(part.split(':')[0]);
|
|
641
|
+
}
|
|
642
|
+
for (const m of code.matchAll(/^export\s*\{([^}]*)\}/gm)) {
|
|
643
|
+
for (const part of uncomment(m[1]).split(',')) add(part.trim().split(/\s+as\s+/).pop());
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
for (const m of code.matchAll(/^[ \t]*(?:async[ \t]+)?(?:def|class)[ \t]+([A-Za-z_]\w*)/gm)) add(m[1]);
|
|
648
|
+
for (const m of code.matchAll(/^([A-Za-z_]\w*)[ \t]*(?::[^=\n]+)?=(?!=)/gm)) add(m[1]);
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// A test calls a name when it reads it off a module, calls it, or imports it.
|
|
653
|
+
function testCalls(tests, name) {
|
|
654
|
+
const n = escapeRe(name);
|
|
655
|
+
if (new RegExp(`\\.${n}(?![\\w$])`).test(tests)) return true;
|
|
656
|
+
if (new RegExp(`(?:^|[^\\w$.])${n}\\s*\\(`, 'm').test(tests)) return true;
|
|
657
|
+
const word = new RegExp(`(?:^|[^\\w$])${n}(?![\\w$])`);
|
|
658
|
+
return tests.split('\n').some((line) => /\b(?:import|require)\b/.test(line) && word.test(line));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function firstUse(tests, name) {
|
|
662
|
+
const at = tests.search(new RegExp(`(?:^|[^\\w$])${escapeRe(name)}(?![\\w$])`, 'm'));
|
|
663
|
+
return at < 0 ? Infinity : at;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// The text inside the bracket that opens at code[at], and where it ends.
|
|
667
|
+
function insideBrackets(code, at) {
|
|
668
|
+
let depth = 0;
|
|
669
|
+
for (let i = at; i < code.length && i < at + 800; i += 1) {
|
|
670
|
+
const ch = code[i];
|
|
671
|
+
if (ch === '(' || ch === '[' || ch === '{') depth += 1;
|
|
672
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
673
|
+
depth -= 1;
|
|
674
|
+
if (depth === 0) return { text: code.slice(at + 1, i), end: i + 1 };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// How a name is called, as its definition writes it: decide(readings, day).
|
|
681
|
+
// A name with no function definition, like a constant, stays a bare name.
|
|
682
|
+
function signatureOf(file, code, name) {
|
|
683
|
+
if (!code) return name;
|
|
684
|
+
const n = escapeRe(name);
|
|
685
|
+
const js = JS_FILE.test(file);
|
|
686
|
+
// each start ends on the opening bracket; `then` is what must follow the
|
|
687
|
+
// closing one, so a call or a bracketed value is not read as a definition.
|
|
688
|
+
const starts = js ? [
|
|
689
|
+
{ re: new RegExp(`(?:^|[^\\w$.])(?:async\\s+)?function\\s*\\*?\\s*${n}\\s*\\(`, 'gm') },
|
|
690
|
+
{ re: new RegExp(`(?:^|[^\\w$])${n}\\s*=\\s*(?:async\\s+)?function\\s*\\*?\\s*[\\w$]*\\s*\\(`, 'gm') },
|
|
691
|
+
{ re: new RegExp(`(?:^|[^\\w$])${n}\\s*=\\s*(?:async\\s+)?\\(`, 'gm'), then: /^\s*=>/ },
|
|
692
|
+
{ re: new RegExp(`^[ \\t]+(?:static\\s+)?(?:async\\s+)?${n}\\s*\\(`, 'gm'), then: /^\s*\{/ },
|
|
693
|
+
] : [{ re: new RegExp(`^[ \\t]*(?:async[ \\t]+)?def[ \\t]+${n}[ \\t]*\\(`, 'gm') }];
|
|
694
|
+
for (const start of starts) {
|
|
695
|
+
for (const match of code.matchAll(start.re)) {
|
|
696
|
+
const inside = insideBrackets(code, match.index + match[0].length - 1);
|
|
697
|
+
if (!inside || (start.then && !start.then.test(code.slice(inside.end, inside.end + 20)))) continue;
|
|
698
|
+
let args = inside.text.replace(/\s+/g, ' ').replace(/,\s*$/, '').trim();
|
|
699
|
+
if (!js) args = args.replace(/^(?:self|cls)\b\s*,?\s*/, '');
|
|
700
|
+
if (args.length > 120) args = `${args.slice(0, 117)}...`;
|
|
701
|
+
const returns = js ? null : /^\s*->\s*([^:\n]+?)\s*:/.exec(code.slice(inside.end, inside.end + 200));
|
|
702
|
+
return `${name}(${args})${returns ? ` -> ${returns[1]}` : ''}`;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (js) {
|
|
706
|
+
const arrow = new RegExp(`(?:^|[^\\w$])${n}\\s*=\\s*(?:async\\s+)?([A-Za-z_$][\\w$]*)\\s*=>`, 'm').exec(code);
|
|
707
|
+
if (arrow) return `${name}(${arrow[1]})`;
|
|
708
|
+
}
|
|
709
|
+
return name;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// The word a test uses to load a file: lib/rsi-loop.js -> rsi-loop.
|
|
713
|
+
function moduleWord(file) {
|
|
714
|
+
const base = path.posix.basename(file).replace(/\.[^.]+$/, '');
|
|
715
|
+
return base === 'index' || base === '__init__' ? path.posix.basename(path.posix.dirname(file)) : base;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// The names a change's tests call that the change itself added or changed:
|
|
719
|
+
// new files the tests load, new functions, classes or exports, and functions
|
|
720
|
+
// whose arguments changed, each written the way it is called. A good ticket
|
|
721
|
+
// names these; a commit message rarely does, and no model can guess a name
|
|
722
|
+
// nobody wrote down.
|
|
723
|
+
function namesFor(root, change) {
|
|
724
|
+
const renamed = new Map();
|
|
725
|
+
for (const line of change.status || []) {
|
|
726
|
+
const parts = String(line).split('\t');
|
|
727
|
+
if (/^R/.test(parts[0] || '') && parts[2]) renamed.set(parts[2], parts[1]);
|
|
728
|
+
}
|
|
729
|
+
const show = (sha, file) => {
|
|
730
|
+
try { return git(['show', `${sha}:${file}`], root); } catch { return null; }
|
|
731
|
+
};
|
|
732
|
+
const tests = change.hidden.map((file) => show(change.fix, file) || '').join('\n');
|
|
733
|
+
const out = [];
|
|
734
|
+
for (const file of change.source) {
|
|
735
|
+
if (out.length >= MAX_NAMED_FILES) break;
|
|
736
|
+
const after = show(change.fix, file);
|
|
737
|
+
if (after === null) continue;
|
|
738
|
+
const before = show(change.base, renamed.get(file) || file);
|
|
739
|
+
const had = publicNames(file, before);
|
|
740
|
+
const names = [...publicNames(file, after)]
|
|
741
|
+
.filter((name) => testCalls(tests, name))
|
|
742
|
+
.map((name) => ({ name, call: signatureOf(file, after, name) }))
|
|
743
|
+
.filter(({ name, call }) => !had.has(name) || call !== signatureOf(file, before, name))
|
|
744
|
+
.sort((a, b) => firstUse(tests, a.name) - firstUse(tests, b.name))
|
|
745
|
+
.slice(0, MAX_NAMES)
|
|
746
|
+
.map(({ call }) => call);
|
|
747
|
+
const loaded = before === null && new RegExp(`(?:^|[^\\w$-])${escapeRe(moduleWord(file))}(?![\\w$-])`).test(tests);
|
|
748
|
+
if (names.length || loaded) out.push({ file, new: before === null, names });
|
|
749
|
+
}
|
|
750
|
+
return out;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// The ask a model gets: what the team wrote, then the names its tests call.
|
|
754
|
+
function askText(task) {
|
|
755
|
+
const named = Array.isArray(task.names) ? task.names.filter((entry) => entry && entry.file) : [];
|
|
756
|
+
if (!named.length) return task.ask;
|
|
757
|
+
const lines = named.map((entry) => {
|
|
758
|
+
const label = entry.new ? `${entry.file} (a new file)` : entry.file;
|
|
759
|
+
return entry.names && entry.names.length ? `- ${label}: ${entry.names.join(', ')}` : `- ${label}`;
|
|
760
|
+
});
|
|
761
|
+
return `${task.ask}\n\nThe checks for this change call these, so match them exactly:\n${lines.join('\n')}`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function hashAsk(text) {
|
|
765
|
+
return crypto.createHash('sha1').update(String(text)).digest('hex').slice(0, 12);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// A result counts only for the exact ask it was given. Rows saved before
|
|
769
|
+
// asks carried names were given the bare ask.
|
|
770
|
+
function sameAsk(row, task) {
|
|
771
|
+
return row.id === task.id && (row.ask_key || hashAsk(task.ask)) === hashAsk(askText(task));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Turn past changes into graded tasks. No AI runs here: each candidate's
|
|
775
|
+
// tests must pass on the change and fail on its parent with the change's
|
|
776
|
+
// tests put back. Returns { tasks, looked, candidates, skipped }.
|
|
777
|
+
async function findTasks(root, opts = {}) {
|
|
778
|
+
const max = Number(opts.max) > 0 ? Number(opts.max) : MAX_TASKS;
|
|
779
|
+
const config = opts.config || readConfig(root, opts.configPath);
|
|
780
|
+
const changes = listChanges(root, Number(opts.scan) > 0 ? Number(opts.scan) : SCAN);
|
|
781
|
+
const skipped = { no_tests: 0, too_big: 0, short_ask: 0, unknown_runner: 0, fails_after: 0, passes_before: 0 };
|
|
782
|
+
const candidates = [];
|
|
783
|
+
for (const change of changes) {
|
|
784
|
+
const lines = git(['diff', '--name-status', '-M', change.base, change.fix], root).split('\n').filter(Boolean);
|
|
785
|
+
const shape = classifyChange(lines);
|
|
786
|
+
if (!shape.runnable.length || !shape.source.length || shape.deletedTest) { skipped.no_tests += 1; continue; }
|
|
787
|
+
const numstat = git(['diff', '--numstat', change.base, change.fix], root).split('\n').filter(Boolean);
|
|
788
|
+
const changed = numstat.reduce((sum, line) => {
|
|
789
|
+
const [a, d] = line.split('\t');
|
|
790
|
+
return sum + (Number(a) || 0) + (Number(d) || 0);
|
|
791
|
+
}, 0);
|
|
792
|
+
if (lines.length > MAX_FILES || changed > MAX_LINES) { skipped.too_big += 1; continue; }
|
|
793
|
+
const ask = askFor(root, change);
|
|
794
|
+
if (wordCount(ask) < 4) { skipped.short_ask += 1; continue; }
|
|
795
|
+
const runner = resolveRunner(root, shape.runnable, config);
|
|
796
|
+
if (!runner) { skipped.unknown_runner += 1; continue; }
|
|
797
|
+
candidates.push({ ...change, ...shape, ask, runner, lines: changed, status: lines });
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const tasks = [];
|
|
801
|
+
if (!candidates.length) return { tasks, looked: changes.length, candidates: 0, skipped };
|
|
802
|
+
const scratch = path.join(opts.workDir || workDir(root), 'find');
|
|
803
|
+
const onCheck = typeof opts.onCheck === 'function' ? opts.onCheck : () => {};
|
|
804
|
+
const findTimeout = Number(opts.testTimeoutMs) > 0 ? Number(opts.testTimeoutMs) : FIND_TIMEOUT_MS;
|
|
805
|
+
makeCopy(root, candidates[0].fix, scratch, opts);
|
|
806
|
+
try {
|
|
807
|
+
for (const cand of candidates) {
|
|
808
|
+
if (tasks.length >= max) break;
|
|
809
|
+
const task = {
|
|
810
|
+
id: taskId(cand), fix: cand.fix, base: cand.base, ask: cand.ask, hidden: cand.hidden,
|
|
811
|
+
runnable: cand.runnable, runner: cand.runner, lines: cand.lines,
|
|
812
|
+
};
|
|
813
|
+
git(['checkout', '-q', '-f', '--detach', cand.fix], scratch);
|
|
814
|
+
git(['clean', '-q', '-fd'], scratch);
|
|
815
|
+
const after = await runTests(task, scratch, opts.env, findTimeout);
|
|
816
|
+
if (!after.ok) { skipped.fails_after += 1; onCheck(task, 'fails_after', after); continue; }
|
|
817
|
+
git(['checkout', '-q', '-f', '--detach', cand.base], scratch);
|
|
818
|
+
git(['clean', '-q', '-fd'], scratch);
|
|
819
|
+
git(['checkout', '-q', cand.fix, '--', ...cand.hidden], scratch);
|
|
820
|
+
const before = await runTests(task, scratch, opts.env, findTimeout);
|
|
821
|
+
if (before.ok) { skipped.passes_before += 1; onCheck(task, 'passes_before', before); continue; }
|
|
822
|
+
task.test_seconds = after.seconds;
|
|
823
|
+
task.names = namesFor(root, cand);
|
|
824
|
+
tasks.push(task);
|
|
825
|
+
onCheck(task, 'found', after);
|
|
826
|
+
}
|
|
827
|
+
} finally {
|
|
828
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
829
|
+
}
|
|
830
|
+
return { tasks, looked: changes.length, candidates: candidates.length, skipped };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ---- tries ----------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
function engineArgs(model) {
|
|
836
|
+
return ['-p', '--model', model, '--permission-mode', 'acceptEdits',
|
|
837
|
+
'--allowedTools', 'Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep',
|
|
838
|
+
'--disallowedTools', ...ENGINE_DENY,
|
|
839
|
+
'--settings', JSON.stringify({ disableAllHooks: true }), '--strict-mcp-config',
|
|
840
|
+
'--output-format', 'stream-json', '--verbose', '--no-session-persistence'];
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Everything that must hold before any AI runs: a plan login, no surprise
|
|
844
|
+
// overage, the spend guard's yes for every model past Haiku, and a `claude`
|
|
845
|
+
// to run. Returns { plan, extraUsage, bin }.
|
|
846
|
+
function preflight(models, opts = {}) {
|
|
847
|
+
const env = opts.env || process.env;
|
|
848
|
+
const login = planLogin(env);
|
|
849
|
+
if (!login.ok) throw new Error(login.message);
|
|
850
|
+
if (login.extraUsage && !opts.allowExtraUsage) {
|
|
851
|
+
throw new Error('extra usage is on for this Claude account, so a long run could bill past your plan. '
|
|
852
|
+
+ 'turn it off in your Claude settings, or pass --allow-extra-usage to accept that.');
|
|
853
|
+
}
|
|
854
|
+
for (const model of models) {
|
|
855
|
+
const check = checkClaudeSpend({ model, job: 'atris scorecard', env });
|
|
856
|
+
if (!check.ok) {
|
|
857
|
+
throw new Error(`${model} uses much more of your plan than Haiku, so it needs your yes for this run: `
|
|
858
|
+
+ `ATRIS_CLAUDE_SPEND_OK=${model} atris scorecard run --models ${models.join(',')}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const bin = findBin(env.ATRIS_SCORECARD_ENGINE || 'claude', env);
|
|
862
|
+
if (!bin) throw new Error('could not find the claude command. install Claude Code, then log in with `claude`.');
|
|
863
|
+
return { ...login, bin };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function primaryModel(meta) {
|
|
867
|
+
const usage = meta && meta.modelUsage && typeof meta.modelUsage === 'object' ? meta.modelUsage : null;
|
|
868
|
+
if (!usage) return null;
|
|
869
|
+
let best = null;
|
|
870
|
+
for (const [id, info] of Object.entries(usage)) {
|
|
871
|
+
const cost = Number(info && info.costUSD) || 0;
|
|
872
|
+
if (!best || cost > best.cost) best = { id, cost };
|
|
873
|
+
}
|
|
874
|
+
return best ? best.id : null;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function runTry(root, task, model, opts = {}) {
|
|
878
|
+
const env = opts.env || process.env;
|
|
879
|
+
const scratch = opts.workDir || workDir(root);
|
|
880
|
+
const copy = path.join(scratch, `try-${task.id.slice(0, 7)}-${model.replace(/[^a-z0-9.-]/gi, '_')}-${opts.n || 0}`);
|
|
881
|
+
const row = { id: task.id, model, n: opts.n || 0, ask_key: hashAsk(askText(task)), passed: false, at: new Date().toISOString() };
|
|
882
|
+
try {
|
|
883
|
+
makeCopy(root, task.base, copy, opts);
|
|
884
|
+
const prompt = WORK_PROMPT.replace('{ask}', () => askText(task));
|
|
885
|
+
const bin = opts.bin || findBin(env.ATRIS_SCORECARD_ENGINE || 'claude', env) || 'claude';
|
|
886
|
+
const limit = minutesFor(model, opts.minutes);
|
|
887
|
+
// Claude Code's first line names where the run's billing comes from.
|
|
888
|
+
// 'none' means the plan login; anything else is a key, so the run is
|
|
889
|
+
// stopped before it asks the model anything.
|
|
890
|
+
let billing = null;
|
|
891
|
+
let meta = null;
|
|
892
|
+
const res = await runProcess(bin, engineArgs(model), {
|
|
893
|
+
cwd: copy, env: engineEnv(copy, scratch, env, task.runner && task.runner.env), input: prompt, timeoutMs: limit * 60000,
|
|
894
|
+
onLine: (line) => {
|
|
895
|
+
let msg;
|
|
896
|
+
try { msg = JSON.parse(line); } catch { return null; }
|
|
897
|
+
if (msg && msg.type === 'system' && msg.subtype === 'init') {
|
|
898
|
+
billing = String(msg.apiKeySource || 'unknown');
|
|
899
|
+
return billing === 'none' ? null : 'stop';
|
|
900
|
+
}
|
|
901
|
+
if (msg && msg.type === 'result') meta = msg;
|
|
902
|
+
return null;
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
row.billing = billing === 'none' ? 'plan' : billing;
|
|
906
|
+
if (billing !== 'none') {
|
|
907
|
+
row.engine_error = true;
|
|
908
|
+
row.not_plan = billing !== null;
|
|
909
|
+
row.engine_tail = billing === null
|
|
910
|
+
? `claude did not start.\n${res.stderr}`.trim().slice(-1500)
|
|
911
|
+
: `claude was about to bill ${billing} instead of your plan login, so the run was stopped.`;
|
|
912
|
+
return row;
|
|
913
|
+
}
|
|
914
|
+
meta = meta || {};
|
|
915
|
+
Object.assign(row, {
|
|
916
|
+
minutes: Math.round((res.ms / 60000) * 100) / 100,
|
|
917
|
+
limit_minutes: limit,
|
|
918
|
+
timed_out: res.timedOut,
|
|
919
|
+
cost_usd: typeof meta.total_cost_usd === 'number' ? meta.total_cost_usd : null,
|
|
920
|
+
turns: Number.isFinite(meta.num_turns) ? meta.num_turns : null,
|
|
921
|
+
model_used: primaryModel(meta),
|
|
922
|
+
engine_error: !res.timedOut && (meta.is_error === true || meta.type !== 'result'),
|
|
923
|
+
});
|
|
924
|
+
if (row.engine_error) {
|
|
925
|
+
row.engine_tail = `${typeof meta.result === 'string' ? meta.result : ''}\n${res.stderr}`.trim().slice(-1500)
|
|
926
|
+
|| 'claude stopped without a result';
|
|
927
|
+
row.out_of_plan = OUT_OF_PLAN.test(row.engine_tail);
|
|
928
|
+
return row;
|
|
929
|
+
}
|
|
930
|
+
git(['add', '-A'], copy);
|
|
931
|
+
row.files_changed = git(['diff', '--cached', '--name-only'], copy).split('\n').filter(Boolean);
|
|
932
|
+
if (opts.saveDiff) {
|
|
933
|
+
fs.mkdirSync(path.dirname(opts.saveDiff), { recursive: true });
|
|
934
|
+
fs.writeFileSync(opts.saveDiff, git(['diff', '--cached'], copy));
|
|
935
|
+
}
|
|
936
|
+
git(['checkout', '-q', task.fix, '--', ...task.hidden], copy);
|
|
937
|
+
const graded = await runTests(task, copy, env);
|
|
938
|
+
row.passed = graded.ok && !row.engine_error;
|
|
939
|
+
if (!graded.ok) {
|
|
940
|
+
row.test_tail = graded.tail.slice(-1500);
|
|
941
|
+
row.reason = graded.reason;
|
|
942
|
+
}
|
|
943
|
+
} catch (err) {
|
|
944
|
+
row.harness_error = String(err.message || err).slice(-1500);
|
|
945
|
+
} finally {
|
|
946
|
+
fs.rmSync(copy, { recursive: true, force: true });
|
|
947
|
+
}
|
|
948
|
+
return row;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function counts(row) {
|
|
952
|
+
return !row.harness_error && !row.engine_error;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Every task on every model, up to `tries` counted tries each. Tries already
|
|
956
|
+
// in `done` are skipped, so an interrupted run picks up where it stopped.
|
|
957
|
+
// Items go task by task so each task's models run close together. Resolves
|
|
958
|
+
// { rows, stopped, planned }; stopped means the plan ran out or a run was
|
|
959
|
+
// about to bill something other than the plan login.
|
|
960
|
+
async function runAll(root, tasks, opts = {}) {
|
|
961
|
+
const models = normalizeModels(opts.models);
|
|
962
|
+
const tries = Math.max(1, Number(opts.tries) || 1);
|
|
963
|
+
const done = Array.isArray(opts.done) ? opts.done.filter(counts) : [];
|
|
964
|
+
const plan = [];
|
|
965
|
+
for (let n = 0; n < tries; n += 1) {
|
|
966
|
+
for (const task of tasks) {
|
|
967
|
+
for (const model of models) {
|
|
968
|
+
if (!done.some((row) => sameAsk(row, task) && row.model === model && row.n === n)) plan.push({ task, model, n });
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const rows = [];
|
|
973
|
+
let next = 0;
|
|
974
|
+
let stopped = false;
|
|
975
|
+
const jobs = Math.max(1, Math.min(Number(opts.jobs) || 2, plan.length || 1));
|
|
976
|
+
async function worker() {
|
|
977
|
+
while (next < plan.length && !stopped) {
|
|
978
|
+
const item = plan[next];
|
|
979
|
+
next += 1;
|
|
980
|
+
const saveDiff = opts.outDir
|
|
981
|
+
? path.join(opts.outDir, 'diffs', `${item.task.id.slice(0, 7)}-${item.model.replace(/[^a-z0-9.-]/gi, '_')}-${item.n}.diff`)
|
|
982
|
+
: null;
|
|
983
|
+
const row = await runTry(root, item.task, item.model, { ...opts, n: item.n, saveDiff });
|
|
984
|
+
if (row.out_of_plan || row.not_plan) stopped = true;
|
|
985
|
+
rows.push(row);
|
|
986
|
+
if (typeof opts.onResult === 'function') opts.onResult(row, rows.length, plan.length);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
await Promise.all(Array.from({ length: jobs }, () => worker()));
|
|
990
|
+
return { rows, stopped, planned: plan.length };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ---- the scorecard ----------------------------------------------------------
|
|
994
|
+
|
|
995
|
+
function money(value) {
|
|
996
|
+
if (value === null || value === undefined || !Number.isFinite(value)) return '-';
|
|
997
|
+
return value < 0.995 ? `${Math.round(value * 100)}¢` : `$${value.toFixed(2)}`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function summarize(tasks, allRows, models) {
|
|
1001
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
1002
|
+
const rows = allRows.filter((row) => byId.has(row.id) && sameAsk(row, byId.get(row.id)));
|
|
1003
|
+
const counted = rows.filter(counts);
|
|
1004
|
+
const names = models && models.length ? models : [...new Set(rows.map((row) => row.model))];
|
|
1005
|
+
const byModel = names.map((model) => {
|
|
1006
|
+
const mine = counted.filter((row) => row.model === model);
|
|
1007
|
+
const passes = mine.filter((row) => row.passed).length;
|
|
1008
|
+
const priced = mine.filter((row) => typeof row.cost_usd === 'number');
|
|
1009
|
+
const cost = priced.reduce((sum, row) => sum + row.cost_usd, 0);
|
|
1010
|
+
return {
|
|
1011
|
+
model,
|
|
1012
|
+
model_used: (mine.find((row) => row.model_used) || {}).model_used || null,
|
|
1013
|
+
tries: mine.length,
|
|
1014
|
+
passes,
|
|
1015
|
+
tasks_done: new Set(mine.filter((row) => row.passed).map((row) => row.id)).size,
|
|
1016
|
+
avg_minutes: mine.length ? Math.round((mine.reduce((s, r) => s + (r.minutes || 0), 0) / mine.length) * 10) / 10 : null,
|
|
1017
|
+
cost_per_try: priced.length ? cost / priced.length : null,
|
|
1018
|
+
cost_per_pass: passes && priced.length ? cost / passes : null,
|
|
1019
|
+
};
|
|
1020
|
+
}).filter((m) => m.tries || (models && models.includes(m.model)));
|
|
1021
|
+
const tried = (id) => counted.filter((row) => row.id === id);
|
|
1022
|
+
const noModel = byModel.length ? tasks.filter((task) => {
|
|
1023
|
+
const mine = tried(task.id);
|
|
1024
|
+
return byModel.every((m) => mine.some((row) => row.model === m.model)) && !mine.some((row) => row.passed);
|
|
1025
|
+
}).map((task) => {
|
|
1026
|
+
const failed = tried(task.id).find((row) => row.reason || row.test_tail) || {};
|
|
1027
|
+
return {
|
|
1028
|
+
id: task.id, fix: task.fix, ask: task.ask, hidden: task.hidden,
|
|
1029
|
+
reason: failed.reason || failureReason(failed.test_tail),
|
|
1030
|
+
tail: failed.test_tail || '',
|
|
1031
|
+
};
|
|
1032
|
+
}) : [];
|
|
1033
|
+
// Cheapest first: the cheapest model's first try, then each pricier model's
|
|
1034
|
+
// first try only when every cheaper one failed the tests.
|
|
1035
|
+
let ladder = null;
|
|
1036
|
+
const ordered = byModel.filter((m) => m.cost_per_try !== null)
|
|
1037
|
+
.sort((a, b) => a.cost_per_try - b.cost_per_try).map((m) => m.model);
|
|
1038
|
+
if (ordered.length >= 2) {
|
|
1039
|
+
let cost = 0;
|
|
1040
|
+
let doneCount = 0;
|
|
1041
|
+
let scored = 0;
|
|
1042
|
+
for (const task of tasks) {
|
|
1043
|
+
const first = ordered.map((model) => tried(task.id).find((row) => row.model === model && row.n === 0));
|
|
1044
|
+
if (first.some((row) => !row)) continue;
|
|
1045
|
+
scored += 1;
|
|
1046
|
+
for (const row of first) {
|
|
1047
|
+
cost += row.cost_usd || 0;
|
|
1048
|
+
if (row.passed) { doneCount += 1; break; }
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (scored) {
|
|
1052
|
+
ladder = { order: ordered, done: doneCount, of: scored, cost_per_pass: doneCount ? cost / doneCount : null, cost_per_task: cost / scored };
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const errors = rows.filter((row) => !counts(row)).length;
|
|
1056
|
+
const named = tasks.filter((task) => askText(task) !== task.ask).length;
|
|
1057
|
+
return { tasks: tasks.length, named, models: byModel, no_model: noModel, ladder, errors };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function shortModel(model) {
|
|
1061
|
+
const match = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(model);
|
|
1062
|
+
return match ? `${match[1]} ${match[2]}.${match[3]}` : model;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function renderScorecard(summary, { repo = '', plan = '', extraUsage = false } = {}) {
|
|
1066
|
+
const lines = [];
|
|
1067
|
+
lines.push(`atris scorecard ${repo}`);
|
|
1068
|
+
lines.push(`${summary.tasks} past changes from this repo, each graded by the tests that shipped with it`);
|
|
1069
|
+
if (summary.named) {
|
|
1070
|
+
lines.push(`each ask is what the team wrote. ${summary.named} of ${summary.tasks} also name the new files and functions their tests call, as a good ticket would`);
|
|
1071
|
+
}
|
|
1072
|
+
lines.push('');
|
|
1073
|
+
const width = Math.max(10, ...summary.models.map((m) => shortModel(m.model).length)) + 2;
|
|
1074
|
+
lines.push(`${'model'.padEnd(width)}${'done'.padEnd(12)}${'avg time'.padEnd(12)}price per done change`);
|
|
1075
|
+
for (const m of summary.models) {
|
|
1076
|
+
const done = m.tries ? `${m.passes} of ${m.tries}` : 'not run';
|
|
1077
|
+
const time = m.avg_minutes === null ? '-' : `${m.avg_minutes} min`;
|
|
1078
|
+
lines.push(`${shortModel(m.model).padEnd(width)}${done.padEnd(12)}${time.padEnd(12)}${money(m.cost_per_pass)}`);
|
|
1079
|
+
}
|
|
1080
|
+
if (summary.ladder) {
|
|
1081
|
+
const order = summary.ladder.order.map(shortModel);
|
|
1082
|
+
lines.push('');
|
|
1083
|
+
lines.push(`cheapest first (${order[0]}, then ${order.slice(1).join(', then ')} only when the tests fail): `
|
|
1084
|
+
+ `${summary.ladder.done} of ${summary.ladder.of} done, ${money(summary.ladder.cost_per_pass)} per done change`);
|
|
1085
|
+
}
|
|
1086
|
+
if (summary.no_model.length) {
|
|
1087
|
+
lines.push('');
|
|
1088
|
+
lines.push(summary.models.length > 1
|
|
1089
|
+
? 'no model did these from what was written, so the ask is missing something the tests check:'
|
|
1090
|
+
: 'not done from what was written. a stronger model, or a clearer ask, may get these:');
|
|
1091
|
+
for (const task of summary.no_model.slice(0, 8)) {
|
|
1092
|
+
lines.push(` ${task.fix.slice(0, 7)} ${task.ask.split('\n')[0].slice(0, 90)}`);
|
|
1093
|
+
lines.push(task.reason
|
|
1094
|
+
? ` the tests wanted: ${task.reason.slice(0, 110)}`
|
|
1095
|
+
: ` tests: ${task.hidden.slice(0, 3).join(', ')}${task.hidden.length > 3 ? ', ...' : ''}`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (summary.errors) {
|
|
1099
|
+
lines.push('');
|
|
1100
|
+
lines.push(`${summary.errors} ${summary.errors === 1 ? 'try' : 'tries'} could not run and ${summary.errors === 1 ? 'is' : 'are'} left out.`);
|
|
1101
|
+
}
|
|
1102
|
+
lines.push('');
|
|
1103
|
+
lines.push(extraUsage
|
|
1104
|
+
? `these ran on your ${plan || 'Claude plan'} with extra usage on. prices are what the same runs cost on pay-per-use.`
|
|
1105
|
+
: `these ran on your ${plan || 'Claude plan'}, so they billed $0. prices are what the same runs would cost on pay-per-use.`);
|
|
1106
|
+
return lines.join('\n');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
module.exports = {
|
|
1110
|
+
DEFAULT_MODELS,
|
|
1111
|
+
MAX_TASKS,
|
|
1112
|
+
askText,
|
|
1113
|
+
classifyChange,
|
|
1114
|
+
cleanMessage,
|
|
1115
|
+
failureReason,
|
|
1116
|
+
findTasks,
|
|
1117
|
+
namesFor,
|
|
1118
|
+
normalizeModels,
|
|
1119
|
+
planLogin,
|
|
1120
|
+
preflight,
|
|
1121
|
+
renderScorecard,
|
|
1122
|
+
repoName,
|
|
1123
|
+
repoRoot,
|
|
1124
|
+
resolveRunner,
|
|
1125
|
+
runAll,
|
|
1126
|
+
runTry,
|
|
1127
|
+
runnerCommand,
|
|
1128
|
+
runnerLabel,
|
|
1129
|
+
shortModel,
|
|
1130
|
+
stopAll,
|
|
1131
|
+
storeDir,
|
|
1132
|
+
stripBilling,
|
|
1133
|
+
summarize,
|
|
1134
|
+
workDir,
|
|
1135
|
+
};
|