ravensight-playtest 0.5.2 → 0.6.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.
Files changed (96) hide show
  1. package/README.md +249 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +92 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +165 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +578 -68
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/profile.js +25 -6
  24. package/src/commands/replay.js +204 -0
  25. package/src/commands/resume.js +7 -0
  26. package/src/commands/run.js +200 -24
  27. package/src/commands/template.js +70 -0
  28. package/src/commands/validate.js +414 -0
  29. package/src/commands/validateHosted.js +292 -0
  30. package/src/create/scaffold.js +192 -0
  31. package/src/run/acceptance.js +212 -0
  32. package/src/run/args.js +18 -1
  33. package/src/run/checks.js +705 -0
  34. package/src/run/dimension.js +95 -0
  35. package/src/run/drivers/godot-observation.js +59 -11
  36. package/src/run/drivers/godot-project.js +201 -31
  37. package/src/run/drivers/godot.js +532 -45
  38. package/src/run/drivers/web.js +18 -0
  39. package/src/run/evidence.js +170 -0
  40. package/src/run/featureLoop.js +288 -0
  41. package/src/run/fixLoop.js +82 -12
  42. package/src/run/git.js +110 -20
  43. package/src/run/json.js +65 -9
  44. package/src/run/labsOutcome.js +136 -0
  45. package/src/run/liveWork.js +103 -0
  46. package/src/run/model.js +105 -5
  47. package/src/run/openPr.js +107 -0
  48. package/src/run/paths.js +4 -1
  49. package/src/run/pendingRegister.js +81 -0
  50. package/src/run/personaLoop.js +111 -4
  51. package/src/run/profile.js +68 -7
  52. package/src/run/replay.js +236 -0
  53. package/src/run/repoTools.js +227 -27
  54. package/src/run/report.js +7 -1
  55. package/src/run/resume.js +19 -1
  56. package/src/run/secretScan.js +35 -0
  57. package/src/run/settle.js +41 -0
  58. package/src/run/tools.js +33 -4
  59. package/src/run/trace.js +124 -0
  60. package/src/simulation/godotPack.js +112 -0
  61. package/src/ui/index.js +61 -9
  62. package/src/upload/allowlist.js +6 -2
  63. package/src/upload/index.js +5 -4
  64. package/templates/godot-2d/README.md +36 -0
  65. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  66. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  67. package/templates/godot-2d/export_presets.cfg +27 -0
  68. package/templates/godot-2d/gitignore +10 -0
  69. package/templates/godot-2d/project.godot +58 -0
  70. package/templates/godot-2d/scenes/main.tscn +67 -0
  71. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  72. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  73. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  74. package/templates/godot-2d/scripts/main.gd +32 -0
  75. package/templates/godot-2d/scripts/player.gd +10 -0
  76. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  77. package/templates/godot-2d/template.json +14 -0
  78. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  79. package/templates/godot-2d/tools/selftest.gd +70 -0
  80. package/templates/godot-3d/README.md +49 -0
  81. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  82. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  83. package/templates/godot-3d/export_presets.cfg +27 -0
  84. package/templates/godot-3d/gitignore +10 -0
  85. package/templates/godot-3d/project.godot +56 -0
  86. package/templates/godot-3d/scenes/main.tscn +154 -0
  87. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  88. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  89. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  90. package/templates/godot-3d/scripts/beacon.gd +20 -0
  91. package/templates/godot-3d/scripts/main.gd +49 -0
  92. package/templates/godot-3d/scripts/player.gd +19 -0
  93. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  94. package/templates/godot-3d/template.json +16 -0
  95. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  96. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -1,5 +1,6 @@
1
1
  import { parseJsonObject } from './json.js';
2
2
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
3
+ import { acceptanceBlock } from './acceptance.js';
3
4
  import { createRepoTools, WRITE_TOOLS } from './repoTools.js';
4
5
 
5
6
  /**
@@ -51,6 +52,12 @@ export const PLAN_MAX_TURNS = 6;
51
52
  */
52
53
  export const NUDGE_EVERY = 8;
53
54
 
55
+ /** What `fix.apply` is told first. */
56
+ export const FIX_APPLY_INSTRUCTIONS = Object.freeze([
57
+ 'Make the change now. Read before you edit. Change an existing file with `edit_file` (old_text copied verbatim, new_text in its place); use `write_file` only for a new file, because it replaces the whole file and a large file will not fit in one answer.',
58
+ 'Change as little as possible, keep the style of the code around it, and stop as soon as the fix is in. When you are done, say in one line what you changed and call no more tools.'
59
+ ]);
60
+
54
61
  /** One breakpoint, frozen so no caller can hand a mutated one to the next turn. */
