ravensight-playtest 0.5.2 → 0.7.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 +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { execFileSync, spawn as spawnProcess } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { copyProjectTree, detectGodotBinary, isGodotProject, measureProject } from './drivers/godot-project.js';
|
|
6
|
+
import { stopAll, track } from './liveWork.js';
|
|
7
|
+
import { isSecretPath } from './repoTools.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fix v2's named checks: the only way a model step ever causes a process to
|
|
11
|
+
* run on the developer's machine, and the reason it is a fixed list rather
|
|
12
|
+
* than a shell.
|
|
13
|
+
*
|
|
14
|
+
* - `godot_import`: `godot --headless --path <copy> --import`, then every
|
|
15
|
+
* script error in its output. Godot exits 0 with parse errors in the log, so
|
|
16
|
+
* the exit code alone proves nothing; the log is what is read.
|
|
17
|
+
* - `godot_script_check`: `--check-only --script` over each changed `.gd`
|
|
18
|
+
* file, after the same import, so a script naming a global class resolves.
|
|
19
|
+
* - `project_tests`: GUT or gdUnit4, found by their addon folder, with a
|
|
20
|
+
* timeout. A project with neither answers `unavailable`, not `passed`.
|
|
21
|
+
*
|
|
22
|
+
* Every check runs on a fresh temporary copy of the project, never on the
|
|
23
|
+
* developer's directory, with a time limit and an output cap, and the copy is
|
|
24
|
+
* removed afterwards. Arguments are fixed here; nothing the model sends
|
|
25
|
+
* reaches a command line except the choice of check name, which is matched
|
|
26
|
+
* against `CHECK_NAMES`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const CHECK_NAMES = Object.freeze(['godot_import', 'godot_script_check', 'project_tests']);
|
|
30
|
+
|
|
31
|
+
/** Time limits, per check. A check that runs over is a failure, never a pass. */
|
|
32
|
+
export const CHECK_TIMEOUTS_MS = Object.freeze({
|
|
33
|
+
godot_import: 180000,
|
|
34
|
+
godot_script_check: 120000,
|
|
35
|
+
project_tests: 300000
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Bytes of output kept per check, whatever the process printed. */
|
|
39
|
+
export const MAX_CHECK_OUTPUT_BYTES = 64 * 1024;
|
|
40
|
+
|
|
41
|
+
/** Characters of a check's result a model turn is shown. */
|
|
42
|
+
export const MAX_CHECK_RESULT_CHARS = 4000;
|
|
43
|
+
|
|
44
|
+
/** At most this many errors are listed per check. */
|
|
45
|
+
export const MAX_CHECK_ERRORS = 40;
|
|
46
|
+
|
|
47
|
+
/** Never copied into the check's temp project. */
|
|
48
|
+
export const CHECK_COPY_EXCLUDES = Object.freeze(['.git', '.ravensight', 'node_modules', '.DS_Store']);
|
|
49
|
+
|
|
50
|
+
/** The tool definition offered to `fix.apply` when the job runs Fix v2. */
|
|
51
|
+
export const RUN_CHECK_TOOL = Object.freeze({
|
|
52
|
+
name: 'run_check',
|
|
53
|
+
description: 'Run one named check on a temporary copy of the project as it is now, with your edits. godot_import imports the project headless and lists script errors; godot_script_check parses each changed .gd file; project_tests runs the project\'s GUT or gdUnit4 tests. No other command can be run.',
|
|
54
|
+
input_schema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
required: ['check'],
|
|
58
|
+
properties: { check: { type: 'string', enum: [...CHECK_NAMES] } }
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/* eslint-disable-next-line no-control-regex */
|
|
63
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
64
|
+
|
|
65
|
+
/** The lines of Godot output that mean a script did not load. */
|
|
66
|
+
const ERROR_LINE = /^(SCRIPT ERROR:|ERROR: Failed to load script|ERROR: .*Parse error|Parse Error:|Compile Error:)/;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The script errors in a Godot log, each with its `at:` line when there is one.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} output
|
|
72
|
+
* @returns {Array<string>}
|
|
73
|
+
*/
|
|
74
|
+
export function parseGodotErrors(output) {
|
|
75
|
+
const lines = String(output || '').replace(ANSI, '').split(/\r?\n/);
|
|
76
|
+
const errors = [];
|
|
77
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
78
|
+
const line = lines[i].trim();
|
|
79
|
+
if (!ERROR_LINE.test(line)) continue;
|
|
80
|
+
const next = (lines[i + 1] || '').trim();
|
|
81
|
+
const where = /^at: /.test(next) ? ` (${next.replace(/^at: /, '')})` : '';
|
|
82
|
+
const entry = `${line}${where}`;
|
|
83
|
+
if (!errors.includes(entry)) errors.push(entry);
|
|
84
|
+
if (errors.length >= MAX_CHECK_ERRORS) break;
|
|
85
|
+
}
|
|
86
|
+
return errors;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The autoload names a project registers, from its `project.godot` text.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} text
|
|
93
|
+
* @returns {Array<string>}
|
|
94
|
+
*/
|
|
95
|
+
export function autoloadNames(text) {
|
|
96
|
+
const names = [];
|
|
97
|
+
let inSection = false;
|
|
98
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
99
|
+
const line = raw.trim();
|
|
100
|
+
if (line.startsWith('[')) inSection = line === '[autoload]';
|
|
101
|
+
else if (inSection) {
|
|
102
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
103
|
+
if (match) names.push(match[1]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return names;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `--check-only` stops before Godot registers the autoloads, so any script that
|
|
111
|
+
* names one fails there with "Identifier not found" although the game runs it
|
|
112
|
+
* fine. Those errors, and the "failed to load" that follows from them, are not
|
|
113
|
+
* about the script. Nor is the "failed to compile depended scripts" a script
|
|
114
|
+
* gets for using a class that names one: a real mistake in that class would be
|
|
115
|
+
* its own entry here. Anything else in the list still stands: the analyzer's own
|
|
116
|
+
* errors come first, so a real mistake is reported beside or before these.
|
|
117
|
+
*
|
|
118
|
+
* @param {Array<string>} errors from parseGodotErrors
|
|
119
|
+
* @param {Array<string>} names from autoloadNames
|
|
120
|
+
* @returns {Array<string>} what is left; empty means the script is fine
|
|
121
|
+
*/
|
|
122
|
+
export function withoutAutoloadAlarms(errors, names) {
|
|
123
|
+
const isAutoload = entry => names.some(name => new RegExp(`Identifier not found: ${name}\\b`).test(entry));
|
|
124
|
+
if (!errors.some(isAutoload)) return errors;
|
|
125
|
+
const left = errors.filter(entry => !isAutoload(entry));
|
|
126
|
+
return left.every(entry => /Failed to load script .*"Compilation failed"|Failed to compile depended scripts/.test(entry)) ? [] : left;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Which test runner the project ships, by its addon folder.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} projectDir
|
|
133
|
+
* @returns {Promise<'gut'|'gdunit4'|null>}
|
|
134
|
+
*/
|
|
135
|
+
export async function detectTestRunner(projectDir) {
|
|
136
|
+
const has = async relative => Boolean(await fs.stat(path.join(projectDir, relative)).catch(() => null));
|
|
137
|
+
if (await has('addons/gut/gut_cmdln.gd')) return 'gut';
|
|
138
|
+
if (await has('addons/gdUnit4/bin/GdUnitCmdTool.gd')) return 'gdunit4';
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The arguments for a test runner. Fixed: the project picks the runner by
|
|
144
|
+
* shipping it, nothing picks the arguments.
|
|
145
|
+
*
|
|
146
|
+
* @param {'gut'|'gdunit4'} runner
|
|
147
|
+
* @param {string} copy
|
|
148
|
+
* @returns {Array<string>}
|
|
149
|
+
*/
|
|
150
|
+
export function testArgs(runner, copy) {
|
|
151
|
+
if (runner === 'gut') return ['--headless', '--path', copy, '-s', 'res://addons/gut/gut_cmdln.gd', '-gexit'];
|
|
152
|
+
return ['--headless', '--path', copy, '-s', 'res://addons/gdUnit4/bin/GdUnitCmdTool.gd', '--ignoreHeadlessMode', '-a', 'res://test'];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The environment a check's Godot sees: PATH and the locale from the
|
|
157
|
+
* developer's environment, and nothing else from it. HOME, the temp dirs and
|
|
158
|
+
* Godot's user-data dirs (HOME on macOS, XDG on Linux, APPDATA on Windows)
|
|
159
|
+
* all point into the check's own temp root. No token, provider key, agent
|
|
160
|
+
* socket or cloud credential can reach the edited project's code this way.
|
|
161
|
+
*
|
|
162
|
+
* @param {{root: string|null, env?: Object, platform?: string}} args
|
|
163
|
+
* @returns {Object}
|
|
164
|
+
*/
|
|
165
|
+
export function checkEnv({ root = null, env = process.env, platform = process.platform } = {}) {
|
|
166
|
+
const out = {};
|
|
167
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
168
|
+
if (value === undefined) continue;
|
|
169
|
+
if (/^path$/i.test(key) || key === 'LANG' || /^LC_[A-Z_]+$/.test(key)) out[key] = value;
|
|
170
|
+
if (platform === 'win32' && /^systemroot$/i.test(key)) out.SystemRoot = value;
|
|
171
|
+
}
|
|
172
|
+
if (!root) return out;
|
|
173
|
+
const home = path.join(root, 'home');
|
|
174
|
+
const tmp = path.join(root, 'tmp');
|
|
175
|
+
Object.assign(out, {
|
|
176
|
+
HOME: home,
|
|
177
|
+
TMPDIR: tmp,
|
|
178
|
+
TMP: tmp,
|
|
179
|
+
TEMP: tmp,
|
|
180
|
+
XDG_DATA_HOME: path.join(home, '.local', 'share'),
|
|
181
|
+
XDG_CONFIG_HOME: path.join(home, '.config'),
|
|
182
|
+
XDG_CACHE_HOME: path.join(home, '.cache')
|
|
183
|
+
});
|
|
184
|
+
if (platform === 'win32') {
|
|
185
|
+
out.APPDATA = path.join(root, 'appdata');
|
|
186
|
+
out.LOCALAPPDATA = path.join(root, 'localappdata');
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The directories `checkEnv` points into, created before the first spawn. */
|
|
192
|
+
async function makeEnvDirs(environment) {
|
|
193
|
+
for (const key of ['HOME', 'TMPDIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'APPDATA', 'LOCALAPPDATA']) {
|
|
194
|
+
if (environment[key]) await fs.mkdir(environment[key], { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Where macOS keeps `sandbox-exec`. */
|
|
199
|
+
export const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `sandbox-exec` when this is macOS and it exists, else null. Elsewhere no OS
|
|
203
|
+
* sandbox is applied: the minimal environment and the temp copy are the
|
|
204
|
+
* whole of the isolation, and the README says so.
|
|
205
|
+
*
|
|
206
|
+
* @returns {Promise<string|null>}
|
|
207
|
+
*/
|
|
208
|
+
export async function detectSandbox({ platform = process.platform, file = SANDBOX_EXEC } = {}) {
|
|
209
|
+
if (platform !== 'darwin') return null;
|
|
210
|
+
try {
|
|
211
|
+
await fs.access(file, fs.constants.X_OK);
|
|
212
|
+
return file;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Where a developer's credentials live, relative to the real home. A check runs with
|
|
220
|
+
* its own HOME, but code can name a path outright, and whatever a failing check prints
|
|
221
|
+
* goes to the model. Named rather than the whole home, because a project may link to
|
|
222
|
+
* shared assets that live there.
|
|
223
|
+
*/
|
|
224
|
+
export const CREDENTIAL_STORES = Object.freeze([
|
|
225
|
+
'.ssh', '.aws', '.gnupg', '.kube', '.docker', '.azure', '.config/gh', '.config/gcloud', '.config/ravensight',
|
|
226
|
+
'.config/ravensight-playtest', '.ravensight', '.ravensight-playtest', '.netrc', '.git-credentials', '.npmrc', '.pypirc', '.pgpass', 'Library/Keychains'
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
function sbString(value) {
|
|
230
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The macOS sandbox profile for one check: no network at all, and no write
|
|
235
|
+
* anywhere but the check's temp root (named both ways, because /var is a
|
|
236
|
+
* link to /private/var) and the null and tty devices.
|
|
237
|
+
*
|
|
238
|
+
* Reads are open, because Godot needs the system, except for `CREDENTIAL_STORES`.
|
|
239
|
+
*
|
|
240
|
+
* @param {Array<string>} roots
|
|
241
|
+
* @param {{home?: string|null}} [options] the developer's real home
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
export function sandboxProfile(roots, { home = null } = {}) {
|
|
245
|
+
const allowed = [...new Set(roots)].map(root => `(subpath ${sbString(root)})`).join(' ');
|
|
246
|
+
return [
|
|
247
|
+
'(version 1)',
|
|
248
|
+
'(allow default)',
|
|
249
|
+
'(deny network*)',
|
|
250
|
+
...(home ? [`(deny file-read* ${CREDENTIAL_STORES.map(store => `(subpath ${sbString(path.join(home, store))})`).join(' ')})`] : []),
|
|
251
|
+
'(deny file-write*)',
|
|
252
|
+
`(allow file-write* ${allowed} (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (regex #"^/dev/fd/"))`
|
|
253
|
+
].join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Paths an edit may not touch for a check to run without --allow-tool-scripts. Case
|
|
258
|
+
* blind, because the filesystems on macOS and Windows are. A `.gdextension` names a
|
|
259
|
+
* native library for the editor to load.
|
|
260
|
+
*/
|
|
261
|
+
const TOOL_PATHS = [/^project\.godot$/i, /^export_presets\.cfg$/i, /^override\.cfg$/i, /^addons\//i, /^\.godot\//i, /\.gdextension$/i];
|
|
262
|
+
|
|
263
|
+
/** A GDScript file that runs in the editor, which is to say during an import. */
|
|
264
|
+
const TOOL_ANNOTATION = /^[ \t]*(?:@\w+(?:\([^)\n]*\))?[ \t]+)*@tool\b/m;
|
|
265
|
+
|
|
266
|
+
function posix(file) {
|
|
267
|
+
return String(file).split(path.sep).join('/').replace(/^\.\//, '');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Why the edits make an import unsafe to run, or null. Editor-side code runs
|
|
272
|
+
* during `--import`: project settings and autoloads, export presets, addons
|
|
273
|
+
* (editor plugins), the import cache, and any `@tool` script.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} projectDir
|
|
276
|
+
* @param {Array<string>} files project relative paths the fix changed
|
|
277
|
+
* @returns {Promise<string|null>}
|
|
278
|
+
*/
|
|
279
|
+
export async function toolScriptRefusal(projectDir, files) {
|
|
280
|
+
const hits = [];
|
|
281
|
+
for (const raw of files) {
|
|
282
|
+
const file = posix(raw);
|
|
283
|
+
if (TOOL_PATHS.some(pattern => pattern.test(file))) {
|
|
284
|
+
hits.push(file);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (file.endsWith('.gd')) {
|
|
288
|
+
const text = await fs.readFile(path.join(projectDir, file), 'utf8').catch(() => '');
|
|
289
|
+
if (TOOL_ANNOTATION.test(text)) hits.push(`${file} (@tool)`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (hits.length === 0) return null;
|
|
293
|
+
return `the fix edited code that runs inside the Godot editor during an import (${hits.slice(0, 5).join(', ')}); pass --allow-tool-scripts to run checks on it`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The test directories a runner is configured with, as project relative prefixes. */
|
|
297
|
+
async function configuredTestDirs(projectDir) {
|
|
298
|
+
const dirs = [];
|
|
299
|
+
const gut = await fs.readFile(path.join(projectDir, '.gutconfig.json'), 'utf8').catch(() => null);
|
|
300
|
+
if (gut) {
|
|
301
|
+
try {
|
|
302
|
+
const parsed = JSON.parse(gut);
|
|
303
|
+
for (const dir of Array.isArray(parsed.dirs) ? parsed.dirs : []) {
|
|
304
|
+
const relative = posix(String(dir).replace(/^res:\/\//, '')).replace(/\/+$/, '');
|
|
305
|
+
if (relative) dirs.push(`${relative}/`);
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// An unreadable config names no directories; the name patterns still apply.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return dirs;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const TEST_FILE = [/(^|\/)tests?\//, /(^|\/)test_[^/]*\.gd$/, /Test\.gd$/, /_test\.gd$/];
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Why the edits touched test code, or null. Test scripts are executed by
|
|
318
|
+
* project_tests and parsed by the import, so a fix that writes them needs the
|
|
319
|
+
* developer's --allow-tests.
|
|
320
|
+
*
|
|
321
|
+
* @returns {Promise<string|null>}
|
|
322
|
+
*/
|
|
323
|
+
export async function testFileRefusal(projectDir, files) {
|
|
324
|
+
const configured = await configuredTestDirs(projectDir);
|
|
325
|
+
const hits = files.map(posix).filter(file => TEST_FILE.some(pattern => pattern.test(file)) || configured.some(dir => file.startsWith(dir)));
|
|
326
|
+
if (hits.length === 0) return null;
|
|
327
|
+
return `the fix created or changed test files (${hits.slice(0, 5).join(', ')}); pass --allow-tests to run checks on them`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Ends every check running now. Ctrl-C uses it: a check can run for minutes. */
|
|
331
|
+
export function stopRunningChecks() {
|
|
332
|
+
stopAll();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Every process under this one, from `ps`. A process group is not enough on its own:
|
|
337
|
+
* Godot's `OS.create_process` starts a new session, which leaves the group.
|
|
338
|
+
*
|
|
339
|
+
* @param {number} pid
|
|
340
|
+
* @returns {Array<number>}
|
|
341
|
+
*/
|
|
342
|
+
export function descendantsOf(pid) {
|
|
343
|
+
try {
|
|
344
|
+
const children = new Map();
|
|
345
|
+
for (const line of execFileSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 }).split('\n')) {
|
|
346
|
+
const [child, parent] = line.trim().split(/\s+/).map(Number);
|
|
347
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
348
|
+
children.get(parent).push(child);
|
|
349
|
+
}
|
|
350
|
+
const found = [];
|
|
351
|
+
const walk = (id) => {
|
|
352
|
+
for (const child of children.get(id) || []) {
|
|
353
|
+
found.push(child);
|
|
354
|
+
walk(child);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
walk(pid);
|
|
358
|
+
return found;
|
|
359
|
+
} catch {
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** How long a process that has exited may keep its pipes open before it is done. */
|
|
365
|
+
const DRAIN_MS = 1000;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Run one process with a timeout and an output cap. Never a shell. With no
|
|
369
|
+
* `env` the child gets the minimal allowlist, never `process.env`, so no
|
|
370
|
+
* caller inherits the developer's secrets by leaving the option out.
|
|
371
|
+
*
|
|
372
|
+
* In its own process group, because killing one process does not kill what it
|
|
373
|
+
* started: `OS.create_process` in a game or an addon outlives a timeout otherwise, and
|
|
374
|
+
* holds this function's pipes open as well. The group goes when the process does.
|
|
375
|
+
*
|
|
376
|
+
* @returns {Promise<{exitCode: number|null, output: string, timedOut: boolean, error: string|null}>}
|
|
377
|
+
*/
|
|
378
|
+
export function runBounded(command, args, { cwd, env = checkEnv({ root: null }), timeoutMs, maxBytes = MAX_CHECK_OUTPUT_BYTES, spawnFn = spawnProcess }) {
|
|
379
|
+
return new Promise((resolve) => {
|
|
380
|
+
let child;
|
|
381
|
+
try {
|
|
382
|
+
child = spawnFn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, detached: process.platform !== 'win32' });
|
|
383
|
+
} catch (error) {
|
|
384
|
+
resolve({ exitCode: null, output: '', timedOut: false, error: error.message });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const chunks = [];
|
|
388
|
+
let size = 0;
|
|
389
|
+
let truncated = false;
|
|
390
|
+
const take = (chunk) => {
|
|
391
|
+
if (size >= maxBytes) {
|
|
392
|
+
truncated = true;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const buffer = Buffer.from(chunk);
|
|
396
|
+
const room = maxBytes - size;
|
|
397
|
+
chunks.push(buffer.subarray(0, room));
|
|
398
|
+
size += Math.min(room, buffer.length);
|
|
399
|
+
if (buffer.length > room) truncated = true;
|
|
400
|
+
};
|
|
401
|
+
child.stdout?.on('data', take);
|
|
402
|
+
child.stderr?.on('data', take);
|
|
403
|
+
let settled = false;
|
|
404
|
+
let timedOut = false;
|
|
405
|
+
let stopped = false;
|
|
406
|
+
const killGroup = () => {
|
|
407
|
+
try {
|
|
408
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
409
|
+
else child.kill('SIGKILL');
|
|
410
|
+
} catch {
|
|
411
|
+
// Already gone.
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
// While it still runs, its descendants can still be named.
|
|
415
|
+
const killTree = () => {
|
|
416
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
417
|
+
for (const pid of descendantsOf(child.pid)) {
|
|
418
|
+
try {
|
|
419
|
+
process.kill(pid, 'SIGKILL');
|
|
420
|
+
} catch {
|
|
421
|
+
// Already gone.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
killGroup();
|
|
426
|
+
};
|
|
427
|
+
const untrack = track({
|
|
428
|
+
stop: () => {
|
|
429
|
+
stopped = true;
|
|
430
|
+
killTree();
|
|
431
|
+
done(null, 'stopped');
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
const done = (exitCode, error = null) => {
|
|
435
|
+
if (settled) return;
|
|
436
|
+
settled = true;
|
|
437
|
+
clearTimeout(timer);
|
|
438
|
+
clearTimeout(drain);
|
|
439
|
+
untrack();
|
|
440
|
+
const output = Buffer.concat(chunks).toString('utf8') + (truncated ? '\n(output cut at the cap)' : '');
|
|
441
|
+
resolve({ exitCode, output, timedOut, error });
|
|
442
|
+
};
|
|
443
|
+
let drain = null;
|
|
444
|
+
const timer = setTimeout(() => {
|
|
445
|
+
timedOut = true;
|
|
446
|
+
killTree();
|
|
447
|
+
done(null, `timed out after ${Math.round(timeoutMs / 1000)} s`);
|
|
448
|
+
}, timeoutMs);
|
|
449
|
+
timer.unref?.();
|
|
450
|
+
child.on('error', error => done(null, error.message));
|
|
451
|
+
child.on('exit', (code) => {
|
|
452
|
+
// Whatever it left behind goes now, which is what lets the pipes close.
|
|
453
|
+
killGroup();
|
|
454
|
+
if (!stopped) drain = setTimeout(() => done(code), DRAIN_MS);
|
|
455
|
+
});
|
|
456
|
+
child.on('close', code => done(code));
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A plain copy of the project for one check: no addon injected, no
|
|
462
|
+
* `.git`, no journals.
|
|
463
|
+
*
|
|
464
|
+
* @param {string} projectDir
|
|
465
|
+
* @returns {Promise<{root: string, copy: string}>}
|
|
466
|
+
*/
|
|
467
|
+
export async function copyForCheck(projectDir, { excludes = CHECK_COPY_EXCLUDES } = {}) {
|
|
468
|
+
await measureProject(projectDir, { excludes: [...excludes] });
|
|
469
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ravensight-check-'));
|
|
470
|
+
await fs.chmod(root, 0o700);
|
|
471
|
+
const copy = path.join(root, 'project');
|
|
472
|
+
// No link survives: an import writes `.import` files beside the art it finds, and
|
|
473
|
+
// through a link that is the developer's own folder. And no credential file: game
|
|
474
|
+
// code runs on this copy, and what a failing check prints goes to the model.
|
|
475
|
+
try {
|
|
476
|
+
await copyProjectTree(projectDir, copy, { excludes, skip: source => isSecretPath(path.relative(projectDir, source)) });
|
|
477
|
+
} catch (error) {
|
|
478
|
+
// A half written copy has no owner yet, so it goes here. A full disk ends up here.
|
|
479
|
+
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
return { root, copy };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The checks for one fix, bound to one project and one Godot binary.
|
|
487
|
+
*
|
|
488
|
+
* `run(name)` always resolves, with a result whose `status` is `passed`,
|
|
489
|
+
* `failed` or `unavailable`. Unavailable is not failed: a project with no
|
|
490
|
+
* tests, or a machine with no Godot, is told so rather than blocked.
|
|
491
|
+
*
|
|
492
|
+
* @param {Object} args
|
|
493
|
+
* @param {string|null} args.projectDir the Godot project, or null when there is none
|
|
494
|
+
* @param {string|null} [args.godotPath] an explicit binary
|
|
495
|
+
* @param {() => Promise<Array<string>>} [args.changedFiles] project relative paths changed so far
|
|
496
|
+
* @returns {{available: boolean, run: (name: string) => Promise<Object>, results: Array<Object>}}
|
|
497
|
+
*/
|
|
498
|
+
export function createCheckRunner({
|
|
499
|
+
projectDir,
|
|
500
|
+
godotPath = null,
|
|
501
|
+
changedFiles = async () => [],
|
|
502
|
+
env = process.env,
|
|
503
|
+
timeouts = CHECK_TIMEOUTS_MS,
|
|
504
|
+
spawnFn = spawnProcess,
|
|
505
|
+
detect = detectGodotBinary,
|
|
506
|
+
copy = copyForCheck,
|
|
507
|
+
now = () => Date.now(),
|
|
508
|
+
allowToolScripts = false,
|
|
509
|
+
allowTests = false,
|
|
510
|
+
sandbox = undefined,
|
|
511
|
+
platform = process.platform
|
|
512
|
+
}) {
|
|
513
|
+
const results = [];
|
|
514
|
+
let sandboxPath = sandbox;
|
|
515
|
+
let binary;
|
|
516
|
+
|
|
517
|
+
async function godot() {
|
|
518
|
+
if (binary !== undefined) return binary;
|
|
519
|
+
try {
|
|
520
|
+
const found = await detect({ explicitPath: godotPath || undefined, projectDir, env });
|
|
521
|
+
binary = found.path;
|
|
522
|
+
} catch {
|
|
523
|
+
binary = null;
|
|
524
|
+
}
|
|
525
|
+
return binary;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function unavailable(name, why, started) {
|
|
529
|
+
return { name, status: 'unavailable', ok: null, durationMs: now() - started, errors: [], detail: why, output: '' };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* One Godot process for a check: the minimal environment rooted in the
|
|
534
|
+
* check's temp root, and on macOS inside sandbox-exec when it exists.
|
|
535
|
+
*/
|
|
536
|
+
async function launch(binaryPath, args, made, timeoutMs) {
|
|
537
|
+
if (sandboxPath === undefined) sandboxPath = await detectSandbox({ platform });
|
|
538
|
+
const environment = checkEnv({ root: made.root, env, platform });
|
|
539
|
+
await makeEnvDirs(environment);
|
|
540
|
+
if (!sandboxPath) return runBounded(binaryPath, args, { cwd: made.copy, env: environment, timeoutMs, spawnFn });
|
|
541
|
+
const real = await fs.realpath(made.root).catch(() => made.root);
|
|
542
|
+
return runBounded(sandboxPath, ['-p', sandboxProfile([made.root, real], { home: os.homedir() }), binaryPath, ...args], { cwd: made.copy, env: environment, timeoutMs, spawnFn });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function importInto(made, binaryPath, deadline) {
|
|
546
|
+
return launch(binaryPath, ['--headless', '--path', made.copy, '--import'], made, deadline);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function runOne(name) {
|
|
550
|
+
const started = now();
|
|
551
|
+
if (!CHECK_NAMES.includes(name)) {
|
|
552
|
+
return { name: String(name), status: 'refused', ok: null, durationMs: 0, errors: [], detail: `there is no check called ${name}; the checks are ${CHECK_NAMES.join(', ')}`, output: '' };
|
|
553
|
+
}
|
|
554
|
+
if (!projectDir || !(await isGodotProject(projectDir))) return unavailable(name, 'there is no Godot project to check', started);
|
|
555
|
+
if (name === 'project_tests' && !allowTests) {
|
|
556
|
+
return unavailable(name, 'project tests run only when the developer allows them with --allow-tests', started);
|
|
557
|
+
}
|
|
558
|
+
// Every check imports the project, and an import runs editor-side code.
|
|
559
|
+
// An edit to that code, or to tests, is not run without the developer's say.
|
|
560
|
+
const changed = await changedFiles();
|
|
561
|
+
const refusal = (allowToolScripts ? null : await toolScriptRefusal(projectDir, changed))
|
|
562
|
+
|| (allowTests ? null : await testFileRefusal(projectDir, changed));
|
|
563
|
+
if (refusal) {
|
|
564
|
+
return { name, status: 'check_refused', ok: false, durationMs: now() - started, errors: [], detail: refusal, output: '' };
|
|
565
|
+
}
|
|
566
|
+
const binaryPath = await godot();
|
|
567
|
+
if (!binaryPath) return unavailable(name, 'no Godot 4.2+ binary was found; pass --godot-path or set RAVENSIGHT_GODOT', started);
|
|
568
|
+
|
|
569
|
+
let runner = null;
|
|
570
|
+
let scripts = [];
|
|
571
|
+
if (name === 'project_tests') {
|
|
572
|
+
runner = await detectTestRunner(projectDir);
|
|
573
|
+
if (!runner) return unavailable(name, 'the project ships neither GUT (addons/gut) nor gdUnit4 (addons/gdUnit4)', started);
|
|
574
|
+
}
|
|
575
|
+
if (name === 'godot_script_check') {
|
|
576
|
+
scripts = changed.filter(file => file.endsWith('.gd') && !file.startsWith('..'));
|
|
577
|
+
if (scripts.length === 0) return unavailable(name, 'no .gd file has changed', started);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let made;
|
|
581
|
+
try {
|
|
582
|
+
made = await copy(projectDir);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
return { name, status: 'failed', ok: false, durationMs: now() - started, errors: [], detail: `the project could not be copied: ${error.message}`, output: '' };
|
|
585
|
+
}
|
|
586
|
+
const release = track({ root: made.root });
|
|
587
|
+
try {
|
|
588
|
+
const budget = timeouts[name];
|
|
589
|
+
const imported = await importInto(made, binaryPath, budget);
|
|
590
|
+
let output = imported.output;
|
|
591
|
+
let exitCode = imported.exitCode;
|
|
592
|
+
let failure = imported.error;
|
|
593
|
+
if (name === 'godot_script_check' && !failure) {
|
|
594
|
+
const autoloads = autoloadNames(await fs.readFile(path.join(made.copy, 'project.godot'), 'utf8').catch(() => ''));
|
|
595
|
+
for (const script of scripts) {
|
|
596
|
+
const remaining = Math.max(1000, budget - (now() - started));
|
|
597
|
+
const checked = await launch(binaryPath, ['--headless', '--path', made.copy, '--check-only', '--script', `res://${script.split(path.sep).join('/')}`], made, remaining);
|
|
598
|
+
output = checked.output;
|
|
599
|
+
if (checked.error) failure = checked.error;
|
|
600
|
+
const reported = parseGodotErrors(checked.output);
|
|
601
|
+
const errors = withoutAutoloadAlarms(reported, autoloads);
|
|
602
|
+
// Only the autoload alarm explains a non-zero exit with nothing left.
|
|
603
|
+
if (reported.length > 0 && errors.length === 0) continue;
|
|
604
|
+
if (errors.length > 0 || checked.exitCode !== 0) {
|
|
605
|
+
exitCode = checked.exitCode;
|
|
606
|
+
output = `${script}:\n${checked.output}`;
|
|
607
|
+
const result = finish(name, started, { exitCode: checked.exitCode, output, failure, errors });
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return finish(name, started, { exitCode: 0, output: `${scripts.length} script(s) parsed: ${scripts.join(', ')}`, failure, errors: [] });
|
|
612
|
+
}
|
|
613
|
+
if (name === 'project_tests' && !failure) {
|
|
614
|
+
const remaining = Math.max(1000, budget - (now() - started));
|
|
615
|
+
const tested = await launch(binaryPath, testArgs(runner, made.copy), made, remaining);
|
|
616
|
+
return finish(name, started, { exitCode: tested.exitCode, output: tested.output, failure: tested.error, errors: parseGodotErrors(tested.output), runner });
|
|
617
|
+
}
|
|
618
|
+
return finish(name, started, { exitCode, output, failure, errors: parseGodotErrors(output) });
|
|
619
|
+
} finally {
|
|
620
|
+
await fs.rm(made.root, { recursive: true, force: true }).catch(() => {});
|
|
621
|
+
release();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function finish(name, started, { exitCode, output, failure, errors, runner = null }) {
|
|
626
|
+
const ok = !failure && exitCode === 0 && errors.length === 0;
|
|
627
|
+
const detail = failure
|
|
628
|
+
? failure
|
|
629
|
+
: errors.length > 0
|
|
630
|
+
? `${errors.length} script error(s)`
|
|
631
|
+
: exitCode === 0 ? 'no errors' : `exit ${exitCode}`;
|
|
632
|
+
return {
|
|
633
|
+
name,
|
|
634
|
+
status: ok ? 'passed' : 'failed',
|
|
635
|
+
ok,
|
|
636
|
+
durationMs: now() - started,
|
|
637
|
+
errors,
|
|
638
|
+
detail,
|
|
639
|
+
output: String(output || '').replace(ANSI, ''),
|
|
640
|
+
...(runner ? { runner } : {})
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
get available() {
|
|
646
|
+
return Boolean(projectDir);
|
|
647
|
+
},
|
|
648
|
+
results,
|
|
649
|
+
async run(name) {
|
|
650
|
+
const result = await runOne(name);
|
|
651
|
+
results.push(result);
|
|
652
|
+
return result;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* A check result as a model turn reads it: the verdict, the errors, and the
|
|
659
|
+
* tail of the output, capped.
|
|
660
|
+
*
|
|
661
|
+
* @param {Object} result
|
|
662
|
+
* @returns {string}
|
|
663
|
+
*/
|
|
664
|
+
export function checkResultText(result) {
|
|
665
|
+
const head = `${result.name}: ${result.status} (${result.detail}, ${Math.round((result.durationMs || 0) / 100) / 10} s)`;
|
|
666
|
+
const errors = result.errors && result.errors.length > 0 ? `\nerrors:\n${result.errors.map(line => `- ${line}`).join('\n')}` : '';
|
|
667
|
+
const room = Math.max(0, MAX_CHECK_RESULT_CHARS - head.length - errors.length - 20);
|
|
668
|
+
const output = result.status === 'failed' && !errors && result.output ? `\noutput (tail):\n${result.output.slice(-room)}` : '';
|
|
669
|
+
return `${head}${errors}${output}`.slice(0, MAX_CHECK_RESULT_CHARS);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The repo tools plus `run_check`, as one tool surface for `fix.apply`.
|
|
674
|
+
*
|
|
675
|
+
* A model may run at most `maxRuns` checks per apply turn set: each can take
|
|
676
|
+
* minutes of the developer's machine.
|
|
677
|
+
*
|
|
678
|
+
* @param {Object} repo a `createRepoTools` surface
|
|
679
|
+
* @param {Object} checks a `createCheckRunner` surface
|
|
680
|
+
* @param {{maxRuns?: number}} [options]
|
|
681
|
+
* @returns {Object}
|
|
682
|
+
*/
|
|
683
|
+
export function withRunCheck(repo, checks, { maxRuns = 6 } = {}) {
|
|
684
|
+
let runs = 0;
|
|
685
|
+
return {
|
|
686
|
+
tools: [...repo.tools, RUN_CHECK_TOOL],
|
|
687
|
+
get written() {
|
|
688
|
+
return repo.written;
|
|
689
|
+
},
|
|
690
|
+
async execute(toolUse) {
|
|
691
|
+
if (toolUse.name !== 'run_check') return repo.execute(toolUse);
|
|
692
|
+
const answer = text => ({ result: { type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text }], ...(text.startsWith('refused') ? { is_error: true } : {}) } });
|
|
693
|
+
const name = toolUse.input && toolUse.input.check;
|
|
694
|
+
if (!CHECK_NAMES.includes(name)) return answer(`refused: the checks are ${CHECK_NAMES.join(', ')}`);
|
|
695
|
+
if (runs >= maxRuns) return answer(`refused: ${maxRuns} checks have run this turn set, which is the limit`);
|
|
696
|
+
runs += 1;
|
|
697
|
+
const result = await checks.run(name);
|
|
698
|
+
const out = answer(checkResultText(result));
|
|
699
|
+
if (result.status === 'failed') out.result.is_error = true;
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export default { createCheckRunner, parseGodotErrors, detectTestRunner, withRunCheck, checkResultText, RUN_CHECK_TOOL, CHECK_NAMES };
|