ravensight-playtest 0.1.0 → 0.2.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.
- package/README.md +87 -3
- package/package.json +12 -1
- package/src/api/client.js +25 -2
- package/src/cli.js +40 -1
- package/src/commands/fakeCore.js +68 -3
- package/src/commands/fix.js +1053 -0
- package/src/commands/run.js +12 -3
- package/src/packs/index.js +17 -2
- package/src/run/args.js +6 -1
- package/src/run/fixLoop.js +430 -0
- package/src/run/git.js +331 -0
- package/src/run/index.js +7 -3
- package/src/run/model.js +7 -1
- package/src/run/repoTools.js +279 -10
package/src/run/git.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { skippedDirIn } from './repoTools.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The only module in this CLI that runs git, and the only one that ever will.
|
|
10
|
+
*
|
|
11
|
+
* `fix` is the one command that changes the developer's working copy, so the
|
|
12
|
+
* rules about how it does that are worth having in one file where they can be
|
|
13
|
+
* read at a glance:
|
|
14
|
+
*
|
|
15
|
+
* - **It never publishes anything.** There is no `commit`, no `push`, no tag,
|
|
16
|
+
* no `gh`. The command ends by printing the pull request command for the
|
|
17
|
+
* developer to run when they have read the diff. A tool that edits your
|
|
18
|
+
* source and pushes the result is a tool you cannot leave running.
|
|
19
|
+
* - **It never rewrites history.** No `reset`, no `rebase`, no `checkout --`
|
|
20
|
+
* of a path. The only mutation is creating and checking out one new branch,
|
|
21
|
+
* and that is refused if the branch already exists, so an earlier attempt's
|
|
22
|
+
* work is never landed on.
|
|
23
|
+
* - **It refuses a dirty tree** unless the developer says otherwise, because
|
|
24
|
+
* the changeset is computed as a diff against the recorded HEAD and a tree
|
|
25
|
+
* that was already dirty makes that diff a mixture of two people's work.
|
|
26
|
+
* - **Everything goes through `execFile`**, never a shell, and every value
|
|
27
|
+
* the developer or the server supplied is an argument rather than a word in
|
|
28
|
+
* a command line. A branch name is the obvious injection vector here.
|
|
29
|
+
*
|
|
30
|
+
* Every call is scoped by `-C <root>`, so the repository is the one the rest
|
|
31
|
+
* of the command is working in rather than whatever directory the process
|
|
32
|
+
* happens to be in.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const run = promisify(execFile);
|
|
36
|
+
|
|
37
|
+
/** Branch names git itself refuses, checked before git is asked. */
|
|
38
|
+
const BAD_BRANCH = /(^-|^\.|\.\.|[~^:?*[\\ ]|@\{|\.lock$|\/$|^\/|\/\/)/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where a fix lands by default: one branch per attempt, named after the
|
|
42
|
+
* finding, with six hex characters so two attempts at the same finding do not
|
|
43
|
+
* collide (and so the second one is not silently refused).
|
|
44
|
+
*
|
|
45
|
+
* @param {string} label the finding's label, e.g. F002
|
|
46
|
+
* @param {() => string} [suffix] test seam
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function fixBranchName(label, suffix = () => randomBytes(3).toString('hex')) {
|
|
50
|
+
const slug = String(label || 'finding').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'finding';
|
|
51
|
+
return `ravensight/fix-${slug}-${suffix()}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} name
|
|
56
|
+
* @returns {string|null} why the name is not usable, or null
|
|
57
|
+
*/
|
|
58
|
+
export function branchNameRefusal(name) {
|
|
59
|
+
const value = String(name || '');
|
|
60
|
+
if (!value) return 'A branch name cannot be empty.';
|
|
61
|
+
if (BAD_BRANCH.test(value)) return `"${value}" is not a usable branch name.`;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A git surface bound to one repository.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} options
|
|
69
|
+
* @param {string} options.root the repository working directory
|
|
70
|
+
* @param {Function} [options.exec] test seam, shaped like promisified execFile
|
|
71
|
+
* @returns {Object}
|
|
72
|
+
*/
|
|
73
|
+
export function createGit({ root, exec = run }) {
|
|
74
|
+
/**
|
|
75
|
+
* @param {string[]} args
|
|
76
|
+
* @returns {Promise<{ok: boolean, stdout: string, stderr: string, code: number|null}>}
|
|
77
|
+
*/
|
|
78
|
+
async function git(args) {
|
|
79
|
+
try {
|
|
80
|
+
const answer = await exec('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
81
|
+
return { ok: true, stdout: String(answer.stdout || ''), stderr: String(answer.stderr || ''), code: 0 };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
stdout: String((error && error.stdout) || ''),
|
|
86
|
+
stderr: String((error && error.stderr) || (error && error.message) || ''),
|
|
87
|
+
// ENOENT (no git on the PATH) has no exit code at all, which is how
|
|
88
|
+
// "git is missing" is told apart from "git said no".
|
|
89
|
+
code: typeof (error && error.code) === 'number' ? error.code : null,
|
|
90
|
+
missing: error && error.code === 'ENOENT'
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
root,
|
|
97
|
+
git,
|
|
98
|
+
|
|
99
|
+
/** Whether a git binary exists at all. */
|
|
100
|
+
async available() {
|
|
101
|
+
const answer = await git(['--version']);
|
|
102
|
+
return answer.ok;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/** Whether `root` is inside a work tree. */
|
|
106
|
+
async isRepo() {
|
|
107
|
+
const answer = await git(['rev-parse', '--is-inside-work-tree']);
|
|
108
|
+
return answer.ok && answer.stdout.trim() === 'true';
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/** The commit `fix` records as the base of its changeset. */
|
|
112
|
+
async head() {
|
|
113
|
+
const answer = await git(['rev-parse', 'HEAD']);
|
|
114
|
+
return answer.ok ? answer.stdout.trim() : null;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/** The branch checked out now, or null on a detached HEAD. */
|
|
118
|
+
async currentBranch() {
|
|
119
|
+
const answer = await git(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
120
|
+
const name = answer.ok ? answer.stdout.trim() : '';
|
|
121
|
+
return name && name !== 'HEAD' ? name : null;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The uncommitted paths that matter, tracked or not.
|
|
126
|
+
*
|
|
127
|
+
* Untracked files count: an edit the model makes to a file the developer
|
|
128
|
+
* had not committed yet is still their work being mixed into the
|
|
129
|
+
* changeset. Generated directories do not, and `.ravensight` least of all:
|
|
130
|
+
* a repository that has not gitignored `.ravensight/jobs/` yet would
|
|
131
|
+
* otherwise be permanently "dirty" because of this CLI's own journals, and
|
|
132
|
+
* every fix would be refused for a reason that has nothing to do with the
|
|
133
|
+
* developer's work.
|
|
134
|
+
*
|
|
135
|
+
* @param {number} [limit]
|
|
136
|
+
* @returns {Promise<Array<string>>}
|
|
137
|
+
*/
|
|
138
|
+
async dirtyPaths(limit = Number.POSITIVE_INFINITY) {
|
|
139
|
+
const answer = await git(['status', '--porcelain', '--untracked-files=normal']);
|
|
140
|
+
if (!answer.ok) return [];
|
|
141
|
+
return answer.stdout.split('\n')
|
|
142
|
+
.map(line => line.slice(3).trim())
|
|
143
|
+
// A rename is `old -> new`; the new path is the one that exists.
|
|
144
|
+
.map(entry => (entry.includes(' -> ') ? entry.split(' -> ').pop().trim() : entry))
|
|
145
|
+
.filter(entry => entry && !skippedDirIn(entry))
|
|
146
|
+
.slice(0, limit);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/** Whether the tree has uncommitted work worth refusing over. */
|
|
150
|
+
async isDirty() {
|
|
151
|
+
return (await this.dirtyPaths(1)).length > 0;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
/** @param {string} name */
|
|
155
|
+
async branchExists(name) {
|
|
156
|
+
const answer = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]);
|
|
157
|
+
return answer.ok && answer.stdout.trim().length > 0;
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create the branch and check it out. `switch -c` rather than
|
|
162
|
+
* `checkout -b`: it only ever changes branches, where `checkout` also
|
|
163
|
+
* takes paths and would discard a file if a name were ever mistaken for
|
|
164
|
+
* one.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} name
|
|
167
|
+
* @returns {Promise<{ok: boolean, reason?: string}>}
|
|
168
|
+
*/
|
|
169
|
+
async createBranch(name) {
|
|
170
|
+
const answer = await git(['switch', '-c', name]);
|
|
171
|
+
return answer.ok ? { ok: true } : { ok: false, reason: firstLine(answer.stderr) };
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The changeset's file list.
|
|
176
|
+
*
|
|
177
|
+
* `git diff --numstat` against the working tree is the source, as the
|
|
178
|
+
* contract says. It sees changes to tracked files and nothing else, so
|
|
179
|
+
* files the fix created are counted separately: a new file is the most
|
|
180
|
+
* ordinary kind of fix there is, and a changeset that reported none of
|
|
181
|
+
* them would call a real edit "no change" and refund a fix that worked.
|
|
182
|
+
*
|
|
183
|
+
* Generated and vendored directories are left out, and `.ravensight`
|
|
184
|
+
* first among them: the run journal and the changeset are written inside
|
|
185
|
+
* the repository while the fix is running, and a repository without
|
|
186
|
+
* `.ravensight/jobs/` in its `.gitignore` would otherwise report the
|
|
187
|
+
* command's own bookkeeping as part of the change it made. Nothing a fix
|
|
188
|
+
* is allowed to write can be in one of these, since `write_file` refuses
|
|
189
|
+
* them by the same rule.
|
|
190
|
+
*
|
|
191
|
+
* @returns {Promise<Array<{path: string, additions: number, deletions: number}>>}
|
|
192
|
+
*/
|
|
193
|
+
async diffStat() {
|
|
194
|
+
const out = [];
|
|
195
|
+
const tracked = await git(['diff', '--numstat']);
|
|
196
|
+
if (tracked.ok) {
|
|
197
|
+
for (const line of tracked.stdout.split('\n')) {
|
|
198
|
+
if (!line.trim()) continue;
|
|
199
|
+
const [additions, deletions, ...rest] = line.split('\t');
|
|
200
|
+
const file = rest.join('\t').trim();
|
|
201
|
+
if (!file || skippedDirIn(file)) continue;
|
|
202
|
+
out.push({
|
|
203
|
+
path: file,
|
|
204
|
+
// A binary file is `-\t-\t<path>` in numstat, which is zero lines
|
|
205
|
+
// rather than an unknown number of them.
|
|
206
|
+
additions: Number.parseInt(additions, 10) || 0,
|
|
207
|
+
deletions: Number.parseInt(deletions, 10) || 0
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const untracked = await git(['ls-files', '--others', '--exclude-standard']);
|
|
212
|
+
if (untracked.ok) {
|
|
213
|
+
for (const line of untracked.stdout.split('\n')) {
|
|
214
|
+
const file = line.trim();
|
|
215
|
+
if (!file || skippedDirIn(file)) continue;
|
|
216
|
+
out.push({ path: file, additions: await countLines(path.join(root, file)), deletions: 0 });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/** Whether anything at all changed, which is what decides the refund. */
|
|
223
|
+
async hasChanges() {
|
|
224
|
+
return (await this.diffStat()).length > 0;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The checks `fix` runs before it edits anything, in the order that answers
|
|
231
|
+
* the developer soonest.
|
|
232
|
+
*
|
|
233
|
+
* Every refusal is one sentence, because it is the whole of what gets
|
|
234
|
+
* printed: a developer who is told "not a git repository" can act on it, and
|
|
235
|
+
* one who is shown a git error with a hint block cannot tell which half is
|
|
236
|
+
* the problem.
|
|
237
|
+
*
|
|
238
|
+
* `create` is false when the caller wants every refusal now and the one
|
|
239
|
+
* mutation later: `fix` checks all of this before it quotes a price, and
|
|
240
|
+
* creates the branch only once the developer has agreed to pay, so declining
|
|
241
|
+
* the price does not leave a branch behind.
|
|
242
|
+
*
|
|
243
|
+
* @param {Object} options
|
|
244
|
+
* @param {Object} options.git a `createGit` surface
|
|
245
|
+
* @param {string} options.branch the branch to create
|
|
246
|
+
* @param {boolean} [options.allowDirty]
|
|
247
|
+
* @param {boolean} [options.create] create and check out the branch now
|
|
248
|
+
* @returns {Promise<{ok: true, head: string, branch: string, from: string|null, dirty: boolean, created: boolean}|{ok: false, reason: string}>}
|
|
249
|
+
*/
|
|
250
|
+
export async function preflight({ git, branch, allowDirty = false, create = true }) {
|
|
251
|
+
if (!(await git.available())) {
|
|
252
|
+
return { ok: false, reason: 'This command needs git on the PATH, and there is none here.' };
|
|
253
|
+
}
|
|
254
|
+
if (!(await git.isRepo())) {
|
|
255
|
+
return { ok: false, reason: `${git.root} is not a git repository, and a fix has to be reviewable as a branch.` };
|
|
256
|
+
}
|
|
257
|
+
const named = branchNameRefusal(branch);
|
|
258
|
+
if (named) return { ok: false, reason: named };
|
|
259
|
+
|
|
260
|
+
const dirty = await git.isDirty();
|
|
261
|
+
if (!allowDirty && dirty) {
|
|
262
|
+
const paths = await git.dirtyPaths(5);
|
|
263
|
+
const listed = paths.length > 0 ? ` (${paths.join(', ')})` : '';
|
|
264
|
+
return { ok: false, reason: `The working tree has uncommitted changes${listed}. Commit or stash them, or pass --allow-dirty to mix them into this fix.` };
|
|
265
|
+
}
|
|
266
|
+
const head = await git.head();
|
|
267
|
+
if (!head) {
|
|
268
|
+
return { ok: false, reason: 'This repository has no commits yet, so there is nothing to branch from.' };
|
|
269
|
+
}
|
|
270
|
+
if (await git.branchExists(branch)) {
|
|
271
|
+
return { ok: false, reason: `The branch ${branch} already exists. Pass --branch <name> to use another one.` };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const from = await git.currentBranch();
|
|
275
|
+
if (!create) return { ok: true, head, branch, from, dirty, created: false };
|
|
276
|
+
|
|
277
|
+
const made = await startBranch({ git, branch });
|
|
278
|
+
if (!made.ok) return made;
|
|
279
|
+
return { ok: true, head, branch, from, dirty, created: true };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Create the branch and check it out, refusing one that already exists.
|
|
284
|
+
*
|
|
285
|
+
* Separate from `preflight` so a caller can put the refusals before a price
|
|
286
|
+
* prompt and this one mutation after it. The existence check is repeated here
|
|
287
|
+
* rather than trusted from earlier: it is the check that stops an earlier
|
|
288
|
+
* attempt's work being landed on.
|
|
289
|
+
*
|
|
290
|
+
* @param {{git: Object, branch: string}} options
|
|
291
|
+
* @returns {Promise<{ok: true, branch: string}|{ok: false, reason: string}>}
|
|
292
|
+
*/
|
|
293
|
+
export async function startBranch({ git, branch }) {
|
|
294
|
+
const named = branchNameRefusal(branch);
|
|
295
|
+
if (named) return { ok: false, reason: named };
|
|
296
|
+
if (await git.branchExists(branch)) {
|
|
297
|
+
return { ok: false, reason: `The branch ${branch} already exists. Pass --branch <name> to use another one.` };
|
|
298
|
+
}
|
|
299
|
+
const created = await git.createBranch(branch);
|
|
300
|
+
if (!created.ok) {
|
|
301
|
+
return { ok: false, reason: `The branch ${branch} could not be created: ${created.reason}` };
|
|
302
|
+
}
|
|
303
|
+
return { ok: true, branch };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Lines in a file the fix created. Read rather than counted with git, because
|
|
308
|
+
* git does not have a number for a file it has never seen.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} absolute
|
|
311
|
+
* @returns {Promise<number>}
|
|
312
|
+
*/
|
|
313
|
+
async function countLines(absolute) {
|
|
314
|
+
try {
|
|
315
|
+
const info = await stat(absolute);
|
|
316
|
+
// A new file big enough to be worth guarding against is not something a
|
|
317
|
+
// fix writes; `write_file` caps one at 200 kB.
|
|
318
|
+
if (!info.isFile() || info.size > 4 * 1024 * 1024) return 0;
|
|
319
|
+
const text = await readFile(absolute, 'utf8');
|
|
320
|
+
if (text.length === 0) return 0;
|
|
321
|
+
return text.endsWith('\n') ? text.split('\n').length - 1 : text.split('\n').length;
|
|
322
|
+
} catch {
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function firstLine(text) {
|
|
328
|
+
return String(text || '').split('\n').map(line => line.trim()).find(Boolean) || 'git gave no reason';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export default { createGit, preflight, startBranch, fixBranchName, branchNameRefusal };
|
package/src/run/index.js
CHANGED
|
@@ -2,21 +2,23 @@
|
|
|
2
2
|
* The runner's entry point, and the only module cli-core's CLI imports from
|
|
3
3
|
* this directory.
|
|
4
4
|
*
|
|
5
|
-
* It lazy-imports exactly
|
|
5
|
+
* It lazy-imports exactly four names, so those four are the contract:
|
|
6
6
|
*
|
|
7
7
|
* | Name | Called with | Answers |
|
|
8
8
|
* |---|---|---|
|
|
9
9
|
* | `runCommand(flags)` | commander's flags plus `flags.argv` (the run-specific options, which `src/run/args.js` parses) | an exit code |
|
|
10
10
|
* | `profileCommand(flags)` | the same | an exit code |
|
|
11
|
+
* | `fixCommand(target, flags)` | the finding to fix, then the same flags | an exit code |
|
|
11
12
|
* | `resumeJob({ api, gameId, jobId, job, runs, state, repoRoot, flags })` | cli-core's `resume` hand-off | an exit code |
|
|
12
13
|
*
|
|
13
14
|
* Everything else exported here is for tests and for cli-godot's driver: the
|
|
14
15
|
* driver interface, the model client, and the pieces `resume` shares with
|
|
15
|
-
* `run`. Nothing in cli-core should reach past the
|
|
16
|
+
* `run`. Nothing in cli-core should reach past the four names above.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
export { runCommand, playJob, playPersonaRun } from '../commands/run.js';
|
|
19
|
+
export { runCommand, playJob, playPersonaRun, deliver } from '../commands/run.js';
|
|
19
20
|
export { profileCommand } from '../commands/profile.js';
|
|
21
|
+
export { fixCommand } from '../commands/fix.js';
|
|
20
22
|
export { resumeJob } from './resume.js';
|
|
21
23
|
|
|
22
24
|
export { EXIT } from './exit.js';
|
|
@@ -26,6 +28,8 @@ export { createModelClient, callStep, callStepWithRetry, ModelUnavailableError,
|
|
|
26
28
|
export { getDeps, setDeps, resetDeps } from './deps.js';
|
|
27
29
|
export { runPersona } from './personaLoop.js';
|
|
28
30
|
export { runGameProfile } from './profile.js';
|
|
31
|
+
export { applyFix, planFix, summariseFix } from './fixLoop.js';
|
|
32
|
+
export { createGit, preflight as gitPreflight } from './git.js';
|
|
29
33
|
export { buildAggregate } from './aggregate.js';
|
|
30
34
|
export { scanForSecrets, assertNoSecrets } from './secretScan.js';
|
|
31
35
|
export { parseRunArgv, optionsFrom } from './args.js';
|
package/src/run/model.js
CHANGED
|
@@ -48,7 +48,13 @@ export const STEPS = Object.freeze({
|
|
|
48
48
|
profileSynthesis: 'game_profile.synthesis',
|
|
49
49
|
aggregateDedup: 'aggregate.dedup',
|
|
50
50
|
aggregateSynthesis: 'aggregate.synthesis',
|
|
51
|
-
briefValidate: 'brief.validate'
|
|
51
|
+
briefValidate: 'brief.validate',
|
|
52
|
+
// The `fix` module's three. They are spendable only from a run whose module
|
|
53
|
+
// is `fix`, and `fix.apply` is the one step on this whole surface whose tool
|
|
54
|
+
// allowlist includes `write_file`.
|
|
55
|
+
fixPlan: 'fix.plan',
|
|
56
|
+
fixApply: 'fix.apply',
|
|
57
|
+
fixSummarize: 'fix.summarize'
|
|
52
58
|
});
|
|
53
59
|
|
|
54
60
|
/** Default retry budget for the answers that are worth retrying. */
|