55
62
  const CACHE_BREAKPOINT = Object.freeze({ type: 'ephemeral' });
56
63
 
@@ -146,7 +153,7 @@ export function resolveFixMaxActions(options = {}, pack = {}) {
146
153
  * @param {Object} args
147
154
  * @returns {Array<Object>}
148
155
  */
149
- export function fixSystem({ skill, jobId, branch, baseCommit, engine = null }) {
156
+ export function fixSystem({ skill, jobId, branch, baseCommit, engine = null, runCheck = false }) {
150
157
  return [
151
158
  { type: 'text', text: skill },
152
159
  {
@@ -156,7 +163,9 @@ export function fixSystem({ skill, jobId, branch, baseCommit, engine = null }) {
156
163
  'the paths you read and write are relative to the repository root; never absolute',
157
164
  `engine recorded on this game: ${engine || 'unknown'}`,
158
165
  `branch: ${branch} (created for this attempt, from ${baseCommit || 'unknown'})`,
159
- 'you cannot run commands, reach the network, or commit anything. The developer reviews the branch and opens the pull request themselves.'
166
+ runCheck
167
+ ? 'you cannot run commands, reach the network, or commit anything, except the named checks the run_check tool offers. The developer reviews the branch.'
168
+ : 'you cannot run commands, reach the network, or commit anything. The developer reviews the branch and opens the pull request themselves.'
160
169
  ].join('\n'),
161
170
  cache_control: { type: 'ephemeral' }
162
171
  }
@@ -244,7 +253,8 @@ export async function planFix({
244
253
  content,
245
254
  repo,
246
255
  ledger = null,
247
- log = () => {}
256
+ log = () => {},
257
+ step = STEPS.fixPlan
248
258
  }) {
249
259
  const messages = [{ role: 'user', content }];
250
260
  let turns = 0;
@@ -260,7 +270,7 @@ export async function planFix({
260
270
  client,
261
271
  jobId,
262
272
  runId,
263
- step: STEPS.fixPlan,
273
+ step,
264
274
  system,
265
275
  messages: withRollingCacheBreakpoint(messages),
266
276
  tools: repo ? repo.tools : undefined,
@@ -321,7 +331,9 @@ export function normalisePlan(parsed) {
321
331
  .map(entry => entry.trim()),
322
332
  out_of_scope: (Array.isArray(parsed.out_of_scope) ? parsed.out_of_scope : [])
323
333
  .map(entry => (typeof entry === 'string' ? entry : JSON.stringify(entry)))
324
- .filter(Boolean)
334
+ .filter(Boolean),
335
+ // Only a feature plan answers this. A fix plan reads exactly as it did.
336
+ ...(Array.isArray(parsed.placeholders) ? { placeholders: parsed.placeholders.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim().slice(0, 200)) } : {})
325
337
  };
326
338
  }
327
339
 
@@ -346,21 +358,34 @@ export async function applyFix({
346
358
  maxActions,
347
359
  codeBrief = null,
348
360
  graphContext = null,
361
+ acceptance = null,
362
+ repair = null,
349
363
  repo = createRepoTools({ root: repoRoot, tools: WRITE_TOOLS }),
350
364
  ledger = null,
351
365
  log = () => {},
352
366
  onProgress = () => {},
353
- shouldStop = () => false
367
+ shouldStop = () => false,
368
+ step = STEPS.fixApply,
369
+ // What this apply is told to do and what it is held to. The feature module
370
+ // has its own words and its own frozen spec; everything else about the loop,
371
+ // its guards and its budget stop, is shared.
372
+ instructions = FIX_APPLY_INSTRUCTIONS,
373
+ contract = null
354
374
  }) {
355
375
  const messages = [{
356
376
  role: 'user',
357
377
  content: [{
358
378
  type: 'text',
359
379
  text: [
360
- 'Make the change now. Read before you edit. Change an existing file with `edit_file` (old_text copied verbatim, new_text in its place); use `write_file` only for a new file, because it replaces the whole file and a large file will not fit in one answer.',
361
- 'Change as little as possible, keep the style of the code around it, and stop as soon as the fix is in. When you are done, say in one line what you changed and call no more tools.',
380
+ ...(repair ? repairLines(repair) : []),
381
+ ...instructions,
362
382
  '',
363
383
  `<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
384
+ ...(acceptance ? [acceptanceBlock(acceptance)] : []),
385
+ ...(contract ? [contract] : []),
386
+ ...(repo.tools.some(tool => tool.name === 'run_check')
387
+ ? ['Before you finish, call run_check with godot_import to prove the project still imports. A change that fails it cannot be uploaded.']
388
+ : []),
364
389
  ...bundleLines(codeBrief, graphContext)
365
390
  ].join('\n')
366
391
  }]
@@ -384,7 +409,7 @@ export async function applyFix({
384
409
  client,
385
410
  jobId,
386
411
  runId,
387
- step: STEPS.fixApply,
412
+ step,
388
413
  system,
389
414
  messages: withRollingCacheBreakpoint(messages),
390
415
  tools: repo.tools,
@@ -453,6 +478,47 @@ export async function applyFix({
453
478
  return { turns, written: repo.written, stop };
454
479
  }
455
480
 
481
+ /**
482
+ * The opening of a repair turn set: the change already on disk did not pass,
483
+ * and this is why. The acceptance contract is restated by the caller,
484
+ * unchanged; nothing here redefines success.
485
+ *
486
+ * @param {{attempt: number, of: number, reason: string, detail: string}} repair
487
+ * @returns {Array<string>}
488
+ */
489
+ export function repairLines(repair) {
490
+ return [
491
+ `Repair ${repair.attempt} of ${repair.of}. Your earlier change is on disk on this branch, and it did not pass: ${repair.reason}.`,
492
+ `<failure>\n${String(repair.detail || '').slice(0, 6000)}\n</failure>`,
493
+ 'Read the files you changed, correct the change so it passes, and keep it as small as before. Do not undo the fix to make a check pass.',
494
+ ''
495
+ ];
496
+ }
497
+
498
+ /**
499
+ * The verify result as the summary model reads it. A bare `reproduced: 0.34`
500
+ * was once written up as "reproduced the finding in 34% of actions", so the
501
+ * number never travels without what it is, what it is not and how it reads.
502
+ *
503
+ * @param {Object|null} verify
504
+ * @param {{fixedBelow: number, reproducedAbove: number}} thresholds
505
+ * @returns {string}
506
+ */
507
+ export function verifyForSummary(verify, { fixedBelow, reproducedAbove }) {
508
+ if (!verify) return 'The fix was not verified by replaying the finding, so say that and claim no result.';
509
+ if (verify.failed === 'game_boot_failed') return 'The verify run could not test the fix because the game did not boot. Say the fix is not verified and claim no result.';
510
+ if (verify.failed) return `The verify run did not finish${verify.detail ? ` (${verify.detail})` : ''}, so it says nothing about the fix. Say the fix is not verified and claim no result.`;
511
+ const score = verify.reproduced;
512
+ if (typeof score !== 'number' || !Number.isFinite(score)) return 'The verify run played, but no verdict was reached. Say the result is unknown and claim no result.';
513
+ const reads = score < fixedBelow ? 'fixed' : (score > reproducedAbove ? 'still reproduced' : 'unknown');
514
+ return [
515
+ `One verify run replayed the finding, and that run was judged and given a score of ${score}.`,
516
+ `The score is a judged probability, from 0 to 1, that the finding is still present: below ${fixedBelow} reads as fixed, above ${reproducedAbove} reads as still reproduced, and anything between reads as unknown.`,
517
+ `This score reads as ${reads}.`,
518
+ 'It is one opinion about one replay. It is not a percentage or a share of actions, attempts, sessions or players, and nothing was measured a number of times. Do not write it as a rate.'
519
+ ].join(' ');
520
+ }
521
+
456
522
  /**
457
523
  * The pull request text, written from the diff that exists rather than from
458
524
  * the plan that was made.
@@ -474,6 +540,7 @@ export async function summariseFix({
474
540
  plan,
475
541
  files,
476
542
  verify,
543
+ thresholds,
477
544
  branch,
478
545
  fallback,
479
546
  ledger = null,
@@ -487,7 +554,7 @@ export async function summariseFix({
487
554
  `<finding>\n${JSON.stringify(finding, null, 2)}\n</finding>`,
488
555
  `<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
489
556
  `<diff_stat>\n${files.map(file => `${file.path}: +${file.additions} -${file.deletions}`).join('\n') || 'nothing'}\n</diff_stat>`,
490
- `<verify>\n${verify ? JSON.stringify(verify) : 'the fix was not verified by replaying the finding'}\n</verify>`,
557
+ `<verify>\n${verifyForSummary(verify, thresholds)}\n</verify>`,
491
558
  `<branch>${branch}</branch>`,
492
559
  '',
493
560
  'Answer with this JSON object and nothing else:',
@@ -511,7 +578,10 @@ export async function summariseFix({
511
578
  log('There was no budget left to write the pull request text, so it was written locally instead.');
512
579
  return { ...fallback, source: 'local' };
513
580
  }
514
- throw error;
581
+ // The change exists, the checks and the verify have run. Prose is the one thing
582
+ // left, and losing it must not lose the changeset or fail a run that worked.
583
+ log(`The pull request text could not be written by the model (${String(error && error.message)}), so it was written locally instead.`);
584
+ return { ...fallback, source: 'local' };
515
585
  }
516
586
  if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
517
587
 
@@ -535,4 +605,4 @@ export function clamp(value, limit) {
535
605
  return text.length <= limit ? text : text.slice(0, limit);
536
606
  }
537
607
 
538
- export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan, withRollingCacheBreakpoint };
608
+ export default { planFix, applyFix, summariseFix, verifyForSummary, resolveFixMaxActions, fixSystem, planContent, normalisePlan, withRollingCacheBreakpoint };
package/src/run/git.js CHANGED
@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
3
3
  import { readFile, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
- import { skippedDirIn } from './repoTools.js';
6
+ import { isRunOutputName, skippedDirIn } from './repoTools.js';
7
7
 
8
8
  /**
9
9
  * The only module in this CLI that runs git, and the only one that ever will.
@@ -19,7 +19,9 @@ import { skippedDirIn } from './repoTools.js';
19
19
  * - **It never rewrites history.** No `reset`, no `rebase`, no `checkout --`
20
20
  * of a path. The only mutation is creating and checking out one new branch,
21
21
  * and that is refused if the branch already exists, so an earlier attempt's
22
- * work is never landed on.
22
+ * work is never landed on. Undoing it, when the server refused the job before
23
+ * anything happened on it, goes back to where it started and deletes the branch
24
+ * with `-d`, which git refuses if the branch holds anything.
23
25
  * - **It refuses a dirty tree** unless the developer says otherwise, because
24
26
  * the changeset is computed as a diff against the recorded HEAD and a tree
25
27
  * that was already dirty makes that diff a mixture of two people's work.
@@ -35,7 +37,7 @@ import { skippedDirIn } from './repoTools.js';
35
37
  const run = promisify(execFile);
36
38
 
37
39
  /** Branch names git itself refuses, checked before git is asked. */
38
- const BAD_BRANCH = /(^-|^\.|\.\.|[~^:?*[\\ ]|@\{|\.lock$|\/$|^\/|\/\/)/;
40
+ const BAD_BRANCH = /(^-|^\+|^@$|^\.|\.\.|[~^:?*[\\ ]|@\{|\.lock$|\/$|^\/|\/\/)/;
39
41
 
40
42
  /**
41
43
  * Where a fix lands by default: one branch per attempt, named after the
@@ -136,14 +138,22 @@ export function createGit({ root, exec = run }) {
136
138
  * @returns {Promise<Array<string>>}
137
139
  */
138
140
  async dirtyPaths(limit = Number.POSITIVE_INFINITY) {
139
- const answer = await git(['status', '--porcelain', '--untracked-files=normal']);
141
+ // `-z` because git quotes a name with non-ASCII characters otherwise, and a
142
+ // quoted name is a path that does not exist. `-- .` because a repository's
143
+ // other directories are not what this command changes.
144
+ const answer = await git(['status', '--porcelain', '-z', '--untracked-files=normal', '--', '.']);
140
145
  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);
146
+ const fields = answer.stdout.split('\0');
147
+ const out = [];
148
+ for (let i = 0; i < fields.length; i += 1) {
149
+ const field = fields[i];
150
+ if (field.length < 4) continue;
151
+ // A rename is `R new`, then the old name in a field of its own.
152
+ if (/[RC]/.test(field.slice(0, 2))) i += 1;
153
+ const entry = field.slice(3);
154
+ if (!skippedDirIn(entry)) out.push(entry);
155
+ }
156
+ return out.slice(0, limit);
147
157
  },
148
158
 
149
159
  /** Whether the tree has uncommitted work worth refusing over. */
@@ -192,12 +202,15 @@ export function createGit({ root, exec = run }) {
192
202
  */
193
203
  async diffStat() {
194
204
  const out = [];
195
- const tracked = await git(['diff', '--numstat']);
205
+ // `-z` as above. `--relative` because numstat otherwise answers from the top
206
+ // of the repository, and the rest of this CLI, untracked files included,
207
+ // works from `root`, which may be one game inside a monorepo.
208
+ const tracked = await git(['diff', '--numstat', '-z', '--relative', '--no-renames', '--no-ext-diff']);
196
209
  if (tracked.ok) {
197
- for (const line of tracked.stdout.split('\n')) {
210
+ for (const line of tracked.stdout.split('\0')) {
198
211
  if (!line.trim()) continue;
199
212
  const [additions, deletions, ...rest] = line.split('\t');
200
- const file = rest.join('\t').trim();
213
+ const file = rest.join('\t');
201
214
  if (!file || skippedDirIn(file)) continue;
202
215
  out.push({
203
216
  path: file,
@@ -208,11 +221,12 @@ export function createGit({ root, exec = run }) {
208
221
  });
209
222
  }
210
223
  }
211
- const untracked = await git(['ls-files', '--others', '--exclude-standard']);
224
+ const untracked = await git(['ls-files', '-z', '--others', '--exclude-standard']);
212
225
  if (untracked.ok) {
213
- for (const line of untracked.stdout.split('\n')) {
214
- const file = line.trim();
215
- if (!file || skippedDirIn(file)) continue;
226
+ for (const file of untracked.stdout.split('\0')) {
227
+ // Untracked at the root under one of these names: an older CLI's
228
+ // leftovers, never the customer's source.
229
+ if (!file || skippedDirIn(file) || isRunOutputName(file)) continue;
216
230
  out.push({ path: file, additions: await countLines(path.join(root, file)), deletions: 0 });
217
231
  }
218
232
  }
@@ -226,6 +240,61 @@ export function createGit({ root, exec = run }) {
226
240
  };
227
241
  }
228
242
 
243
+ /**
244
+ * Which of these paths git ignores. A tracked file is never among them, whatever
245
+ * the rules say, because git still shows its changes. Outside a repository, or
246
+ * with no git at all, nothing is ignored: there are no rules to apply.
247
+ *
248
+ * @param {string} root
249
+ * @param {Array<string>} paths repository relative
250
+ * @returns {Promise<Set<string>>}
251
+ */
252
+ export function ignoredPaths(root, paths) {
253
+ return new Promise((resolve) => {
254
+ if (paths.length === 0) {
255
+ resolve(new Set());
256
+ return;
257
+ }
258
+ const child = execFile('git', ['-C', root, 'check-ignore', '--stdin', '-z'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout) => {
259
+ // Exit 1 is "none of them", and 128 is "not a repository".
260
+ resolve(new Set(error ? [] : String(stdout).split('\0').filter(Boolean)));
261
+ });
262
+ child.stdin.on('error', () => {});
263
+ child.stdin.end(`${paths.join('\0')}\0`);
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Every ignored file under the root. Asked this way round for a whole tree, because
269
+ * `check-ignore` refuses the entire list over one path inside a submodule.
270
+ *
271
+ * @param {string} root
272
+ * @returns {Promise<Set<string>>}
273
+ */
274
+ export async function allIgnoredFiles(root) {
275
+ try {
276
+ const { stdout } = await run('git', ['-C', root, 'ls-files', '-z', '--others', '--ignored', '--exclude-standard'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
277
+ return new Set(stdout.split('\0').filter(Boolean));
278
+ } catch {
279
+ return new Set();
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Where this repository's hooks live, `core.hooksPath` included, as an absolute path.
285
+ *
286
+ * @param {string} root
287
+ * @returns {Promise<string|null>}
288
+ */
289
+ export async function hooksDirectory(root) {
290
+ try {
291
+ const { stdout } = await run('git', ['-C', root, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8' });
292
+ return path.resolve(root, stdout.trim());
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
229
298
  /**
230
299
  * The checks `fix` runs before it edits anything, in the order that answers
231
300
  * the developer soonest.
@@ -247,7 +316,7 @@ export function createGit({ root, exec = run }) {
247
316
  * @param {boolean} [options.create] create and check out the branch now
248
317
  * @returns {Promise<{ok: true, head: string, branch: string, from: string|null, dirty: boolean, created: boolean}|{ok: false, reason: string}>}
249
318
  */
250
- export async function preflight({ git, branch, allowDirty = false, create = true }) {
319
+ export async function preflight({ git, branch, allowDirty = false, create = true, reuseBranch = false }) {
251
320
  if (!(await git.available())) {
252
321
  return { ok: false, reason: 'This command needs git on the PATH, and there is none here.' };
253
322
  }
@@ -256,6 +325,10 @@ export async function preflight({ git, branch, allowDirty = false, create = true
256
325
  }
257
326
  const named = branchNameRefusal(branch);
258
327
  if (named) return { ok: false, reason: named };
328
+ // Git has the last word on what a branch can be called, and asking it now means
329
+ // the refusal comes before a price rather than after one.
330
+ const usable = await git.git(['check-ref-format', '--branch', branch]);
331
+ if (!usable.ok && !usable.missing) return { ok: false, reason: `"${printable(branch)}" is not a usable branch name.` };
259
332
 
260
333
  const dirty = await git.isDirty();
261
334
  if (!allowDirty && dirty) {
@@ -267,7 +340,8 @@ export async function preflight({ git, branch, allowDirty = false, create = true
267
340
  if (!head) {
268
341
  return { ok: false, reason: 'This repository has no commits yet, so there is nothing to branch from.' };
269
342
  }
270
- if (await git.branchExists(branch)) {
343
+ // Only a rerun after an unknown register keeps its branch (`run/pendingRegister.js`).
344
+ if (!reuseBranch && await git.branchExists(branch)) {
271
345
  return { ok: false, reason: `The branch ${branch} already exists. Pass --branch <name> to use another one.` };
272
346
  }
273
347
 
@@ -303,6 +377,17 @@ export async function startBranch({ git, branch }) {
303
377
  return { ok: true, branch };
304
378
  }
305
379
 
380
+ /**
381
+ * Undo `startBranch`, when the job it was for never existed.
382
+ *
383
+ * @param {{git: Object, branch: string, from: string|null, head: string}} options
384
+ * @returns {Promise<void>}
385
+ */
386
+ export async function abandonBranch({ git, branch, from, head }) {
387
+ const back = await git.git(from ? ['switch', from] : ['switch', '--detach', head]);
388
+ if (back.ok) await git.git(['branch', '-d', branch]);
389
+ }
390
+
306
391
  /**
307
392
  * Lines in a file the fix created. Read rather than counted with git, because
308
393
  * git does not have a number for a file it has never seen.
@@ -324,8 +409,13 @@ async function countLines(absolute) {
324
409
  }
325
410
  }
326
411
 
412
+ function printable(text) {
413
+ /* eslint-disable-next-line no-control-regex */
414
+ return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
415
+ }
416
+
327
417
  function firstLine(text) {
328
418
  return String(text || '').split('\n').map(line => line.trim()).find(Boolean) || 'git gave no reason';
329
419
  }
330
420
 
331
- export default { createGit, preflight, startBranch, fixBranchName, branchNameRefusal };
421
+ export default { abandonBranch, allIgnoredFiles, createGit, hooksDirectory, ignoredPaths, preflight, startBranch, fixBranchName, branchNameRefusal };
package/src/run/json.js CHANGED
@@ -19,21 +19,77 @@ function unfence(text) {
19
19
  return fenced ? fenced[1] : value;
20
20
  }
21
21
 
22
+ /** The most `{` positions tried per candidate text, so a pathological answer stays cheap. */
23
+ const MAX_OBJECT_STARTS = 200;
24
+
25
+ /**
26
+ * Every balanced `{...}` span in a text, skipping braces inside strings.
27
+ *
28
+ * @param {string} body
29
+ * @returns {Array<string>}
30
+ */
31
+ function objectSpans(body) {
32
+ const spans = [];
33
+ let starts = 0;
34
+ for (let start = body.indexOf('{'); start !== -1 && starts < MAX_OBJECT_STARTS; start = body.indexOf('{', start + 1)) {
35
+ starts += 1;
36
+ let depth = 0;
37
+ let inString = false;
38
+ let escaped = false;
39
+ for (let i = start; i < body.length; i += 1) {
40
+ const char = body[i];
41
+ if (inString) {
42
+ if (escaped) escaped = false;
43
+ else if (char === '\\') escaped = true;
44
+ else if (char === '"') inString = false;
45
+ continue;
46
+ }
47
+ if (char === '"') inString = true;
48
+ else if (char === '{') depth += 1;
49
+ else if (char === '}') {
50
+ depth -= 1;
51
+ if (depth === 0) {
52
+ spans.push(body.slice(start, i + 1));
53
+ break;
54
+ }
55
+ }
56
+ }
57
+ }
58
+ return spans;
59
+ }
60
+
22
61
  /**
62
+ * The JSON object in a model answer: fenced or not, with prose before or
63
+ * after it, and with braces in that prose (a GDScript `{}` quoted in a
64
+ * sentence, a fence of another language first). Of every object that parses,
65
+ * the longest wins, since the report is the big one and a quoted example is
66
+ * small.
67
+ *
23
68
  * @param {string} text
24
69
  * @returns {Object|null} null when the answer holds no JSON object
25
70
  */
26
71
  export function parseJsonObject(text) {
27
- const body = unfence(text);
28
- const start = body.indexOf('{');
29
- const end = body.lastIndexOf('}');
30
- if (start === -1 || end <= start) return null;
31
- try {
32
- const parsed = JSON.parse(body.slice(start, end + 1));
33
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
34
- } catch {
35
- return null;
72
+ const value = String(text || '');
73
+ const candidates = [];
74
+ for (const match of value.matchAll(/```[A-Za-z0-9_-]*[ \t]*\n?([\s\S]*?)```/g)) candidates.push(match[1]);
75
+ candidates.push(value);
76
+ let best = null;
77
+ let bestLength = -1;
78
+ for (const candidate of candidates) {
79
+ for (const span of objectSpans(candidate)) {
80
+ if (span.length <= bestLength) continue;
81
+ try {
82
+ const parsed = JSON.parse(span);
83
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
84
+ best = parsed;
85
+ bestLength = span.length;
86
+ }
87
+ } catch {
88
+ // Not this span.
89
+ }
90
+ }
36
91
  }
92
+ return best;
37
93
  }
38
94
 
39
95
  /**
@@ -0,0 +1,136 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { promisify } from 'node:util';
4
+ import { jobsDir } from './paths.js';
5
+ import * as stateFile from './state.js';
6
+
7
+ /**
8
+ * What became of a pull request this CLI opened with `--open-pr`, reported on the
9
+ * next run. Feedback for the job's `labs_outcome`, read by no money path.
10
+ *
11
+ * Best effort and never blocking: it starts in the background, every failure is
12
+ * swallowed, it has a hard time limit, and a report the server did not take is
13
+ * tried again on the run after. It runs `gh` only for a job whose local journal
14
+ * says this machine opened a pull request, so a job without one costs a directory
15
+ * read. A revert found this way is one made through a pull request: a direct push
16
+ * of a revert commit reads as merged.
17
+ */
18
+
19
+ const run = promisify(execFile);
20
+
21
+ /** A check of one pull request at most this long, and all of them together. */
22
+ const GH_TIMEOUT_MS = 5000;
23
+ const OVERALL_TIMEOUT_MS = 8000;
24
+ /** How many pull requests one run looks at, newest first. */
25
+ export const MAX_CHECKS_PER_RUN = 5;
26
+ /** A merged pull request can still be reverted, so it is followed this long. */
27
+ export const FOLLOW_DAYS = 90;
28
+ const FINAL = new Set(['reverted', 'abandoned']);
29
+ const PR_URL = /^https:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)$/;
30
+
31
+ /**
32
+ * @param {{state: string}} pr what `gh pr view` shows
33
+ * @param {boolean} reverted
34
+ * @returns {'open'|'merged'|'reverted'|'abandoned'|null}
35
+ */
36
+ export function outcomeOf(pr, reverted) {
37
+ if (pr.state === 'OPEN') return 'open';
38
+ if (pr.state === 'MERGED') return reverted ? 'reverted' : 'merged';
39
+ if (pr.state === 'CLOSED') return 'abandoned';
40
+ return null;
41
+ }
42
+
43
+ async function gh(exec, args, signal) {
44
+ const { stdout } = await exec('gh', args, { timeout: GH_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal });
45
+ return JSON.parse(String(stdout));
46
+ }
47
+
48
+ /** A merged pull request whose title or body says it reverts this one. */
49
+ async function wasReverted(exec, pr, [, owner, name, number], signal) {
50
+ const repo = `${owner}/${name}`;
51
+ const merged = await gh(exec, ['pr', 'list', '--repo', repo, '--state', 'merged', '--search', 'Revert in:title,body', '--json', 'title,body', '--limit', '30'], signal);
52
+ const byBody = new RegExp(`\\bReverts\\s+(${repo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?#${number}\\b`, 'i');
53
+ return merged.some(entry => entry.title === `Revert "${pr.title}"` || byBody.test(String(entry.body || '')));
54
+ }
55
+
56
+ /**
57
+ * Tell the server this job opened a pull request and remember the URL locally. The
58
+ * URL stays on this machine: the server stores the outcome only.
59
+ */
60
+ export async function recordPrOpened({ api, gameId, repoRoot, journal, url, now = () => new Date() }) {
61
+ if (!url) return;
62
+ try {
63
+ journal.pull_request = { url, opened_at: now().toISOString(), reported: null };
64
+ await stateFile.save(repoRoot, journal);
65
+ await api.jobs.labsOutcomePr(gameId, journal.job_id, { pr: 'open' });
66
+ journal.pull_request.reported = 'open';
67
+ await stateFile.save(repoRoot, journal);
68
+ } catch {
69
+ // The next run says it again.
70
+ }
71
+ }
72
+
73
+ async function reportOne({ api, gameId, repoRoot, journal, exec, signal }) {
74
+ const record = journal.pull_request;
75
+ const parts = PR_URL.exec(record.url);
76
+ const pr = await gh(exec, ['pr', 'view', record.url, '--json', 'state,title,number'], signal);
77
+ const reverted = pr.state === 'MERGED' ? await wasReverted(exec, pr, parts, signal) : false;
78
+ const outcome = outcomeOf(pr, reverted);
79
+ if (!outcome) return;
80
+ if (!record.reported) {
81
+ await api.jobs.labsOutcomePr(gameId, journal.job_id, { pr: 'open' });
82
+ record.reported = 'open';
83
+ await stateFile.save(repoRoot, journal);
84
+ }
85
+ if (outcome === record.reported) return;
86
+ await api.jobs.labsOutcomePr(gameId, journal.job_id, { pr: outcome });
87
+ record.reported = outcome;
88
+ if (FINAL.has(outcome)) record.done = true;
89
+ await stateFile.save(repoRoot, journal);
90
+ }
91
+
92
+ async function followable(repoRoot, now) {
93
+ const cutoff = now().getTime() - FOLLOW_DAYS * 24 * 60 * 60 * 1000;
94
+ const found = [];
95
+ for (const jobId of await readdir(jobsDir(repoRoot))) {
96
+ const journal = await stateFile.load(repoRoot, jobId).catch(() => null);
97
+ const record = journal && journal.pull_request;
98
+ if (!record || record.done || !PR_URL.test(String(record.url))) continue;
99
+ const opened = Date.parse(record.opened_at);
100
+ if (!Number.isFinite(opened) || opened < cutoff) continue;
101
+ found.push({ journal, opened });
102
+ }
103
+ return found.sort((a, b) => b.opened - a.opened).slice(0, MAX_CHECKS_PER_RUN);
104
+ }
105
+
106
+ /**
107
+ * Look at every pull request this repository's jobs opened, and report what changed.
108
+ * Never rejects and never takes longer than its limit.
109
+ *
110
+ * @returns {Promise<void>}
111
+ */
112
+ export async function reportPrOutcomes({ api, gameId, repoRoot, exec = run, now = () => new Date(), timeoutMs = OVERALL_TIMEOUT_MS }) {
113
+ const controller = new AbortController();
114
+ let timer;
115
+ const expired = new Promise(resolve => {
116
+ timer = setTimeout(() => {
117
+ controller.abort();
118
+ resolve();
119
+ }, timeoutMs);
120
+ });
121
+ const work = (async () => {
122
+ const entries = await followable(repoRoot, now);
123
+ for (const { journal } of entries) {
124
+ if (controller.signal.aborted) return;
125
+ try {
126
+ await reportOne({ api, gameId, repoRoot, journal, exec, signal: controller.signal });
127
+ } catch {
128
+ // No gh, not signed in, no network, a server without the route: next run.
129
+ }
130
+ }
131
+ })().catch(() => {});
132
+ await Promise.race([work, expired]);
133
+ clearTimeout(timer);
134
+ }
135
+
136
+ export default { outcomeOf, recordPrOpened, reportPrOutcomes, MAX_CHECKS_PER_RUN, FOLLOW_DAYS };