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/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 three names, so those three are the contract:
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 three names above.
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. */